diff --git a/.phan/config.php b/.phan/config.php index 38c3bc405..3a6e8ee8a 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -280,7 +280,11 @@ // Add any issue types (such as `'PhanUndeclaredMethod'`) // to this deny-list to inhibit them from being reported. - 'suppress_issue_types' => [], + 'suppress_issue_types' => [ + 'PhanAccessClassInternal', + 'PhanAccessMethodInternal', + 'PhanAccessPropertyInternal', + ], // A regular expression to match files to be excluded // from parsing and analysis and will not be read at all. diff --git a/Makefile b/Makefile index 1ac8328fb..30cede5de 100644 --- a/Makefile +++ b/Makefile @@ -65,9 +65,14 @@ smoke-test-exporter-examples: FORCE ## Run (some) exporter smoke test examples smoke-test-collector-integration: ## Run smoke test collector integration docker-compose -f docker-compose.collector.yaml up -d --remove-orphans # This is slow because it's building the image from scratch (and parts of that, like installing the gRPC extension, are slow) -# This can be sped up by switching to the pre-built images hosted on ghcr.io (and referenced in other docker-compose**.yaml files) +# This can be sped up by switching to the pre-built images hosted on ghcr.io (and referenced in other docker-compose**.yaml files) docker-compose -f docker-compose.collector.yaml run -e OTEL_EXPORTER_OTLP_ENDPOINT=collector:4317 --rm php php ./examples/traces/features/exporters/otlp_grpc.php docker-compose -f docker-compose.collector.yaml stop +smoke-test-collector-metrics-integration: + docker-compose -f docker-compose.collector.yaml up -d --force-recreate collector + COMPOSE_IGNORE_ORPHANS=TRUE docker-compose -f docker-compose.yaml run --rm php php ./examples/metrics/features/exporters/otlp.php + docker-compose -f docker-compose.collector.yaml logs collector + docker-compose -f docker-compose.collector.yaml stop collector smoke-test-prometheus-example: metrics-prometheus-example stop-prometheus metrics-prometheus-example: @docker-compose -f docker-compose.prometheus.yaml -p opentelemetry-php_metrics-prometheus-example up -d web diff --git a/composer.json b/composer.json index 8e1b9fea9..933e21ed6 100644 --- a/composer.json +++ b/composer.json @@ -88,6 +88,7 @@ "php-http/mock-client": "^1.5", "phpbench/phpbench": "^1.2", "phpmetrics/phpmetrics": "^2.7", + "phpspec/prophecy-phpunit": "^2.0", "phpstan/phpstan": "^1.1", "phpstan/phpstan-mockery": "^1.0", "phpstan/phpstan-phpunit": "^1.0", @@ -100,6 +101,7 @@ "symfony/http-client": "^5.2" }, "suggest": { + "ext-gmp": "To support unlimited number of synchronous metric readers", "ext-grpc": "To use the OTLP GRPC Exporter", "ext-protobuf": "For more performant protobuf/grpc exporting", "ext-sockets": "To use the Thrift UDP Exporter for the Jaeger Agent" diff --git a/examples/metrics/basic.php b/examples/metrics/basic.php new file mode 100644 index 000000000..78a86c5e3 --- /dev/null +++ b/examples/metrics/basic.php @@ -0,0 +1,76 @@ +register( + new InstrumentNameCriteria('http.server.duration'), + ViewTemplate::create() + ->withAttributeKeys(['http.method', 'http.status_code']) + ->withAggregation(new ExplicitBucketHistogramAggregation([])), +); + +$meterProvider = new MeterProvider( + null, + ResourceInfoFactory::emptyResource(), + $clock, + Attributes::factory(), + new InstrumentationScopeFactory(Attributes::factory()), + [$reader], + $views, + new WithSampledTraceExemplarFilter(), + new ImmediateStalenessHandlerFactory(), +); + +$serverDuration = $meterProvider->getMeter('io.opentelemetry.contrib.php')->createHistogram( + 'http.server.duration', + 'ms', + 'measures the duration inbound HTTP requests', +); + +// During the time range (T0, T1]: +$serverDuration->record(50, ['http.method' => 'GET', 'http.status_code' => 200]); +$serverDuration->record(100, ['http.method' => 'GET', 'http.status_code' => 200]); +$serverDuration->record(1, ['http.method' => 'GET', 'http.status_code' => 500]); +$reader->collect(); + +// During the time range (T1, T2]: +$reader->collect(); + +// During the time range (T2, T3]: +$serverDuration->record(5, ['http.method' => 'GET', 'http.status_code' => 500]); +$serverDuration->record(2, ['http.method' => 'GET', 'http.status_code' => 500]); +$reader->collect(); + +// During the time range (T3, T4]: +$serverDuration->record(100, ['http.method' => 'GET', 'http.status_code' => 200]); +$reader->collect(); + +// During the time range (T4, T5]: +$serverDuration->record(100, ['http.method' => 'GET', 'http.status_code' => 200]); +$serverDuration->record(30, ['http.method' => 'GET', 'http.status_code' => 200]); +$serverDuration->record(50, ['http.method' => 'GET', 'http.status_code' => 200]); +$reader->collect(); + +$meterProvider->shutdown(); diff --git a/examples/metrics/features/exporters/otlp.php b/examples/metrics/features/exporters/otlp.php new file mode 100644 index 000000000..2ddfc37f2 --- /dev/null +++ b/examples/metrics/features/exporters/otlp.php @@ -0,0 +1,75 @@ +getMeter('io.opentelemetry.contrib.php'); +$meter + ->createObservableUpDownCounter('process.memory.usage', 'By', 'The amount of physical memory in use.') + ->observe(static function (ObserverInterface $observer): void { + $observer->observe(memory_get_usage(true)); + }); + +$serverDuration = $meter + ->createHistogram('http.server.duration', 'ms', 'measures the duration inbound HTTP requests'); + +// During the time range (T0, T1]: +$serverDuration->record(50, ['http.method' => 'GET', 'http.status_code' => 200]); +$serverDuration->record(100, ['http.method' => 'GET', 'http.status_code' => 200]); +$serverDuration->record(1, ['http.method' => 'GET', 'http.status_code' => 500]); +$reader->collect(); + +// During the time range (T1, T2]: +$reader->collect(); + +// During the time range (T2, T3]: +$serverDuration->record(5, ['http.method' => 'GET', 'http.status_code' => 500]); +$serverDuration->record(2, ['http.method' => 'GET', 'http.status_code' => 500]); +$reader->collect(); + +// During the time range (T3, T4]: +$serverDuration->record(100, ['http.method' => 'GET', 'http.status_code' => 200]); +$reader->collect(); + +// During the time range (T4, T5]: +$serverDuration->record(100, ['http.method' => 'GET', 'http.status_code' => 200]); +$serverDuration->record(30, ['http.method' => 'GET', 'http.status_code' => 200]); +$serverDuration->record(50, ['http.method' => 'GET', 'http.status_code' => 200]); +$reader->collect(); + +$meterProvider->shutdown(); diff --git a/examples/metrics/prometheus/prometheus_metrics_example.php b/examples/metrics/prometheus/prometheus_metrics_example.php index 7e56951b4..c52c44868 100644 --- a/examples/metrics/prometheus/prometheus_metrics_example.php +++ b/examples/metrics/prometheus/prometheus_metrics_example.php @@ -4,9 +4,6 @@ require __DIR__ . '/../../../vendor/autoload.php'; -use OpenTelemetry\Contrib\Prometheus\PrometheusExporter; -use OpenTelemetry\SDK\Metrics\Counter; -use Prometheus\CollectorRegistry; use Prometheus\Storage\Redis; Redis::setDefaultOptions( @@ -20,10 +17,4 @@ ] ); -$counter = new Counter('opentelemetry_prometheus_counter', 'Just a quick measurement'); - -$counter->increment(); - -$exporter = new PrometheusExporter(CollectorRegistry::getDefault()); - -$exporter->export([$counter]); +trigger_error('Prometheus exporter currently not supported', E_USER_WARNING); diff --git a/files/collector/otel-collector-config.yml b/files/collector/otel-collector-config.yml index 7fe033bdf..89da7105e 100644 --- a/files/collector/otel-collector-config.yml +++ b/files/collector/otel-collector-config.yml @@ -30,3 +30,6 @@ service: receivers: [otlp, zipkin] exporters: [zipkin, logging, newrelic] processors: [batch] + metrics: + receivers: [otlp] + exporters: [logging] diff --git a/src/API/Common/Instrumentation/InstrumentationTrait.php b/src/API/Common/Instrumentation/InstrumentationTrait.php index 42aec8aab..a07b27548 100644 --- a/src/API/Common/Instrumentation/InstrumentationTrait.php +++ b/src/API/Common/Instrumentation/InstrumentationTrait.php @@ -6,7 +6,7 @@ use OpenTelemetry\API\Metrics\MeterInterface; use OpenTelemetry\API\Metrics\MeterProviderInterface; -use OpenTelemetry\API\Metrics\NoopMeter; +use OpenTelemetry\API\Metrics\Noop\NoopMeter; use OpenTelemetry\API\Trace\NoopTracer; use OpenTelemetry\API\Trace\TracerInterface; use OpenTelemetry\API\Trace\TracerProviderInterface; @@ -177,6 +177,7 @@ private function initDefaults(): void { $this->propagator = new NullPropagator(); $this->tracer = new NoopTracer(); + /** @phan-suppress-next-line PhanAccessMethodInternal */ $this->meter = new NoopMeter(); $this->logger = new NullLogger(); } diff --git a/src/API/Metrics/CounterInterface.php b/src/API/Metrics/CounterInterface.php index 286c8ad36..a6e48d05d 100644 --- a/src/API/Metrics/CounterInterface.php +++ b/src/API/Metrics/CounterInterface.php @@ -4,30 +4,18 @@ namespace OpenTelemetry\API\Metrics; -interface CounterInterface extends MetricInterface -{ - /** - * Adds value to the counter - * - * @access public - * @param int $value - * @return self - */ - public function add(int $value): CounterInterface; +use OpenTelemetry\Context\Context; - /** - * Increments value - * - * @access public - * @return self - */ - public function increment(): CounterInterface; +interface CounterInterface +{ /** - * Gets the value + * @param float|int $amount non-negative amount to increment by + * @param iterable $attributes + * attributes of the data point + * @param Context|false|null $context execution context * - * @access public - * @return int + * @see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/api.md#add */ - public function getValue(): int; + public function add($amount, iterable $attributes = [], $context = null): void; } diff --git a/src/API/Metrics/ExporterInterface.php b/src/API/Metrics/ExporterInterface.php deleted file mode 100644 index 82df89246..000000000 --- a/src/API/Metrics/ExporterInterface.php +++ /dev/null @@ -1,26 +0,0 @@ - $metrics - * @return int - * - * todo Should we pass a result callback in the 2nd parameter like in JavaScript implementation? - */ - public function export(iterable $metrics): int; -} diff --git a/src/API/Metrics/HistogramInterface.php b/src/API/Metrics/HistogramInterface.php new file mode 100644 index 000000000..532128e3b --- /dev/null +++ b/src/API/Metrics/HistogramInterface.php @@ -0,0 +1,21 @@ + $attributes + * attributes of the data point + * @param Context|false|null $context execution context + * + * @see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/api.md#record + */ + public function record($amount, iterable $attributes = [], $context = null): void; +} diff --git a/src/API/Metrics/LabelableMetricInterfaceInterface.php b/src/API/Metrics/LabelableMetricInterfaceInterface.php deleted file mode 100644 index 467136635..000000000 --- a/src/API/Metrics/LabelableMetricInterfaceInterface.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ - public function getLabels(): array; - - /** - * Set $labels - * - * @param array $labels - * @return self - */ - public function setLabels(array $labels); - - /** - * Set $labels - * - * @param string $label - * @return self - */ - public function addLabel(string $label); -} diff --git a/src/API/Metrics/MeterInterface.php b/src/API/Metrics/MeterInterface.php index e0186b744..6e06d9085 100644 --- a/src/API/Metrics/MeterInterface.php +++ b/src/API/Metrics/MeterInterface.php @@ -6,47 +6,106 @@ interface MeterInterface { + + /** + * Creates a `Counter`. + * + * @param string $name name of the instrument + * @param string|null $unit unit of measure + * @param string|null $description description of the instrument + * @return CounterInterface created instrument + * + * @see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/api.md#counter-creation + */ + public function createCounter( + string $name, + ?string $unit = null, + ?string $description = null + ): CounterInterface; + /** - * Returns the meter name. + * Creates an `ObservableCounter`. * - * @return string + * @param string $name name of the instrument + * @param string|null $unit unit of measure + * @param string|null $description description of the instrument + * @param callable ...$callbacks responsible for reporting measurements + * @return ObservableCounterInterface created instrument + * + * @see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/api.md#asynchronous-counter-creation */ - public function getName(): string; + public function createObservableCounter( + string $name, + ?string $unit = null, + ?string $description = null, + callable ...$callbacks + ): ObservableCounterInterface; /** - * Returns the meter version. + * Creates a `Histogram`. + * + * @param string $name name of the instrument + * @param string|null $unit unit of measure + * @param string|null $description description of the instrument + * @return HistogramInterface created instrument * - * @return string Metric version + * @see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/api.md#histogram-creation */ - public function getVersion(): string; + public function createHistogram( + string $name, + ?string $unit = null, + ?string $description = null + ): HistogramInterface; /** - * Creates a Counter metric instrument. + * Creates an `ObservableGauge`. * - * @param string $name (required) - Counter name - * @param string $description (optional) - Counter description + * @param string $name name of the instrument + * @param string|null $unit unit of measure + * @param string|null $description description of the instrument + * @param callable ...$callbacks responsible for reporting measurements + * @return ObservableGaugeInterface created instrument * - * @return CounterInterface + * @see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/api.md#asynchronous-gauge-creation */ - public function newCounter(string $name, string $description): CounterInterface; + public function createObservableGauge( + string $name, + ?string $unit = null, + ?string $description = null, + callable ...$callbacks + ): ObservableGaugeInterface; /** - * Creates an UpDownCounter metric instrument. + * Creates an `UpDownCounter`. * - * @param string $name (required) - UpDownCounter name - * @param string $description (optional) - UpDownCounter description + * @param string $name name of the instrument + * @param string|null $unit unit of measure + * @param string|null $description description of the instrument + * @return UpDownCounterInterface created instrument * - * @return UpDownCounterInterface + * @see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/api.md#updowncounter-creation */ - public function newUpDownCounter(string $name, string $description): UpDownCounterInterface; + public function createUpDownCounter( + string $name, + ?string $unit = null, + ?string $description = null + ): UpDownCounterInterface; /** - * Creates a ValueRecorder metric instrument. + * Creates an `ObservableUpDownCounter`. * - * @param string $name (required) - ValueRecorder name - * @param string $description (optional) - ValueRecorder description + * @param string $name name of the instrument + * @param string|null $unit unit of measure + * @param string|null $description description of the instrument + * @param callable ...$callbacks responsible for reporting measurements + * @return ObservableUpDownCounterInterface created instrument * - * @return ValueRecorderInterface + * @see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/api.md#asynchronous-updowncounter-creation */ - public function newValueRecorder(string $name, string $description): ValueRecorderInterface; + public function createObservableUpDownCounter( + string $name, + ?string $unit = null, + ?string $description = null, + callable ...$callbacks + ): ObservableUpDownCounterInterface; } diff --git a/src/API/Metrics/MeterProviderInterface.php b/src/API/Metrics/MeterProviderInterface.php index 74a0a6085..f8fa07a2e 100644 --- a/src/API/Metrics/MeterProviderInterface.php +++ b/src/API/Metrics/MeterProviderInterface.php @@ -6,16 +6,23 @@ interface MeterProviderInterface { + /** - * @access public - * @param string $name - (required) - This name must identify the instrumentation scope - * (e.g. io.opentelemetry.contrib.mongodb) and not the instrumented library. - * In case an invalid name (null or empty string) is specified, a working default Meter implementation is returned - * as a fallback rather than returning null or throwing an exception. - * A MeterProvider could also return a no-op Meter here if application owners configure the SDK to suppress - * telemetry produced by this library. - * @param ?string $version - (optional) - Specifies the version of the instrumentation scope (e.g. semver:1.0.0) - * @return MeterInterface + * Returns a `Meter` for the given instrumentation scope. + * + * @param string $name name of the instrumentation scope + * @param string|null $version version of the instrumentation scope + * @param string|null $schemaUrl schema url to record in the emitted telemetry + * @param iterable $attributes + * instrumentation scope attributes + * @return MeterInterface meter instance for the instrumentation scope + * + * @see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/api.md#get-a-meter */ - public function getMeter(string $name, ?string $version = null): MeterInterface; + public function getMeter( + string $name, + ?string $version = null, + ?string $schemaUrl = null, + iterable $attributes = [] + ): MeterInterface; } diff --git a/src/API/Metrics/MetricInterface.php b/src/API/Metrics/MetricInterface.php deleted file mode 100644 index cf4944826..000000000 --- a/src/API/Metrics/MetricInterface.php +++ /dev/null @@ -1,32 +0,0 @@ -name; - } - - public function getDescription(): string - { - return $this->description; - } - - public function getType(): int - { - return MetricKind::COUNTER; - } - }; - - $counter->name = $name; - $counter->description = $description; - - return $counter; - } - - public function newUpDownCounter(string $name, string $description): UpDownCounterInterface - { - return new class() implements UpDownCounterInterface { - public function add($increment): int - { - return 0; - } - }; - } - - public function newValueRecorder(string $name, string $description): ValueRecorderInterface - { - $recorder = new class() implements ValueRecorderInterface { - public string $name; - public string $description; - - public function getName(): string - { - return $this->name; - } - - public function getDescription(): string - { - return $this->description; - } - public function getType(): int - { - return MetricKind::VALUE_RECORDER; - } - - public function record(float $value): void - { - } - - public function getSum(): float - { - return 0.0; - } - - public function getMin(): float - { - return 0.0; - } - - public function getMax(): float - { - return 0.0; - } - - public function getCount(): int - { - return 0; - } - }; - - $recorder->name = $name; - $recorder->description = $description; - - return $recorder; - } -} diff --git a/src/API/Metrics/NoopMeterProvider.php b/src/API/Metrics/NoopMeterProvider.php deleted file mode 100644 index 7c8ab9804..000000000 --- a/src/API/Metrics/NoopMeterProvider.php +++ /dev/null @@ -1,15 +0,0 @@ - $attributes + * attributes of the data point + */ + public function observe($amount, iterable $attributes = []): void; +} diff --git a/src/API/Metrics/UpDownCounterInterface.php b/src/API/Metrics/UpDownCounterInterface.php index e216fbea6..bd77375ab 100644 --- a/src/API/Metrics/UpDownCounterInterface.php +++ b/src/API/Metrics/UpDownCounterInterface.php @@ -4,28 +4,16 @@ namespace OpenTelemetry\API\Metrics; -/* - * Name: UpDownCounter - * Instrument kind : Synchronous additive - * Function(argument) : Add(increment) where increment is a numeric value - * Default aggregation : Sum - * Notes : Per-request, part of a non-monotonic sum - * - * UpDownCounter supports negative increments. This makes UpDownCounter - * not useful for computing a rate aggregation. It aggregates a Sum, - * only the sum is non-monotonic. It is generally useful for capturing changes - * in an amount of resources used, or any quantity that rises and falls during - * a request. - * - */ +use OpenTelemetry\Context\Context; + interface UpDownCounterInterface { + /** - * Updates counter value with the positive or negative int that is passed in. - * - * @access public - * @param int|float $increment - * @return int returns the non-monotonic sum + * @param float|int $amount amount to increment / decrement by + * @param iterable $attributes + * attributes of the data point + * @param Context|false|null $context execution context */ - public function add($increment) : int; + public function add($amount, iterable $attributes = [], $context = null): void; } diff --git a/src/API/Metrics/UpDownSumObserverInterface.php b/src/API/Metrics/UpDownSumObserverInterface.php deleted file mode 100644 index 23c1ade3f..000000000 --- a/src/API/Metrics/UpDownSumObserverInterface.php +++ /dev/null @@ -1,32 +0,0 @@ -getValues()[] = self::convertAnyValue($element); + } + $result->setArrayValue($values); + + break; + case is_int($value): + $result->setIntValue($value); + + break; + case is_bool($value): + $result->setBoolValue($value); + + break; + case is_float($value): + $result->setDoubleValue($value); + + break; + case is_string($value): + $result->setStringValue($value); + + break; + } + + return $result; + } +} diff --git a/src/Contrib/Otlp/MetricConverter.php b/src/Contrib/Otlp/MetricConverter.php new file mode 100644 index 000000000..4a50f048f --- /dev/null +++ b/src/Contrib/Otlp/MetricConverter.php @@ -0,0 +1,257 @@ + $batch + */ + public function convert(iterable $batch): ExportMetricsServiceRequest + { + $pExportMetricsServiceRequest = new ExportMetricsServiceRequest(); + + $resourceMetrics = []; + $resourceCache = []; + $scopeMetrics = []; + $scopeCache = []; + foreach ($batch as $metric) { + $resource = $metric->resource; + $instrumentationScope = $metric->instrumentationScope; + + $resourceId = $resourceCache[spl_object_id($resource)] ??= serialize([ + $resource->getSchemaUrl(), + $resource->getAttributes()->toArray(), + $resource->getAttributes()->getDroppedAttributesCount(), + ]); + $instrumentationScopeId = $scopeCache[spl_object_id($instrumentationScope)] ??= serialize([ + $instrumentationScope->getName(), + $instrumentationScope->getVersion(), + $instrumentationScope->getSchemaUrl(), + $instrumentationScope->getAttributes()->toArray(), + $instrumentationScope->getAttributes()->getDroppedAttributesCount(), + ]); + + if (($pResourceMetrics = $resourceMetrics[$resourceId] ?? null) === null) { + /** @psalm-suppress InvalidArgument */ + $pExportMetricsServiceRequest->getResourceMetrics()[] + = $resourceMetrics[$resourceId] + = $pResourceMetrics + = $this->convertResourceMetrics($resource); + } + + if (($pScopeMetrics = $scopeMetrics[$resourceId][$instrumentationScopeId] ?? null) === null) { + /** @psalm-suppress InvalidArgument */ + $pResourceMetrics->getScopeMetrics()[] + = $scopeMetrics[$resourceId][$instrumentationScopeId] + = $pScopeMetrics + = $this->convertScopeMetrics($instrumentationScope); + } + + /** @psalm-suppress InvalidArgument */ + $pScopeMetrics->getMetrics()[] = $this->convertMetric($metric); + } + + return $pExportMetricsServiceRequest; + } + + private function convertResourceMetrics(SDK\Resource\ResourceInfo $resource): ResourceMetrics + { + $pResourceMetrics = new ResourceMetrics(); + $pResource = new Resource_(); + $this->setAttributes($pResource, $resource->getAttributes()); + $pResourceMetrics->setResource($pResource); + $pResourceMetrics->setSchemaUrl((string) $resource->getSchemaUrl()); + + return $pResourceMetrics; + } + + private function convertScopeMetrics(SDK\Common\Instrumentation\InstrumentationScopeInterface $instrumentationScope): ScopeMetrics + { + $pScopeMetrics = new ScopeMetrics(); + $pInstrumentationScope = new InstrumentationScope(); + $pInstrumentationScope->setName($instrumentationScope->getName()); + $pInstrumentationScope->setVersion((string) $instrumentationScope->getVersion()); + $pScopeMetrics->setScope($pInstrumentationScope); + $pScopeMetrics->setSchemaUrl((string) $instrumentationScope->getSchemaUrl()); + + return $pScopeMetrics; + } + + private function convertMetric(SDK\Metrics\Data\Metric $metric): Metric + { + $pMetric = new Metric(); + $pMetric->setName($metric->name); + $pMetric->setDescription((string) $metric->description); + $pMetric->setUnit((string) $metric->unit); + + $data = $metric->data; + if ($data instanceof SDK\Metrics\Data\Gauge) { + $pMetric->setGauge($this->convertGauge($data)); + } + if ($data instanceof SDK\Metrics\Data\Histogram) { + $pMetric->setHistogram($this->convertHistogram($data)); + } + if ($data instanceof SDK\Metrics\Data\Sum) { + $pMetric->setSum($this->convertSum($data)); + } + + return $pMetric; + } + + private function convertTemporality($temporality): int + { + switch ($temporality) { + case SDK\Metrics\Data\Temporality::DELTA: + return AggregationTemporality::AGGREGATION_TEMPORALITY_DELTA; + case SDK\Metrics\Data\Temporality::CUMULATIVE: + return AggregationTemporality::AGGREGATION_TEMPORALITY_CUMULATIVE; + } + + // @codeCoverageIgnoreStart + return AggregationTemporality::AGGREGATION_TEMPORALITY_UNSPECIFIED; + // @codeCoverageIgnoreEnd + } + + private function convertGauge(SDK\Metrics\Data\Gauge $gauge): Gauge + { + $pGauge = new Gauge(); + foreach ($gauge->dataPoints as $dataPoint) { + /** @psalm-suppress InvalidArgument */ + $pGauge->getDataPoints()[] = $this->convertNumberDataPoint($dataPoint); + } + + return $pGauge; + } + + private function convertHistogram(SDK\Metrics\Data\Histogram $histogram): Histogram + { + $pHistogram = new Histogram(); + foreach ($histogram->dataPoints as $dataPoint) { + /** @psalm-suppress InvalidArgument */ + $pHistogram->getDataPoints()[] = $this->convertHistogramDataPoint($dataPoint); + } + $pHistogram->setAggregationTemporality($this->convertTemporality($histogram->temporality)); + + return $pHistogram; + } + + private function convertSum(SDK\Metrics\Data\Sum $sum): Sum + { + $pSum = new Sum(); + foreach ($sum->dataPoints as $dataPoint) { + /** @psalm-suppress InvalidArgument */ + $pSum->getDataPoints()[] = $this->convertNumberDataPoint($dataPoint); + } + $pSum->setAggregationTemporality($this->convertTemporality($sum->temporality)); + $pSum->setIsMonotonic($sum->monotonic); + + return $pSum; + } + + private function convertNumberDataPoint(SDK\Metrics\Data\NumberDataPoint $dataPoint): NumberDataPoint + { + $pNumberDataPoint = new NumberDataPoint(); + $this->setAttributes($pNumberDataPoint, $dataPoint->attributes); + $pNumberDataPoint->setStartTimeUnixNano($dataPoint->startTimestamp); + $pNumberDataPoint->setTimeUnixNano($dataPoint->timestamp); + if (is_int($dataPoint->value)) { + $pNumberDataPoint->setAsInt($dataPoint->value); + } + if (is_float($dataPoint->value)) { + $pNumberDataPoint->setAsDouble($dataPoint->value); + } + foreach ($dataPoint->exemplars as $exemplar) { + /** @psalm-suppress InvalidArgument */ + $pNumberDataPoint->getExemplars()[] = $this->convertExemplar($exemplar); + } + + return $pNumberDataPoint; + } + + private function convertHistogramDataPoint(SDK\Metrics\Data\HistogramDataPoint $dataPoint): HistogramDataPoint + { + $pHistogramDataPoint = new HistogramDataPoint(); + $this->setAttributes($pHistogramDataPoint, $dataPoint->attributes); + $pHistogramDataPoint->setStartTimeUnixNano($dataPoint->startTimestamp); + $pHistogramDataPoint->setTimeUnixNano($dataPoint->timestamp); + $pHistogramDataPoint->setCount($dataPoint->count); + $pHistogramDataPoint->setSum($dataPoint->sum); + /** @phpstan-ignore-next-line */ + $pHistogramDataPoint->setBucketCounts($dataPoint->bucketCounts); + /** @phpstan-ignore-next-line */ + $pHistogramDataPoint->setExplicitBounds($dataPoint->explicitBounds); + foreach ($dataPoint->exemplars as $exemplar) { + /** @psalm-suppress InvalidArgument */ + $pHistogramDataPoint->getExemplars()[] = $this->convertExemplar($exemplar); + } + + return $pHistogramDataPoint; + } + + private function convertExemplar(SDK\Metrics\Data\Exemplar $exemplar): Exemplar + { + $pExemplar = new Exemplar(); + $this->setFilteredAttributes($pExemplar, $exemplar->attributes); + $pExemplar->setTimeUnixNano($exemplar->timestamp); + $pExemplar->setSpanId(hex2bin((string) $exemplar->spanId)); + $pExemplar->setTraceId(hex2bin((string) $exemplar->traceId)); + if (is_int($exemplar->value)) { + $pExemplar->setAsInt($exemplar->value); + } + if (is_float($exemplar->value)) { + $pExemplar->setAsDouble($exemplar->value); + } + + return $pExemplar; + } + + /** + * @param Resource_|NumberDataPoint|HistogramDataPoint $pElement + */ + private function setAttributes($pElement, SDK\Common\Attribute\AttributesInterface $attributes): void + { + foreach ($attributes as $key => $value) { + /** @psalm-suppress InvalidArgument */ + $pElement->getAttributes()[] = $pAttribute = new KeyValue(); + $pAttribute->setKey($key); + $pAttribute->setValue(AttributesConverter::convertAnyValue($value)); + } + if (method_exists($pElement, 'setDroppedAttributesCount')) { + $pElement->setDroppedAttributesCount($attributes->getDroppedAttributesCount()); + } + } + + private function setFilteredAttributes(Exemplar $pElement, SDK\Common\Attribute\AttributesInterface $attributes): void + { + foreach ($attributes as $key => $value) { + /** @psalm-suppress InvalidArgument */ + $pElement->getFilteredAttributes()[] = $pAttribute = new KeyValue(); + $pAttribute->setKey($key); + $pAttribute->setValue(AttributesConverter::convertAnyValue($value)); + } + } +} diff --git a/src/Contrib/Otlp/SpanConverter.php b/src/Contrib/Otlp/SpanConverter.php index 3cca0cfe9..d8ddb32ea 100644 --- a/src/Contrib/Otlp/SpanConverter.php +++ b/src/Contrib/Otlp/SpanConverter.php @@ -8,8 +8,6 @@ use function iterator_to_array; use OpenTelemetry\API\Trace as API; use Opentelemetry\Proto\Collector\Trace\V1\ExportTraceServiceRequest; -use Opentelemetry\Proto\Common\V1\AnyValue; -use Opentelemetry\Proto\Common\V1\ArrayValue; use Opentelemetry\Proto\Common\V1\InstrumentationScope; use Opentelemetry\Proto\Common\V1\KeyValue; use Opentelemetry\Proto\Resource\V1\Resource as Resource_; @@ -111,46 +109,11 @@ private function setAttributes($pElement, AttributesInterface $attributes): void /** @psalm-suppress InvalidArgument */ $pElement->getAttributes()[] = (new KeyValue()) ->setKey($key) - ->setValue($this->convertAnyValue($value)); + ->setValue(AttributesConverter::convertAnyValue($value)); } $pElement->setDroppedAttributesCount($attributes->getDroppedAttributesCount()); } - private function convertAnyValue($value): AnyValue - { - $result = new AnyValue(); - - switch (true) { - case is_array($value): - $values = new ArrayValue(); - foreach ($value as $element) { - /** @psalm-suppress InvalidArgument */ - $values->getValues()[] = $this->convertAnyValue($element); - } - $result->setArrayValue($values); - - break; - case is_int($value): - $result->setIntValue($value); - - break; - case is_bool($value): - $result->setBoolValue($value); - - break; - case is_float($value): - $result->setDoubleValue($value); - - break; - case is_string($value): - $result->setStringValue($value); - - break; - } - - return $result; - } - private function convertSpanKind(int $kind): int { switch ($kind) { diff --git a/src/Contrib/Otlp/StreamMetricExporter.php b/src/Contrib/Otlp/StreamMetricExporter.php new file mode 100644 index 000000000..fb9e652e8 --- /dev/null +++ b/src/Contrib/Otlp/StreamMetricExporter.php @@ -0,0 +1,81 @@ +stream = is_string($stream) + ? @fopen($stream, 'ab') + : $stream; + $this->temporality = $temporality; + } + + public function temporality(MetricMetadataInterface $metric) + { + return $this->temporality ?? $metric->temporality(); + } + + public function export(iterable $batch): bool + { + if (!$this->stream) { + return false; + } + + $payload = (new MetricConverter())->convert($batch)->serializeToJsonString(); + $payload .= "\n"; + + return @fwrite($this->stream, $payload) === strlen($payload); + } + + public function shutdown(): bool + { + if (!$this->stream) { + return false; + } + + $flush = @fflush($this->stream); + $this->stream = null; + + return $flush; + } + + public function forceFlush(): bool + { + if (!$this->stream) { + return false; + } + + return @fflush($this->stream); + } +} diff --git a/src/Contrib/OtlpHttp/MetricExporter.php b/src/Contrib/OtlpHttp/MetricExporter.php new file mode 100644 index 000000000..6f56b5cd7 --- /dev/null +++ b/src/Contrib/OtlpHttp/MetricExporter.php @@ -0,0 +1,184 @@ + $headers + * @param string|Temporality|null $temporality + */ + private function __construct( + ClientInterface $client, + RequestFactoryInterface $requestFactory, + StreamFactoryInterface $streamFactory, + string $endpoint, + array $headers, + ?string $compression, + int $retryDelay, + int $maxRetries, + $temporality + ) { + $this->client = $client; + $this->requestFactory = $requestFactory; + $this->streamFactory = $streamFactory; + $this->endpoint = $endpoint; + $this->headers = $headers; + $this->compression = $compression; + $this->retryDelay = $retryDelay; + $this->maxRetries = $maxRetries; + $this->temporality = $temporality; + } + + /** + * @param string $endpoint endpoint to connect to + * @param array $headers headers to set + * @param string|null $compression compression to apply + * @param int $retryDelay retry delay in milliseconds + * @param int $maxRetries maximum number of retries + * @param string|Temporality|null $temporality temporality to use + */ + public static function create( + ClientInterface $client, + RequestFactoryInterface $requestFactory, + StreamFactoryInterface $streamFactory, + string $endpoint = 'https://localhost:4318/v1/metrics', + array $headers = [], + ?string $compression = null, + int $retryDelay = 100, + int $maxRetries = 1, + $temporality = null + ): MetricExporterInterface { + return new self( + $client, + $requestFactory, + $streamFactory, + $endpoint, + $headers, + $compression, + $retryDelay * 1000, + $maxRetries, + $temporality, + ); + } + + public function temporality(MetricMetadataInterface $metric) + { + return $this->temporality ?? $metric->temporality(); + } + + public function export(iterable $batch): bool + { + if ($this->closed) { + return false; + } + + $payload = (new MetricConverter())->convert($batch)->serializeToString(); + $request = $this->requestFactory + ->createRequest('POST', $this->endpoint) + ->withHeader('Content-Type', 'application/x-protobuf') + ; + + if ($this->compression && $encoder = self::encoder($this->compression)) { + $payload = $encoder($payload); + $request = $request->withHeader('Content-Encoding', $this->compression); + } + + foreach ($this->headers as $header => $value) { + $request = $request->withAddedHeader((string) $header, $value); + } + + for ($retries = 0;;) { + $statusCode = null; + $e = null; + + $request = $request->withBody($this->streamFactory->createStream($payload)); + + try { + $response = $this->client->sendRequest($request); + + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 300) { + return true; + } + if ($statusCode >= 400 && $statusCode < 500 && $statusCode !== 408) { + break; + } + } catch (RequestExceptionInterface $e) { + break; + } catch (ClientExceptionInterface $e) { + } + + if (++$retries >= $this->maxRetries) { + break; + } + + $wait = $this->retryDelay << $retries - 1; + /** @psalm-suppress InvalidArgument */ + usleep(rand($wait >> 1, $wait)); + } + + /** @psalm-suppress PossiblyUndefinedVariable */ + self::logError('Metric export failed', [ + 'exception' => $e, + 'status_code' => $statusCode, + ]); + + return false; + } + + public function shutdown(): bool + { + if ($this->closed) { + return false; + } + + $this->closed = true; + + return true; + } + + public function forceFlush(): bool + { + return !$this->closed; + } + + private static function encoder(string $encoding): ?callable + { + static $encoders; + $encoders ??= array_filter([ + 'gzip' => 'gzencode', + ], 'function_exists'); + + return $encoders[$encoding] ?? null; + } +} diff --git a/src/Contrib/Prometheus/PrometheusExporter.php b/src/Contrib/Prometheus/PrometheusExporter.php deleted file mode 100644 index 89c4a3f78..000000000 --- a/src/Contrib/Prometheus/PrometheusExporter.php +++ /dev/null @@ -1,54 +0,0 @@ -registry = $registry; - $this->namespace = $namespace; - } - - /** - * {@inheritDoc} - */ - protected function doExport(iterable $metrics): void - { - foreach ($metrics as $metric) { - switch (true) { - case $metric instanceof API\CounterInterface: - $this->exportCounter($metric); - - break; - default: - throw new CantBeExported('Unknown metrics type: ' . get_class($metric)); - } - } - } - - protected function exportCounter(API\CounterInterface $counter): void - { - $labels = ($counter instanceof API\LabelableMetricInterfaceInterface) ? $counter->getLabels() : []; - - $record = $this->registry->getOrRegisterCounter( - $this->namespace, - $counter->getName(), - $counter->getDescription(), - $labels - ); - - $record->incBy($counter->getValue(), $labels); - } -} diff --git a/src/SDK/Metrics/AbstractMetric.php b/src/SDK/Metrics/AbstractMetric.php deleted file mode 100644 index 8e30ddf23..000000000 --- a/src/SDK/Metrics/AbstractMetric.php +++ /dev/null @@ -1,36 +0,0 @@ -name = $name; - $this->description = $description; - } - - /** - * {@inheritDoc} - */ - public function getName(): string - { - return $this->name; - } - - /** - * {@inheritDoc} - */ - public function getDescription(): string - { - return $this->description; - } -} diff --git a/src/SDK/Metrics/Aggregation/ExplicitBucketHistogramAggregation.php b/src/SDK/Metrics/Aggregation/ExplicitBucketHistogramAggregation.php new file mode 100644 index 000000000..e345c9586 --- /dev/null +++ b/src/SDK/Metrics/Aggregation/ExplicitBucketHistogramAggregation.php @@ -0,0 +1,178 @@ + + */ +final class ExplicitBucketHistogramAggregation implements AggregationInterface +{ + /** + * @var list + * @readonly + */ + public array $boundaries; + + /** + * @param list $boundaries strictly ascending histogram bucket boundaries + */ + public function __construct(array $boundaries) + { + $this->boundaries = $boundaries; + } + + public function initialize(): ExplicitBucketHistogramSummary + { + return new ExplicitBucketHistogramSummary( + 0, + 0, + +INF, + -INF, + array_fill(0, count($this->boundaries) + 1, 0), + ); + } + + /** + * @param ExplicitBucketHistogramSummary $summary + */ + public function record($summary, $value, AttributesInterface $attributes, Context $context, int $timestamp): void + { + $boundariesCount = count($this->boundaries); + for ($i = 0; $i < $boundariesCount && $this->boundaries[$i] < $value; $i++) { + } + $summary->count++; + $summary->sum += $value; + $summary->min = self::min($value, $summary->min); + $summary->max = self::max($value, $summary->max); + $summary->buckets[$i]++; + } + + /** + * @param ExplicitBucketHistogramSummary $left + * @param ExplicitBucketHistogramSummary $right + */ + public function merge($left, $right): ExplicitBucketHistogramSummary + { + $count = $left->count + $right->count; + $sum = $left->sum + $right->sum; + $min = self::min($left->min, $right->min); + $max = self::max($left->max, $right->max); + $buckets = $right->buckets; + foreach ($left->buckets as $i => $bucketCount) { + $buckets[$i] += $bucketCount; + } + + return new ExplicitBucketHistogramSummary( + $count, + $sum, + $min, + $max, + $buckets, + ); + } + + /** + * @param ExplicitBucketHistogramSummary $left + * @param ExplicitBucketHistogramSummary $right + */ + public function diff($left, $right): ExplicitBucketHistogramSummary + { + $count = -$left->count + $right->count; + $sum = -$left->sum + $right->sum; + $min = $left->min > $right->min ? $right->min : NAN; + $max = $left->max < $right->max ? $right->max : NAN; + $buckets = $right->buckets; + foreach ($left->buckets as $i => $bucketCount) { + $buckets[$i] -= $bucketCount; + } + + return new ExplicitBucketHistogramSummary( + $count, + $sum, + $min, + $max, + $buckets, + ); + } + + /** + * @param array $summaries + */ + public function toData( + array $attributes, + array $summaries, + array $exemplars, + int $startTimestamp, + int $timestamp, + $temporality + ): Data\Histogram { + $dataPoints = []; + foreach ($attributes as $key => $dataPointAttributes) { + if ($summaries[$key]->count === 0) { + continue; + } + + $dataPoints[] = new Data\HistogramDataPoint( + $summaries[$key]->count, + $summaries[$key]->sum, + $summaries[$key]->min, + $summaries[$key]->max, + $summaries[$key]->buckets, + $this->boundaries, + $dataPointAttributes, + $startTimestamp, + $timestamp, + $exemplars[$key] ?? [], + ); + } + + return new Data\Histogram( + $dataPoints, + $temporality, + ); + } + + public function exemplarReservoir(AttributesFactoryInterface $attributesFactory): ExemplarReservoirInterface + { + return $this->boundaries + ? new HistogramBucketReservoir($attributesFactory, $this->boundaries) + : new FixedSizeReservoir($attributesFactory); + } + + /** + * @param float|int $left + * @param float|int $right + * @return float|int + */ + private static function min($left, $right) + { + /** @noinspection PhpConditionAlreadyCheckedInspection */ + return $left <= $right ? $left : ($right <= $left ? $right : NAN); + } + + /** + * @param float|int $left + * @param float|int $right + * @return float|int + */ + private static function max($left, $right) + { + /** @noinspection PhpConditionAlreadyCheckedInspection */ + return $left >= $right ? $left : ($right >= $left ? $right : NAN); + } +} diff --git a/src/SDK/Metrics/Aggregation/ExplicitBucketHistogramSummary.php b/src/SDK/Metrics/Aggregation/ExplicitBucketHistogramSummary.php new file mode 100644 index 000000000..1878a34a0 --- /dev/null +++ b/src/SDK/Metrics/Aggregation/ExplicitBucketHistogramSummary.php @@ -0,0 +1,40 @@ +count = $count; + $this->sum = $sum; + $this->min = $min; + $this->max = $max; + $this->buckets = $buckets; + } +} diff --git a/src/SDK/Metrics/Aggregation/LastValueAggregation.php b/src/SDK/Metrics/Aggregation/LastValueAggregation.php new file mode 100644 index 000000000..5352f5bce --- /dev/null +++ b/src/SDK/Metrics/Aggregation/LastValueAggregation.php @@ -0,0 +1,89 @@ + + */ +final class LastValueAggregation implements AggregationInterface +{ + public function initialize(): LastValueSummary + { + return new LastValueSummary(null, 0); + } + + /** + * @param LastValueSummary $summary + */ + public function record($summary, $value, AttributesInterface $attributes, Context $context, int $timestamp): void + { + if ($summary->value === null || $timestamp >= $summary->timestamp) { + $summary->value = $value; + $summary->timestamp = $timestamp; + } + } + + /** + * @param LastValueSummary $left + * @param LastValueSummary $right + */ + public function merge($left, $right): LastValueSummary + { + return $right->timestamp >= $left->timestamp ? $right : $left; + } + + /** + * @param LastValueSummary $left + * @param LastValueSummary $right + */ + public function diff($left, $right): LastValueSummary + { + return $right->timestamp >= $left->timestamp ? $right : $left; + } + + /** + * @param array $summaries + */ + public function toData( + array $attributes, + array $summaries, + array $exemplars, + int $startTimestamp, + int $timestamp, + $temporality + ): Data\Gauge { + $dataPoints = []; + foreach ($attributes as $key => $dataPointAttributes) { + if ($summaries[$key]->value === null) { + continue; + } + + $dataPoints[] = new Data\NumberDataPoint( + $summaries[$key]->value, + $dataPointAttributes, + $startTimestamp, + $timestamp, + $exemplars[$key] ?? [], + ); + } + + return new Data\Gauge( + $dataPoints, + ); + } + + public function exemplarReservoir(AttributesFactoryInterface $attributesFactory): ExemplarReservoirInterface + { + return new FixedSizeReservoir($attributesFactory); + } +} diff --git a/src/SDK/Metrics/Aggregation/LastValueSummary.php b/src/SDK/Metrics/Aggregation/LastValueSummary.php new file mode 100644 index 000000000..6cdb5ac9f --- /dev/null +++ b/src/SDK/Metrics/Aggregation/LastValueSummary.php @@ -0,0 +1,22 @@ +value = $value; + $this->timestamp = $timestamp; + } +} diff --git a/src/SDK/Metrics/Aggregation/SumAggregation.php b/src/SDK/Metrics/Aggregation/SumAggregation.php new file mode 100644 index 000000000..d3e927226 --- /dev/null +++ b/src/SDK/Metrics/Aggregation/SumAggregation.php @@ -0,0 +1,99 @@ + + */ +final class SumAggregation implements AggregationInterface +{ + private bool $monotonic; + + public function __construct(bool $monotonic = false) + { + $this->monotonic = $monotonic; + } + + public function initialize(): SumSummary + { + return new SumSummary(0); + } + + /** + * @param SumSummary $summary + */ + public function record($summary, $value, AttributesInterface $attributes, Context $context, int $timestamp): void + { + $summary->value += $value; + } + + /** + * @param SumSummary $left + * @param SumSummary $right + */ + public function merge($left, $right): SumSummary + { + $sum = $left->value + $right->value; + + return new SumSummary( + $sum, + ); + } + + /** + * @param SumSummary $left + * @param SumSummary $right + */ + public function diff($left, $right): SumSummary + { + $sum = -$left->value + $right->value; + + return new SumSummary( + $sum, + ); + } + + /** + * @param array $summaries + */ + public function toData( + array $attributes, + array $summaries, + array $exemplars, + int $startTimestamp, + int $timestamp, + $temporality + ): Data\Sum { + $dataPoints = []; + foreach ($attributes as $key => $dataPointAttributes) { + $dataPoints[] = new Data\NumberDataPoint( + $summaries[$key]->value, + $dataPointAttributes, + $startTimestamp, + $timestamp, + $exemplars[$key] ?? [], + ); + } + + return new Data\Sum( + $dataPoints, + $temporality, + $this->monotonic, + ); + } + + public function exemplarReservoir(AttributesFactoryInterface $attributesFactory): ExemplarReservoirInterface + { + return new FixedSizeReservoir($attributesFactory); + } +} diff --git a/src/SDK/Metrics/Aggregation/SumSummary.php b/src/SDK/Metrics/Aggregation/SumSummary.php new file mode 100644 index 000000000..9b257193c --- /dev/null +++ b/src/SDK/Metrics/Aggregation/SumSummary.php @@ -0,0 +1,20 @@ +value = $value; + } +} diff --git a/src/SDK/Metrics/AggregationInterface.php b/src/SDK/Metrics/AggregationInterface.php new file mode 100644 index 000000000..d929f0a1e --- /dev/null +++ b/src/SDK/Metrics/AggregationInterface.php @@ -0,0 +1,61 @@ + $attributes + * @psalm-param array $summaries + * @param array> $exemplars + * @param string|Temporality $temporality + */ + public function toData( + array $attributes, + array $summaries, + array $exemplars, + int $startTimestamp, + int $timestamp, + $temporality + ): DataInterface; + + public function exemplarReservoir(AttributesFactoryInterface $attributesFactory): ?ExemplarReservoirInterface; +} diff --git a/src/SDK/Metrics/AttributeProcessor/FilteredAttributeProcessor.php b/src/SDK/Metrics/AttributeProcessor/FilteredAttributeProcessor.php new file mode 100644 index 000000000..12504172d --- /dev/null +++ b/src/SDK/Metrics/AttributeProcessor/FilteredAttributeProcessor.php @@ -0,0 +1,35 @@ +attributesFactory = $attributesFactory; + $this->attributeKeys = $attributeKeys; + } + + public function process(AttributesInterface $attributes, Context $context): AttributesInterface + { + $filtered = $this->attributesFactory->builder(); + foreach ($this->attributeKeys as $key) { + $filtered[$key] = $attributes->get($key); + } + + return $filtered->build(); + } +} diff --git a/src/SDK/Metrics/AttributeProcessor/IdentityAttributeProcessor.php b/src/SDK/Metrics/AttributeProcessor/IdentityAttributeProcessor.php new file mode 100644 index 000000000..4e23423e6 --- /dev/null +++ b/src/SDK/Metrics/AttributeProcessor/IdentityAttributeProcessor.php @@ -0,0 +1,20 @@ +writer = $writer; + $this->referenceCounter = $referenceCounter; + $this->clock = $clock; - /** - * Returns the current value - * - * @access public - * @return int - */ - public function getValue(): int - { - return $this->value; + $this->referenceCounter->acquire(); } - /** - * Increments the current value - * - * @access public - * @return self - */ - public function increment(): API\CounterInterface + public function __destruct() { - $this->value++; - - return $this; + $this->referenceCounter->release(); } - /** - * Adds the specified value to the current counter's value - * - * @access public - * @return self - */ - public function add(int $value): API\CounterInterface + public function add($amount, iterable $attributes = [], $context = null): void { - if ($value <= 0) { - throw new InvalidArgumentException('Only positive numbers can be added to the Counter'); - } - - $this->value += $value; - - return $this; + $this->writer->record($amount, $attributes, $context, $this->clock->now()); } } diff --git a/src/SDK/Metrics/Data/DataInterface.php b/src/SDK/Metrics/Data/DataInterface.php new file mode 100644 index 000000000..7aa0c0e20 --- /dev/null +++ b/src/SDK/Metrics/Data/DataInterface.php @@ -0,0 +1,9 @@ +value = $value; + $this->timestamp = $timestamp; + $this->attributes = $attributes; + $this->traceId = $traceId; + $this->spanId = $spanId; + } +} diff --git a/src/SDK/Metrics/Data/Gauge.php b/src/SDK/Metrics/Data/Gauge.php new file mode 100644 index 000000000..00eb50939 --- /dev/null +++ b/src/SDK/Metrics/Data/Gauge.php @@ -0,0 +1,22 @@ + + * @readonly + */ + public iterable $dataPoints; + /** + * @param iterable $dataPoints + */ + public function __construct(iterable $dataPoints) + { + $this->dataPoints = $dataPoints; + } +} diff --git a/src/SDK/Metrics/Data/Histogram.php b/src/SDK/Metrics/Data/Histogram.php new file mode 100644 index 000000000..782698026 --- /dev/null +++ b/src/SDK/Metrics/Data/Histogram.php @@ -0,0 +1,29 @@ + + * @readonly + */ + public iterable $dataPoints; + /** + * @var string|Temporality + * @readonly + */ + public $temporality; + /** + * @param iterable $dataPoints + * @param string|Temporality $temporality + */ + public function __construct(iterable $dataPoints, $temporality) + { + $this->dataPoints = $dataPoints; + $this->temporality = $temporality; + } +} diff --git a/src/SDK/Metrics/Data/HistogramDataPoint.php b/src/SDK/Metrics/Data/HistogramDataPoint.php new file mode 100644 index 000000000..4c9df07b4 --- /dev/null +++ b/src/SDK/Metrics/Data/HistogramDataPoint.php @@ -0,0 +1,76 @@ + + * @readonly + */ + public array $explicitBounds; + /** + * @readonly + */ + public AttributesInterface $attributes; + /** + * @readonly + */ + public int $startTimestamp; + /** + * @readonly + */ + public int $timestamp; + /** + * @readonly + */ + public iterable $exemplars = []; + /** + * @param float|int $sum + * @param float|int $min + * @param float|int $max + * @param int[] $bucketCounts + * @param list $explicitBounds + */ + public function __construct(int $count, $sum, $min, $max, array $bucketCounts, array $explicitBounds, AttributesInterface $attributes, int $startTimestamp, int $timestamp, iterable $exemplars = []) + { + $this->count = $count; + $this->sum = $sum; + $this->min = $min; + $this->max = $max; + $this->bucketCounts = $bucketCounts; + $this->explicitBounds = $explicitBounds; + $this->attributes = $attributes; + $this->startTimestamp = $startTimestamp; + $this->timestamp = $timestamp; + $this->exemplars = $exemplars; + } +} diff --git a/src/SDK/Metrics/Data/Metric.php b/src/SDK/Metrics/Data/Metric.php new file mode 100644 index 000000000..41fcb52dd --- /dev/null +++ b/src/SDK/Metrics/Data/Metric.php @@ -0,0 +1,46 @@ +instrumentationScope = $instrumentationScope; + $this->resource = $resource; + $this->name = $name; + $this->description = $description; + $this->unit = $unit; + $this->data = $data; + } +} diff --git a/src/SDK/Metrics/Data/NumberDataPoint.php b/src/SDK/Metrics/Data/NumberDataPoint.php new file mode 100644 index 000000000..1d00e783a --- /dev/null +++ b/src/SDK/Metrics/Data/NumberDataPoint.php @@ -0,0 +1,43 @@ +value = $value; + $this->attributes = $attributes; + $this->startTimestamp = $startTimestamp; + $this->timestamp = $timestamp; + $this->exemplars = $exemplars; + } +} diff --git a/src/SDK/Metrics/Data/Sum.php b/src/SDK/Metrics/Data/Sum.php new file mode 100644 index 000000000..77c4c1021 --- /dev/null +++ b/src/SDK/Metrics/Data/Sum.php @@ -0,0 +1,34 @@ + + * @readonly + */ + public iterable $dataPoints; + /** + * @var string|Temporality + * @readonly + */ + public $temporality; + /** + * @readonly + */ + public bool $monotonic; + /** + * @param iterable $dataPoints + * @param string|Temporality $temporality + */ + public function __construct(iterable $dataPoints, $temporality, bool $monotonic) + { + $this->dataPoints = $dataPoints; + $this->temporality = $temporality; + $this->monotonic = $monotonic; + } +} diff --git a/src/SDK/Metrics/Data/Temporality.php b/src/SDK/Metrics/Data/Temporality.php new file mode 100644 index 000000000..b6642ebd0 --- /dev/null +++ b/src/SDK/Metrics/Data/Temporality.php @@ -0,0 +1,20 @@ + */ + private array $buckets; + + public function __construct(AttributesFactoryInterface $attributesFactory, int $size = 0) + { + $this->attributesFactory = $attributesFactory; + $this->buckets = array_fill(0, $size, null); + } + + /** + * @param int|string $index + * @param float|int $value + */ + public function store(int $bucket, $index, $value, AttributesInterface $attributes, Context $context, int $timestamp, int $revision): void + { + assert($bucket <= count($this->buckets)); + + $exemplar = $this->buckets[$bucket] ??= new BucketEntry(); + $exemplar->index = $index; + $exemplar->value = $value; + $exemplar->timestamp = $timestamp; + $exemplar->attributes = $attributes; + $exemplar->revision = $revision; + + if (class_exists(AbstractSpan::class) && ($spanContext = AbstractSpan::fromContext($context)->getContext())->isValid()) { + $exemplar->traceId = $spanContext->getTraceId(); + $exemplar->spanId = $spanContext->getSpanId(); + } else { + $exemplar->traceId = null; + $exemplar->spanId = null; + } + } + + /** + * @param array $dataPointAttributes + * @return array> + */ + public function collect(array $dataPointAttributes, int $revision, int $limit): array + { + $exemplars = []; + foreach ($this->buckets as $exemplar) { + if (!$exemplar || $exemplar->revision < $revision || $exemplar->revision >= $limit) { + continue; + } + + $exemplars[$exemplar->index][] = new Exemplar( + $exemplar->value, + $exemplar->timestamp, + $this->filterExemplarAttributes( + $dataPointAttributes[$exemplar->index], + $exemplar->attributes, + ), + $exemplar->traceId, + $exemplar->spanId, + ); + } + + return $exemplars; + } + + private function filterExemplarAttributes(AttributesInterface $dataPointAttributes, AttributesInterface $exemplarAttributes): AttributesInterface + { + $attributes = $this->attributesFactory->builder(); + foreach ($exemplarAttributes as $key => $value) { + if ($dataPointAttributes->get($key) === null) { + $attributes[$key] = $value; + } + } + + return $attributes->build(); + } +} diff --git a/src/SDK/Metrics/Exemplar/ExemplarFilter/AllExemplarFilter.php b/src/SDK/Metrics/Exemplar/ExemplarFilter/AllExemplarFilter.php new file mode 100644 index 000000000..6e602dfcf --- /dev/null +++ b/src/SDK/Metrics/Exemplar/ExemplarFilter/AllExemplarFilter.php @@ -0,0 +1,17 @@ +getContext()->isSampled(); + } +} diff --git a/src/SDK/Metrics/Exemplar/ExemplarFilterInterface.php b/src/SDK/Metrics/Exemplar/ExemplarFilterInterface.php new file mode 100644 index 000000000..4ae383e77 --- /dev/null +++ b/src/SDK/Metrics/Exemplar/ExemplarFilterInterface.php @@ -0,0 +1,16 @@ + $dataPointAttributes + * @return array> + */ + public function collect(array $dataPointAttributes, int $revision, int $limit): array; +} diff --git a/src/SDK/Metrics/Exemplar/FilteredReservoir.php b/src/SDK/Metrics/Exemplar/FilteredReservoir.php new file mode 100644 index 000000000..e51d916ea --- /dev/null +++ b/src/SDK/Metrics/Exemplar/FilteredReservoir.php @@ -0,0 +1,32 @@ +reservoir = $reservoir; + $this->filter = $filter; + } + + public function offer($index, $value, AttributesInterface $attributes, Context $context, int $timestamp, int $revision): void + { + if ($this->filter->accepts($value, $attributes, $context, $timestamp)) { + $this->reservoir->offer($index, $value, $attributes, $context, $timestamp, $revision); + } + } + + public function collect(array $dataPointAttributes, int $revision, int $limit): array + { + return $this->reservoir->collect($dataPointAttributes, $revision, $limit); + } +} diff --git a/src/SDK/Metrics/Exemplar/FixedSizeReservoir.php b/src/SDK/Metrics/Exemplar/FixedSizeReservoir.php new file mode 100644 index 000000000..820c46889 --- /dev/null +++ b/src/SDK/Metrics/Exemplar/FixedSizeReservoir.php @@ -0,0 +1,39 @@ +storage = new BucketStorage($attributesFactory, $size); + $this->size = $size; + } + + public function offer($index, $value, AttributesInterface $attributes, Context $context, int $timestamp, int $revision): void + { + $bucket = random_int(0, $this->measurements); + $this->measurements++; + if ($bucket < $this->size) { + $this->storage->store($bucket, $index, $value, $attributes, $context, $timestamp, $revision); + } + } + + public function collect(array $dataPointAttributes, int $revision, int $limit): array + { + $this->measurements = 0; + + return $this->storage->collect($dataPointAttributes, $revision, $limit); + } +} diff --git a/src/SDK/Metrics/Exemplar/HistogramBucketReservoir.php b/src/SDK/Metrics/Exemplar/HistogramBucketReservoir.php new file mode 100644 index 000000000..c546e4982 --- /dev/null +++ b/src/SDK/Metrics/Exemplar/HistogramBucketReservoir.php @@ -0,0 +1,41 @@ + + */ + private array $boundaries; + + /** + * @param list $boundaries + */ + public function __construct(AttributesFactoryInterface $attributesFactory, array $boundaries) + { + $this->storage = new BucketStorage($attributesFactory, count($boundaries) + 1); + $this->boundaries = $boundaries; + } + + public function offer($index, $value, AttributesInterface $attributes, Context $context, int $timestamp, int $revision): void + { + $boundariesCount = count($this->boundaries); + for ($i = 0; $i < $boundariesCount && $this->boundaries[$i] < $value; $i++) { + } + $this->storage->store($i, $index, $value, $attributes, $context, $timestamp, $revision); + } + + public function collect(array $dataPointAttributes, int $revision, int $limit): array + { + return $this->storage->collect($dataPointAttributes, $revision, $limit); + } +} diff --git a/src/SDK/Metrics/Exemplar/NoopReservoir.php b/src/SDK/Metrics/Exemplar/NoopReservoir.php new file mode 100644 index 000000000..8751e9c42 --- /dev/null +++ b/src/SDK/Metrics/Exemplar/NoopReservoir.php @@ -0,0 +1,21 @@ +doExport($metrics); - - return API\ExporterInterface::SUCCESS; - } catch (RetryableExportException $exception) { - return API\ExporterInterface::FAILED_RETRYABLE; - } catch (Exception $exception) { - return API\ExporterInterface::FAILED_NOT_RETRYABLE; - } - } - - /** - * Sends metrics to the destination system - * - * @access protected - * @param iterable $metrics - * @return void - */ - abstract protected function doExport(iterable $metrics): void; -} diff --git a/src/SDK/Metrics/HasLabelsTrait.php b/src/SDK/Metrics/HasLabelsTrait.php deleted file mode 100644 index 71bd262d6..000000000 --- a/src/SDK/Metrics/HasLabelsTrait.php +++ /dev/null @@ -1,49 +0,0 @@ - - */ - protected $labels = []; - - /** - * @return array - */ - public function getLabels(): array - { - return $this->labels; - } - - /** - * {@inheritDoc} - */ - public function setLabels(array $labels) - { - foreach ($labels as $label) { - if (! is_string($label)) { - throw new InvalidArgumentException('The label is expected to be a string'); - } - } - - $this->labels = $labels; - - return $this; - } - - /** - * {@inheritDoc} - */ - public function addLabel(string $label) - { - $this->labels[] = $label; - - return $this; - } -} diff --git a/src/SDK/Metrics/Histogram.php b/src/SDK/Metrics/Histogram.php new file mode 100644 index 000000000..e7b17bee0 --- /dev/null +++ b/src/SDK/Metrics/Histogram.php @@ -0,0 +1,37 @@ +writer = $writer; + $this->referenceCounter = $referenceCounter; + $this->clock = $clock; + + $this->referenceCounter->acquire(); + } + + public function __destruct() + { + $this->referenceCounter->release(); + } + + public function record($amount, iterable $attributes = [], $context = null): void + { + $this->writer->record($amount, $attributes, $context, $this->clock->now()); + } +} diff --git a/src/SDK/Metrics/Instrument.php b/src/SDK/Metrics/Instrument.php new file mode 100644 index 000000000..3543604c0 --- /dev/null +++ b/src/SDK/Metrics/Instrument.php @@ -0,0 +1,36 @@ +type = $type; + $this->name = $name; + $this->unit = $unit; + $this->description = $description; + } +} diff --git a/src/SDK/Metrics/InstrumentType.php b/src/SDK/Metrics/InstrumentType.php new file mode 100644 index 000000000..ae603b2fe --- /dev/null +++ b/src/SDK/Metrics/InstrumentType.php @@ -0,0 +1,25 @@ + */ + private iterable $metricRegistries; + private ViewRegistryInterface $viewRegistry; + private ?ExemplarFilterInterface $exemplarFilter; + private MeterInstruments $instruments; + private InstrumentationScopeInterface $instrumentationScope; - public function __construct(string $name, string $version = null) - { - $this->name = $name; - $this->version = (string) $version; - } + private ?string $instrumentationScopeId = null; /** - * {@inheritdoc} + * @param iterable $metricRegistries */ - public function getName(): string + public function __construct( + ?ContextStorageInterface $contextStorage, + MetricFactoryInterface $metricFactory, + ResourceInfo $resource, + ClockInterface $clock, + AttributesFactoryInterface $attributesFactory, + StalenessHandlerFactoryInterface $stalenessHandlerFactory, + iterable $metricRegistries, + ViewRegistryInterface $viewRegistry, + ?ExemplarFilterInterface $exemplarFilter, + MeterInstruments $instruments, + InstrumentationScopeInterface $instrumentationScope + ) { + $this->contextStorage = $contextStorage; + $this->metricFactory = $metricFactory; + $this->resource = $resource; + $this->clock = $clock; + $this->attributesFactory = $attributesFactory; + $this->stalenessHandlerFactory = $stalenessHandlerFactory; + $this->metricRegistries = $metricRegistries; + $this->viewRegistry = $viewRegistry; + $this->exemplarFilter = $exemplarFilter; + $this->instruments = $instruments; + $this->instrumentationScope = $instrumentationScope; + } + + public function createCounter(string $name, ?string $unit = null, ?string $description = null): CounterInterface { - return $this->name; + [$writer, $referenceCounter] = $this->createSynchronousWriter( + InstrumentType::COUNTER, + $name, + $unit, + $description, + ); + + return new Counter($writer, $referenceCounter, $this->clock); } - /** - * {@inheritdoc} - */ - public function getVersion(): string + public function createObservableCounter(string $name, ?string $unit = null, ?string $description = null, callable ...$callbacks): ObservableCounterInterface + { + [$observer, $referenceCounter] = $this->createAsynchronousObserver( + InstrumentType::ASYNCHRONOUS_COUNTER, + $name, + $unit, + $description, + ); + + foreach ($callbacks as $callback) { + /** @psalm-suppress InvalidArgument */ + $observer->observe(closure($callback)); + $referenceCounter->acquire(true); + } + + return new ObservableCounter($observer, $referenceCounter); + } + + public function createHistogram(string $name, ?string $unit = null, ?string $description = null): HistogramInterface { - return $this->version; + [$writer, $referenceCounter] = $this->createSynchronousWriter( + InstrumentType::HISTOGRAM, + $name, + $unit, + $description, + ); + + return new Histogram($writer, $referenceCounter, $this->clock); + } + + public function createObservableGauge(string $name, ?string $unit = null, ?string $description = null, callable ...$callbacks): ObservableGaugeInterface + { + [$observer, $referenceCounter] = $this->createAsynchronousObserver( + InstrumentType::ASYNCHRONOUS_GAUGE, + $name, + $unit, + $description, + ); + + foreach ($callbacks as $callback) { + /** @psalm-suppress InvalidArgument */ + $observer->observe(closure($callback)); + $referenceCounter->acquire(true); + } + + return new ObservableGauge($observer, $referenceCounter); + } + + public function createUpDownCounter(string $name, ?string $unit = null, ?string $description = null): UpDownCounterInterface + { + [$writer, $referenceCounter] = $this->createSynchronousWriter( + InstrumentType::UP_DOWN_COUNTER, + $name, + $unit, + $description, + ); + + return new UpDownCounter($writer, $referenceCounter, $this->clock); + } + + public function createObservableUpDownCounter(string $name, ?string $unit = null, ?string $description = null, callable ...$callbacks): ObservableUpDownCounterInterface + { + [$observer, $referenceCounter] = $this->createAsynchronousObserver( + InstrumentType::ASYNCHRONOUS_UP_DOWN_COUNTER, + $name, + $unit, + $description, + ); + + foreach ($callbacks as $callback) { + /** @psalm-suppress InvalidArgument */ + $observer->observe(closure($callback)); + $referenceCounter->acquire(true); + } + + return new ObservableUpDownCounter($observer, $referenceCounter); } /** - * {@inheritdoc} + * @param string|InstrumentType $instrumentType + * @return array{MetricWriterInterface, ReferenceCounterInterface} */ - public function newCounter(string $name, string $description = ''): API\CounterInterface + private function createSynchronousWriter($instrumentType, string $name, ?string $unit, ?string $description): array { - return new Counter($name, $description); + $instrument = new Instrument($instrumentType, $name, $unit, $description); + + $instrumentationScopeId = $this->instrumentationScopeId($this->instrumentationScope); + $instrumentId = $this->instrumentId($instrument); + + $instruments = $this->instruments; + if ($writer = $instruments->writers[$instrumentationScopeId][$instrumentId] ?? null) { + return $writer; + } + + $stalenessHandler = $this->stalenessHandlerFactory->create(); + $stalenessHandler->onStale(static function () use ($instruments, $instrumentationScopeId, $instrumentId): void { + unset($instruments->writers[$instrumentationScopeId][$instrumentId]); + if (!$instruments->writers[$instrumentationScopeId]) { + unset($instruments->writers[$instrumentationScopeId]); + } + + $instruments->startTimestamp = null; + }); + + $instruments->startTimestamp ??= $this->clock->now(); + + return $instruments->writers[$instrumentationScopeId][$instrumentId] = [ + $this->metricFactory->createSynchronousWriter( + $this->resource, + $this->instrumentationScope, + $instrument, + $instruments->startTimestamp, + $this->viewRegistrationRequests($instrument, $stalenessHandler), + $this->attributesFactory, + $this->exemplarFilter, + $this->contextStorage, + ), + $stalenessHandler, + ]; } /** - * {@inheritdoc} + * @param string|InstrumentType $instrumentType + * @return array{MetricObserverInterface, ReferenceCounterInterface} */ - public function newUpDownCounter(string $name, string $description = ''): API\UpDownCounterInterface + private function createAsynchronousObserver($instrumentType, string $name, ?string $unit, ?string $description): array { - return new UpDownCounter($name, $description); + $instrument = new Instrument($instrumentType, $name, $unit, $description); + + $instrumentationScopeId = $this->instrumentationScopeId($this->instrumentationScope); + $instrumentId = $this->instrumentId($instrument); + + $instruments = $this->instruments; + /** @phan-suppress-next-line PhanDeprecatedProperty */ + $instruments->staleObservers = []; + if ($observer = $instruments->observers[$instrumentationScopeId][$instrumentId] ?? null) { + return $observer; + } + + $stalenessHandler = $this->stalenessHandlerFactory->create(); + $stalenessHandler->onStale(static function () use ($instruments, $instrumentationScopeId, $instrumentId): void { + if (PHP_VERSION_ID < 80000) { + /** @phan-suppress-next-line PhanDeprecatedProperty */ + $instruments->staleObservers[] = $instruments->observers[$instrumentationScopeId][$instrumentId][0]; + } + + unset($instruments->observers[$instrumentationScopeId][$instrumentId]); + if (!$instruments->observers[$instrumentationScopeId]) { + unset($instruments->observers[$instrumentationScopeId]); + } + + $instruments->startTimestamp = null; + }); + + $instruments->startTimestamp ??= $this->clock->now(); + + return $instruments->observers[$instrumentationScopeId][$instrumentId] = [ + $this->metricFactory->createAsynchronousObserver( + $this->resource, + $this->instrumentationScope, + $instrument, + $instruments->startTimestamp, + $this->viewRegistrationRequests($instrument, $stalenessHandler), + $this->attributesFactory, + $this->exemplarFilter, + ), + $stalenessHandler, + ]; } /** - * {@inheritdoc} + * @return iterable */ - public function newValueRecorder(string $name, string $description = ''): API\ValueRecorderInterface + private function viewRegistrationRequests(Instrument $instrument, StalenessHandlerInterface $stalenessHandler): iterable + { + $views = $this->viewRegistry->find($instrument, $this->instrumentationScope) ?? [ + new ViewProjection( + $instrument->name, + $instrument->unit, + $instrument->description, + null, + null, + ), + ]; + + $compositeRegistration = new MultiRegistryRegistration($this->metricRegistries, $stalenessHandler); + foreach ($views as $view) { + if ($view->aggregation !== null) { + yield [$view, $compositeRegistration]; + } else { + foreach ($this->metricRegistries as $metricRegistry) { + yield [ + new ViewProjection( + $view->name, + $view->unit, + $view->description, + $view->attributeKeys, + $metricRegistry->defaultAggregation($instrument->type), + ), + new RegistryRegistration($metricRegistry, $stalenessHandler), + ]; + } + } + } + } + + private function instrumentationScopeId(InstrumentationScopeInterface $instrumentationScope): string + { + return $this->instrumentationScopeId ??= serialize($instrumentationScope); + } + + private function instrumentId(Instrument $instrument): string { - return new ValueRecorder($name, $description); + return serialize($instrument); } } diff --git a/src/SDK/Metrics/MeterInstruments.php b/src/SDK/Metrics/MeterInstruments.php new file mode 100644 index 000000000..725a1ade1 --- /dev/null +++ b/src/SDK/Metrics/MeterInstruments.php @@ -0,0 +1,27 @@ +> + */ + public array $observers = []; + /** + * @var array> + */ + public array $writers = []; + + /** + * @var list + * @deprecated + */ + public array $staleObservers = []; +} diff --git a/src/SDK/Metrics/MeterProvider.php b/src/SDK/Metrics/MeterProvider.php new file mode 100644 index 000000000..7d96a21f1 --- /dev/null +++ b/src/SDK/Metrics/MeterProvider.php @@ -0,0 +1,119 @@ + $metricReaders + */ + public function __construct( + ?ContextStorageInterface $contextStorage, + ResourceInfo $resource, + ClockInterface $clock, + AttributesFactoryInterface $attributesFactory, + InstrumentationScopeFactoryInterface $instrumentationScopeFactory, + iterable $metricReaders, + ViewRegistryInterface $viewRegistry, + ?ExemplarFilterInterface $exemplarFilter, + StalenessHandlerFactoryInterface $stalenessHandlerFactory, + MetricFactoryInterface $metricFactory = null + ) { + $this->contextStorage = $contextStorage; + $this->metricFactory = $metricFactory ?? new StreamFactory(); + $this->resource = $resource; + $this->clock = $clock; + $this->attributesFactory = $attributesFactory; + $this->instrumentationScopeFactory = $instrumentationScopeFactory; + $this->metricReaders = $metricReaders; + $this->viewRegistry = $viewRegistry; + $this->exemplarFilter = $exemplarFilter; + $this->stalenessHandlerFactory = $stalenessHandlerFactory; + $this->instruments = new MeterInstruments(); + } + + public function getMeter( + string $name, + ?string $version = null, + ?string $schemaUrl = null, + iterable $attributes = [] + ): MeterInterface { + if ($this->closed) { + return new NoopMeter(); + } + + return new Meter( + $this->contextStorage, + $this->metricFactory, + $this->resource, + $this->clock, + $this->attributesFactory, + $this->stalenessHandlerFactory, + $this->metricReaders, + $this->viewRegistry, + $this->exemplarFilter, + $this->instruments, + $this->instrumentationScopeFactory->create($name, $version, $schemaUrl, $attributes), + ); + } + + public function shutdown(): bool + { + if ($this->closed) { + return false; + } + + $this->closed = true; + + $success = true; + foreach ($this->metricReaders as $metricReader) { + if (!$metricReader->shutdown()) { + $success = false; + } + } + + return $success; + } + + public function forceFlush(): bool + { + if ($this->closed) { + return false; + } + + $success = true; + foreach ($this->metricReaders as $metricReader) { + if (!$metricReader->forceFlush()) { + $success = false; + } + } + + return $success; + } +} diff --git a/src/SDK/Metrics/MeterProviderInterface.php b/src/SDK/Metrics/MeterProviderInterface.php new file mode 100644 index 000000000..fcb951106 --- /dev/null +++ b/src/SDK/Metrics/MeterProviderInterface.php @@ -0,0 +1,12 @@ + + */ + private array $metrics = []; + /** + * @var string|Temporality|null + */ + private $temporality; + + private bool $closed = false; + + /** + * @param string|Temporality|null $temporality + */ + public function __construct($temporality = null) + { + $this->temporality = $temporality; + } + + public function temporality(MetricMetadataInterface $metric) + { + return $this->temporality ?? $metric->temporality(); + } + + /** + * @return list + */ + public function collect(bool $reset = false): array + { + $metrics = $this->metrics; + if ($reset) { + $this->metrics = []; + } + + return $metrics; + } + + public function export(iterable $batch): bool + { + if ($this->closed) { + return false; + } + + /** @psalm-suppress InvalidPropertyAssignmentValue */ + array_push($this->metrics, ...$batch); + + return true; + } + + public function shutdown(): bool + { + if ($this->closed) { + return false; + } + + $this->closed = true; + + return true; + } + + public function forceFlush(): bool + { + return !$this->closed; + } +} diff --git a/src/SDK/Metrics/MetricExporterInterface.php b/src/SDK/Metrics/MetricExporterInterface.php new file mode 100644 index 000000000..6b4618b5b --- /dev/null +++ b/src/SDK/Metrics/MetricExporterInterface.php @@ -0,0 +1,31 @@ + $batch + */ + public function export(iterable $batch): bool; + + public function shutdown(): bool; + + public function forceFlush(): bool; +} diff --git a/src/SDK/Metrics/MetricFactory/StreamFactory.php b/src/SDK/Metrics/MetricFactory/StreamFactory.php new file mode 100644 index 000000000..98251da8b --- /dev/null +++ b/src/SDK/Metrics/MetricFactory/StreamFactory.php @@ -0,0 +1,184 @@ +aggregation === null) { + continue; + } + + $streamId = $this->streamId($view->aggregation, $view->attributeKeys); + if (($stream = $dedup[$streamId] ?? null) === null) { + $stream = new AsynchronousMetricStream( + $attributesFactory, + $this->attributeProcessor($view->attributeKeys, $attributesFactory), + $view->aggregation, + $this->applyExemplarFilter( + $view->aggregation->exemplarReservoir($attributesFactory), + $exemplarFilter, + ), + $observer, + $timestamp, + ); + + $dedup[$streamId] = $stream; + } + + $this->registerSource( + $view, + $instrument, + $instrumentationScope, + $resource, + $stream, + $registry, + ); + } + + return $observer; + } + + public function createSynchronousWriter( + ResourceInfo $resource, + InstrumentationScopeInterface $instrumentationScope, + Instrument $instrument, + int $timestamp, + iterable $views, + AttributesFactoryInterface $attributesFactory, + ?ExemplarFilterInterface $exemplarFilter = null, + ?ContextStorageInterface $contextStorage = null + ): MetricWriterInterface { + $streams = []; + $dedup = []; + foreach ($views as [$view, $registry]) { + if ($view->aggregation === null) { + continue; + } + + $streamId = $this->streamId($view->aggregation, $view->attributeKeys); + if (($stream = $dedup[$streamId] ?? null) === null) { + $stream = new SynchronousMetricStream( + $this->attributeProcessor($view->attributeKeys, $attributesFactory), + $view->aggregation, + $this->applyExemplarFilter( + $view->aggregation->exemplarReservoir($attributesFactory), + $exemplarFilter, + ), + $timestamp, + ); + $streams[] = $stream->writable(); + + $dedup[$streamId] = $stream; + } + + $this->registerSource( + $view, + $instrument, + $instrumentationScope, + $resource, + $stream, + $registry, + ); + } + + return new MultiStreamWriter( + $contextStorage, + $attributesFactory, + $streams, + ); + } + + private function attributeProcessor( + ?array $attributeKeys, + AttributesFactoryInterface $attributesFactory + ): ?AttributeProcessorInterface { + return $attributeKeys !== null + ? new FilteredAttributeProcessor($attributesFactory, $attributeKeys) + : null; + } + + private function applyExemplarFilter( + ?ExemplarReservoirInterface $exemplarReservoir, + ?ExemplarFilterInterface $exemplarFilter + ): ?ExemplarReservoirInterface { + return $exemplarReservoir !== null && $exemplarFilter !== null + ? new FilteredReservoir($exemplarReservoir, $exemplarFilter) + : $exemplarReservoir; + } + + private function registerSource( + ViewProjection $view, + Instrument $instrument, + InstrumentationScopeInterface $instrumentationScope, + ResourceInfo $resource, + MetricStreamInterface $stream, + MetricRegistrationInterface $metricRegistration + ): void { + $provider = new StreamMetricSourceProvider( + $view, + $instrument, + $instrumentationScope, + $resource, + $stream, + ); + + $metricRegistration->register($provider, $provider); + } + + private function streamId(AggregationInterface $aggregation, ?array $attributeKeys): string + { + return $this->trySerialize($aggregation) . serialize($attributeKeys); + } + + private function trySerialize(object $object) + { + try { + return serialize($object); + } catch (Throwable $e) { + } + + return spl_object_id($object); + } +} diff --git a/src/SDK/Metrics/MetricFactory/StreamMetricSource.php b/src/SDK/Metrics/MetricFactory/StreamMetricSource.php new file mode 100644 index 000000000..16de5a6dc --- /dev/null +++ b/src/SDK/Metrics/MetricFactory/StreamMetricSource.php @@ -0,0 +1,44 @@ +provider = $provider; + $this->reader = $reader; + } + + public function collectionTimestamp(): int + { + return $this->provider->stream->collectionTimestamp(); + } + + public function collect(?int $timestamp): Metric + { + return new Metric( + $this->provider->instrumentationLibrary, + $this->provider->resource, + $this->provider->view->name, + $this->provider->view->unit, + $this->provider->view->description, + $this->provider->stream->collect($this->reader, $timestamp), + ); + } + + public function __destruct() + { + $this->provider->stream->unregister($this->reader); + } +} diff --git a/src/SDK/Metrics/MetricFactory/StreamMetricSourceProvider.php b/src/SDK/Metrics/MetricFactory/StreamMetricSourceProvider.php new file mode 100644 index 000000000..136ecd6e6 --- /dev/null +++ b/src/SDK/Metrics/MetricFactory/StreamMetricSourceProvider.php @@ -0,0 +1,79 @@ +view = $view; + $this->instrument = $instrument; + $this->instrumentationLibrary = $instrumentationLibrary; + $this->resource = $resource; + $this->stream = $stream; + } + + public function create($temporality): MetricSourceInterface + { + return new StreamMetricSource($this, $this->stream->register($temporality)); + } + + public function instrumentType() + { + return $this->instrument->type; + } + + public function name(): string + { + return $this->view->name; + } + + public function unit(): ?string + { + return $this->view->unit; + } + + public function description(): ?string + { + return $this->view->description; + } + + public function temporality() + { + return $this->stream->temporality(); + } +} diff --git a/src/SDK/Metrics/MetricFactoryInterface.php b/src/SDK/Metrics/MetricFactoryInterface.php new file mode 100644 index 000000000..bdb9d7193 --- /dev/null +++ b/src/SDK/Metrics/MetricFactoryInterface.php @@ -0,0 +1,44 @@ + $views + */ + public function createAsynchronousObserver( + ResourceInfo $resource, + InstrumentationScopeInterface $instrumentationScope, + Instrument $instrument, + int $timestamp, + iterable $views, + AttributesFactoryInterface $attributesFactory, + ?ExemplarFilterInterface $exemplarFilter = null + ): MetricObserverInterface; + + /** + * @param iterable $views + */ + public function createSynchronousWriter( + ResourceInfo $resource, + InstrumentationScopeInterface $instrumentationScope, + Instrument $instrument, + int $timestamp, + iterable $views, + AttributesFactoryInterface $attributesFactory, + ?ExemplarFilterInterface $exemplarFilter = null, + ?ContextStorageInterface $contextStorage = null + ): MetricWriterInterface; +} diff --git a/src/SDK/Metrics/MetricMetadataInterface.php b/src/SDK/Metrics/MetricMetadataInterface.php new file mode 100644 index 000000000..aa1a02d60 --- /dev/null +++ b/src/SDK/Metrics/MetricMetadataInterface.php @@ -0,0 +1,28 @@ + + */ + public array $tokens = []; + private MetricObserverInterface $metricObserver; + private ReferenceCounterInterface $referenceCounter; + + public function __construct(MetricObserverInterface $metricObserver, ReferenceCounterInterface $referenceCounter) + { + $this->metricObserver = $metricObserver; + $this->referenceCounter = $referenceCounter; + } + + public function __destruct() + { + foreach ($this->tokens as $token) { + if ($this->metricObserver->has($token)) { + $this->metricObserver->cancel($token); + $this->referenceCounter->release(); + } + } + } +} diff --git a/src/SDK/Metrics/MetricObserver/MultiObserver.php b/src/SDK/Metrics/MetricObserver/MultiObserver.php new file mode 100644 index 000000000..0cb007f02 --- /dev/null +++ b/src/SDK/Metrics/MetricObserver/MultiObserver.php @@ -0,0 +1,55 @@ + + */ + private array $callbacks = []; + private ?ArrayAccess $weakMap = null; + + public function __invoke(ObserverInterface $observer): void + { + foreach ($this->callbacks as $token => $callback) { + if (isset($this->callbacks[$token])) { + $callback($observer); + } + } + } + + public function observe(Closure $callback): int + { + $this->callbacks[] = $callback; + + return array_key_last($this->callbacks); + } + + public function has(int $token): bool + { + return isset($this->callbacks[$token]); + } + + public function cancel(int $token): void + { + unset($this->callbacks[$token]); + } + + public function destructors(): ArrayAccess + { + return $this->weakMap ??= WeakMap::create(); + } +} diff --git a/src/SDK/Metrics/MetricObserverInterface.php b/src/SDK/Metrics/MetricObserverInterface.php new file mode 100644 index 000000000..dfeffa8b2 --- /dev/null +++ b/src/SDK/Metrics/MetricObserverInterface.php @@ -0,0 +1,30 @@ + + */ + public function destructors(): ArrayAccess; +} diff --git a/src/SDK/Metrics/MetricReader/ExportingReader.php b/src/SDK/Metrics/MetricReader/ExportingReader.php new file mode 100644 index 000000000..c56bf6f98 --- /dev/null +++ b/src/SDK/Metrics/MetricReader/ExportingReader.php @@ -0,0 +1,112 @@ + */ + private array $sources = []; + + private bool $closed = false; + + public function __construct(MetricExporterInterface $exporter, ClockInterface $clock) + { + $this->exporter = $exporter; + $this->clock = $clock; + } + + public function defaultAggregation($instrumentType): ?AggregationInterface + { + if ($this->exporter instanceof DefaultAggregationProviderInterface) { + return $this->exporter->defaultAggregation($instrumentType); + } + + return $this->_defaultAggregation($instrumentType); + } + + public function add(MetricSourceProviderInterface $provider, MetricMetadataInterface $metadata, StalenessHandlerInterface $stalenessHandler): void + { + if ($this->closed) { + return; + } + if (!$temporality = $this->exporter->temporality($metadata)) { + return; + } + + $source = $provider->create($temporality); + $sourceId = spl_object_id($source); + + $this->sources[$sourceId] = $source; + + $stalenessHandler->onStale(function () use ($sourceId): void { + unset($this->sources[$sourceId]); + }); + } + + private function doCollect(): bool + { + $timestamp = $this->clock->now(); + $metrics = []; + foreach ($this->sources as $source) { + $metrics[] = $source->collect($timestamp); + } + + return $this->exporter->export($metrics); + } + + public function collect(): bool + { + if ($this->closed) { + return false; + } + + return $this->doCollect(); + } + + public function shutdown(): bool + { + if ($this->closed) { + return false; + } + + $this->closed = true; + + $collect = $this->doCollect(); + $shutdown = $this->exporter->shutdown(); + + $this->sources = []; + + return $collect && $shutdown; + } + + public function forceFlush(): bool + { + if ($this->closed) { + return false; + } + + $collect = $this->doCollect(); + $forceFlush = $this->exporter->forceFlush(); + + return $collect && $forceFlush; + } +} diff --git a/src/SDK/Metrics/MetricReaderInterface.php b/src/SDK/Metrics/MetricReaderInterface.php new file mode 100644 index 000000000..f5900eef5 --- /dev/null +++ b/src/SDK/Metrics/MetricReaderInterface.php @@ -0,0 +1,14 @@ + $registries + */ + public function __construct(iterable $registries, StalenessHandlerInterface $stalenessHandler) + { + $this->registries = $registries; + $this->stalenessHandler = $stalenessHandler; + } + + public function register(MetricSourceProviderInterface $provider, MetricMetadataInterface $metadata): void + { + foreach ($this->registries as $registry) { + $registry->add($provider, $metadata, $this->stalenessHandler); + } + } +} diff --git a/src/SDK/Metrics/MetricRegistration/RegistryRegistration.php b/src/SDK/Metrics/MetricRegistration/RegistryRegistration.php new file mode 100644 index 000000000..3c1108902 --- /dev/null +++ b/src/SDK/Metrics/MetricRegistration/RegistryRegistration.php @@ -0,0 +1,31 @@ +registry = $registry; + $this->stalenessHandler = $stalenessHandler; + } + + public function register(MetricSourceProviderInterface $provider, MetricMetadataInterface $metadata): void + { + $this->registry->add($provider, $metadata, $this->stalenessHandler); + } +} diff --git a/src/SDK/Metrics/MetricRegistrationInterface.php b/src/SDK/Metrics/MetricRegistrationInterface.php new file mode 100644 index 000000000..b0cc2484e --- /dev/null +++ b/src/SDK/Metrics/MetricRegistrationInterface.php @@ -0,0 +1,13 @@ +metricObserver = $metricObserver; + $this->referenceCounter = $referenceCounter; + $this->token = $token; + $this->callbackDestructor = $callbackDestructor; + } + + public function detach(): void + { + if (!$this->metricObserver->has($this->token)) { + return; + } + + $this->metricObserver->cancel($this->token); + $this->referenceCounter->release(); + if ($this->callbackDestructor !== null) { + unset($this->callbackDestructor->tokens[$this->token]); + } + } + + public function __destruct() + { + if ($this->callbackDestructor !== null) { + return; + } + if (!$this->metricObserver->has($this->token)) { + return; + } + + $this->referenceCounter->acquire(true); + $this->referenceCounter->release(); + } +} diff --git a/src/SDK/Metrics/ObservableCounter.php b/src/SDK/Metrics/ObservableCounter.php new file mode 100644 index 000000000..99ae43eee --- /dev/null +++ b/src/SDK/Metrics/ObservableCounter.php @@ -0,0 +1,15 @@ +metricObserver = $metricObserver; + $this->referenceCounter = $referenceCounter; + + $this->referenceCounter->acquire(); + } + + public function __destruct() + { + $this->referenceCounter->release(); + } + + /** + * @param callable(ObserverInterface): void $callback + */ + public function observe(callable $callback, bool $weaken = false): ObservableCallbackInterface + { + $target = null; + $callback = closure($callback); + if ($weaken) { + $callback = weaken($callback, $target); + } + + /** @psalm-var \Closure(ObserverInterface): void $callback */ + $token = $this->metricObserver->observe($callback); + $this->referenceCounter->acquire(); + + $destructor = null; + if ($object = $target) { + $destructor = $this->metricObserver->destructors()[$object] ??= new CallbackDestructor($this->metricObserver, $this->referenceCounter); + $destructor->tokens[$token] = $token; + } + + return new ObservableCallback($this->metricObserver, $this->referenceCounter, $token, $destructor); + } +} diff --git a/src/SDK/Metrics/ObservableUpDownCounter.php b/src/SDK/Metrics/ObservableUpDownCounter.php new file mode 100644 index 000000000..8d21be734 --- /dev/null +++ b/src/SDK/Metrics/ObservableUpDownCounter.php @@ -0,0 +1,15 @@ +$name(...$arguments); - } -} diff --git a/src/SDK/Metrics/Providers/MeterProvider.php b/src/SDK/Metrics/Providers/MeterProvider.php deleted file mode 100644 index 99eef79bd..000000000 --- a/src/SDK/Metrics/Providers/MeterProvider.php +++ /dev/null @@ -1,39 +0,0 @@ -meters[$name . $version])) { - $this->meters[$name . $version] = $this->getCreatedMeter($name, $version); - } - - return $this->meters[$name . $version]; - } - - /** - * Creates a new Meter instance - * - * @access protected - * @param string $name - * @param string|null $version Default: null - * @return API\MeterInterface - */ - protected function getCreatedMeter(string $name, string $version = null): API\MeterInterface - { - // todo: once the Meter interface and an implementation are done, change this - return new Meter($name, $version); - } -} diff --git a/src/SDK/Metrics/ReferenceCounterInterface.php b/src/SDK/Metrics/ReferenceCounterInterface.php new file mode 100644 index 000000000..f7e70b644 --- /dev/null +++ b/src/SDK/Metrics/ReferenceCounterInterface.php @@ -0,0 +1,15 @@ +stale = $stale; + $this->freshen = $freshen; + } + + public function acquire(bool $persistent = false): void + { + if ($this->count === 0) { + ($this->freshen)($this); + } + + $this->count++; + + if ($persistent) { + $this->onStale = null; + } + } + + public function release(): void + { + if (--$this->count || $this->onStale === null) { + return; + } + + ($this->stale)($this); + } + + public function onStale(Closure $callback): void + { + if ($this->onStale === null) { + return; + } + + $this->onStale[] = $callback; + } + + public function triggerStale(): void + { + assert($this->onStale !== null); + + $callbacks = $this->onStale; + $this->onStale = []; + foreach ($callbacks as $callback) { + $callback(); + } + } +} diff --git a/src/SDK/Metrics/StalenessHandler/DelayedStalenessHandlerFactory.php b/src/SDK/Metrics/StalenessHandler/DelayedStalenessHandlerFactory.php new file mode 100644 index 000000000..0d719c74f --- /dev/null +++ b/src/SDK/Metrics/StalenessHandler/DelayedStalenessHandlerFactory.php @@ -0,0 +1,64 @@ +&Traversable */ + private $staleHandlers; + + /** + * @param float $delay delay in seconds + */ + public function __construct(ClockInterface $clock, float $delay) + { + $this->clock = $clock; + $this->nanoDelay = (int) ($delay * 1e9); + + $this->stale = function (DelayedStalenessHandler $handler): void { + $this->staleHandlers[$handler] = $this->clock->now(); + }; + $this->freshen = function (DelayedStalenessHandler $handler): void { + unset($this->staleHandlers[$handler]); + }; + + $this->staleHandlers = WeakMap::create(); + } + + public function create(): StalenessHandlerInterface + { + $this->triggerStaleHandlers(); + + return new DelayedStalenessHandler($this->stale, $this->freshen); + } + + private function triggerStaleHandlers(): void + { + $expired = $this->clock->now() - $this->nanoDelay; + foreach ($this->staleHandlers as $handler => $timestamp) { + if ($timestamp > $expired) { + break; + } + + /** @var DelayedStalenessHandler $handler */ + unset($this->staleHandlers[$handler]); + $handler->triggerStale(); + } + } +} diff --git a/src/SDK/Metrics/StalenessHandler/ImmediateStalenessHandler.php b/src/SDK/Metrics/StalenessHandler/ImmediateStalenessHandler.php new file mode 100644 index 000000000..a5b32d5c4 --- /dev/null +++ b/src/SDK/Metrics/StalenessHandler/ImmediateStalenessHandler.php @@ -0,0 +1,50 @@ +count++; + + if ($persistent) { + $this->onStale = null; + } + } + + public function release(): void + { + if (--$this->count !== 0 || !$this->onStale) { + return; + } + + $callbacks = $this->onStale; + $this->onStale = []; + foreach ($callbacks as $callback) { + $callback(); + } + } + + public function onStale(Closure $callback): void + { + if ($this->onStale === null) { + return; + } + + $this->onStale[] = $callback; + } +} diff --git a/src/SDK/Metrics/StalenessHandler/ImmediateStalenessHandlerFactory.php b/src/SDK/Metrics/StalenessHandler/ImmediateStalenessHandlerFactory.php new file mode 100644 index 000000000..899615dea --- /dev/null +++ b/src/SDK/Metrics/StalenessHandler/ImmediateStalenessHandlerFactory.php @@ -0,0 +1,16 @@ + */ + private array $lastReads = []; + + /** + * @param callable(ObserverInterface): void $instrument + */ + public function __construct( + AttributesFactoryInterface $attributesFactory, + ?AttributeProcessorInterface $attributeProcessor, + AggregationInterface $aggregation, + ?ExemplarReservoirInterface $exemplarReservoir, + callable $instrument, + int $startTimestamp + ) { + $this->metricAggregator = new MetricAggregator( + $attributeProcessor, + $aggregation, + $exemplarReservoir, + ); + $this->attributesFactory = $attributesFactory; + $this->aggregation = $aggregation; + $this->instrument = $instrument; + $this->startTimestamp = $startTimestamp; + $this->metric = new Metric([], [], $startTimestamp, -1); + } + + public function temporality() + { + return Temporality::CUMULATIVE; + } + + public function collectionTimestamp(): int + { + return $this->metric->timestamp; + } + + public function register($temporality): int + { + if ($temporality === Temporality::CUMULATIVE) { + return -1; + } + + if (($reader = array_search(null, $this->lastReads, true)) === false) { + $reader = count($this->lastReads); + } + + $this->lastReads[$reader] = $this->metric; + + return $reader; + } + + public function unregister(int $reader): void + { + if (!isset($this->lastReads[$reader])) { + return; + } + + $this->lastReads[$reader] = null; + } + + public function collect(int $reader, ?int $timestamp): DataInterface + { + if ($timestamp !== null && !$this->locked) { + $this->locked = true; + + try { + ($this->instrument)(new AsynchronousMetricStreamObserver($this->metricAggregator, $this->attributesFactory, $timestamp)); + + $this->metric = $this->metricAggregator->collect($timestamp); + } finally { + $this->locked = false; + } + } + + $metric = $this->metric; + + if (($lastRead = $this->lastReads[$reader] ?? null) === null) { + $temporality = Temporality::CUMULATIVE; + $startTimestamp = $this->startTimestamp; + } else { + $temporality = Temporality::DELTA; + $startTimestamp = $lastRead->timestamp; + + $this->lastReads[$reader] = $metric; + $metric = $this->diff($lastRead, $metric); + } + + return $this->aggregation->toData( + $metric->attributes, + $metric->summaries, + $this->metricAggregator->exemplars($metric), + $startTimestamp, + $metric->timestamp, + $temporality, + ); + } + + private function diff(Metric $lastRead, Metric $metric): Metric + { + assert($lastRead->revision <= $metric->revision); + + $diff = clone $metric; + foreach ($metric->summaries as $k => $summary) { + if (!isset($lastRead->summaries[$k])) { + continue; + } + + $diff->summaries[$k] = $this->aggregation->diff($lastRead->summaries[$k], $summary); + } + + return $diff; + } +} diff --git a/src/SDK/Metrics/Stream/AsynchronousMetricStreamObserver.php b/src/SDK/Metrics/Stream/AsynchronousMetricStreamObserver.php new file mode 100644 index 000000000..03ad9b768 --- /dev/null +++ b/src/SDK/Metrics/Stream/AsynchronousMetricStreamObserver.php @@ -0,0 +1,31 @@ +stream = $stream; + $this->attributesFactory = $attributesFactory; + $this->timestamp = $timestamp; + } + + public function observe($amount, iterable $attributes = []): void + { + $this->stream->record($amount, $this->attributesFactory->builder($attributes)->build(), Context::getRoot(), $this->timestamp); + } +} diff --git a/src/SDK/Metrics/Stream/Delta.php b/src/SDK/Metrics/Stream/Delta.php new file mode 100644 index 000000000..a4ff56d71 --- /dev/null +++ b/src/SDK/Metrics/Stream/Delta.php @@ -0,0 +1,33 @@ +metric = $metric; + $this->readers = $readers; + $this->prev = $prev; + } +} diff --git a/src/SDK/Metrics/Stream/DeltaStorage.php b/src/SDK/Metrics/Stream/DeltaStorage.php new file mode 100644 index 000000000..4e307c857 --- /dev/null +++ b/src/SDK/Metrics/Stream/DeltaStorage.php @@ -0,0 +1,111 @@ +aggregation = $aggregation; + $this->head = new Delta(new Metric([], [], 0, 0), 0); + + /** @phan-suppress-next-line PhanTypeObjectUnsetDeclaredProperty */ + unset($this->head->metric); + } + + /** + * @psalm-suppress UndefinedDocblockClass + * @phan-suppress PhanUndeclaredTypeParameter + * @param int|GMP $readers + */ + public function add(Metric $metric, $readers): void + { + /** @phpstan-ignore-next-line */ + if ($readers == 0) { + return; + } + + if (($this->head->prev->readers ?? null) != $readers) { + $this->head->prev = new Delta($metric, $readers, $this->head->prev); + } else { + assert($this->head->prev !== null); + $this->mergeInto($this->head->prev->metric, $metric); + } + } + + public function collect(int $reader, bool $retain = false): ?Metric + { + $n = null; + for ($d = $this->head; $d->prev; $d = $d->prev) { + if (($d->prev->readers >> $reader & 1) != 0) { + if ($n !== null) { + assert($n->prev !== null); + $n->prev->readers ^= $d->prev->readers; + $this->mergeInto($d->prev->metric, $n->prev->metric); + $this->tryUnlink($n); + + if ($n->prev === $d->prev) { + continue; + } + } + + $n = $d; + } + } + + $metric = $n->prev->metric ?? null; + + if (!$retain && $n) { + assert($n->prev !== null); + $n->prev->readers ^= ($n->prev->readers & 1 | 1) << $reader; + $this->tryUnlink($n); + } + + return $metric; + } + + private function tryUnlink(Delta $n): void + { + assert($n->prev !== null); + /** @phpstan-ignore-next-line */ + if ($n->prev->readers == 0) { + $n->prev = $n->prev->prev; + + return; + } + + for ($c = $n->prev->prev; + $c && ($n->prev->readers & $c->readers) == 0; + $c = $c->prev) { + } + + if ($c && $n->prev->readers === $c->readers) { + $this->mergeInto($c->metric, $n->prev->metric); + $n->prev = $n->prev->prev; + } + } + + private function mergeInto(Metric $into, Metric $metric): void + { + assert($into->revision < $metric->revision); + + foreach ($metric->summaries as $k => $summary) { + $into->attributes[$k] ??= $metric->attributes[$k]; + $into->summaries[$k] = isset($into->summaries[$k]) + ? $this->aggregation->merge($into->summaries[$k], $summary) + : $summary; + } + } +} diff --git a/src/SDK/Metrics/Stream/Metric.php b/src/SDK/Metrics/Stream/Metric.php new file mode 100644 index 000000000..f4ba67f6e --- /dev/null +++ b/src/SDK/Metrics/Stream/Metric.php @@ -0,0 +1,38 @@ + + */ + public array $attributes; + /** + * @var array + */ + public array $summaries; + public int $timestamp; + public int $revision; + /** + * @param array $attributes + * @param array $summaries + */ + public function __construct(array $attributes, array $summaries, int $timestamp, int $revision) + { + $this->attributes = $attributes; + $this->summaries = $summaries; + $this->timestamp = $timestamp; + $this->revision = $revision; + } +} diff --git a/src/SDK/Metrics/Stream/MetricAggregator.php b/src/SDK/Metrics/Stream/MetricAggregator.php new file mode 100644 index 000000000..eccfaade5 --- /dev/null +++ b/src/SDK/Metrics/Stream/MetricAggregator.php @@ -0,0 +1,84 @@ + */ + private array $attributes = []; + private array $summaries = []; + + public int $revision = 0; + + public function __construct( + ?AttributeProcessorInterface $attributeProcessor, + AggregationInterface $aggregation, + ?ExemplarReservoirInterface $exemplarReservoir + ) { + $this->attributeProcessor = $attributeProcessor; + $this->aggregation = $aggregation; + $this->exemplarReservoir = $exemplarReservoir; + } + + /** + * @param float|int $value + */ + public function record($value, AttributesInterface $attributes, Context $context, int $timestamp): void + { + $filteredAttributes = $this->attributeProcessor !== null + ? $this->attributeProcessor->process($attributes, $context) + : $attributes; + $raw = $filteredAttributes->toArray(); + $index = $raw !== [] ? serialize($raw) : 0; + $this->attributes[$index] = $filteredAttributes; + $this->aggregation->record( + $this->summaries[$index] ??= $this->aggregation->initialize(), + $value, + $attributes, + $context, + $timestamp, + ); + + if ($this->exemplarReservoir !== null) { + $this->exemplarReservoir->offer($index, $value, $attributes, $context, $timestamp, $this->revision); + } + } + + public function collect(int $timestamp): Metric + { + $metric = new Metric($this->attributes, $this->summaries, $timestamp, $this->revision); + + $this->attributes = []; + $this->summaries = []; + $this->revision++; + + return $metric; + } + + /** + * @return array> + */ + public function exemplars(Metric $metric): array + { + return $this->exemplarReservoir && $metric->revision !== -1 + ? $this->exemplarReservoir->collect($metric->attributes, $metric->revision, $this->revision) + : []; + } +} diff --git a/src/SDK/Metrics/Stream/MetricStreamInterface.php b/src/SDK/Metrics/Stream/MetricStreamInterface.php new file mode 100644 index 000000000..72ecf35cf --- /dev/null +++ b/src/SDK/Metrics/Stream/MetricStreamInterface.php @@ -0,0 +1,53 @@ + $streams + */ + public function __construct(?ContextStorageInterface $contextStorage, AttributesFactoryInterface $attributesFactory, iterable $streams) + { + $this->contextStorage = $contextStorage; + $this->attributesFactory = $attributesFactory; + $this->streams = $streams; + } + + public function record($value, iterable $attributes, $context, int $timestamp): void + { + $context = $context + ?? ($this->contextStorage ?? Context::storage())->current() + ?: Context::getRoot(); + + $attributes = $this->attributesFactory->builder($attributes)->build(); + foreach ($this->streams as $stream) { + $stream->record($value, $attributes, $context, $timestamp); + } + } +} diff --git a/src/SDK/Metrics/Stream/StreamWriter.php b/src/SDK/Metrics/Stream/StreamWriter.php new file mode 100644 index 000000000..30132b616 --- /dev/null +++ b/src/SDK/Metrics/Stream/StreamWriter.php @@ -0,0 +1,38 @@ +contextStorage = $contextStorage; + $this->attributesFactory = $attributesFactory; + $this->stream = $stream; + } + + public function record($value, iterable $attributes, $context, int $timestamp): void + { + $context = $context + ?? ($this->contextStorage ?? Context::storage())->current() + ?: Context::getRoot(); + + $attributes = $this->attributesFactory->builder($attributes)->build(); + $this->stream->record($value, $attributes, $context, $timestamp); + } +} diff --git a/src/SDK/Metrics/Stream/SynchronousMetricStream.php b/src/SDK/Metrics/Stream/SynchronousMetricStream.php new file mode 100644 index 000000000..507584124 --- /dev/null +++ b/src/SDK/Metrics/Stream/SynchronousMetricStream.php @@ -0,0 +1,144 @@ +metricAggregator = new MetricAggregator( + $attributeProcessor, + $aggregation, + $exemplarReservoir, + ); + $this->aggregation = $aggregation; + $this->timestamp = $startTimestamp; + $this->delta = new DeltaStorage($aggregation); + } + + public function writable(): WritableMetricStreamInterface + { + return $this->metricAggregator; + } + + public function temporality() + { + return Temporality::DELTA; + } + + public function collectionTimestamp(): int + { + return $this->timestamp; + } + + public function register($temporality): int + { + $reader = 0; + for ($r = $this->readers; ($r & 1) != 0; $r >>= 1, $reader++) { + } + + if ($reader === (PHP_INT_SIZE << 3) - 1 && is_int($this->readers)) { + if (!extension_loaded('gmp')) { + trigger_error(sprintf('GMP extension required to register over %d readers', (PHP_INT_SIZE << 3) - 1), E_USER_WARNING); + $reader = PHP_INT_SIZE << 3; + } else { + assert(is_int($this->cumulative)); + $this->readers = gmp_init($this->readers); + $this->cumulative = gmp_init($this->cumulative); + } + } + + $readerMask = ($this->readers & 1 | 1) << $reader; + $this->readers ^= $readerMask; + if ($temporality === Temporality::CUMULATIVE) { + $this->cumulative ^= $readerMask; + } + + return $reader; + } + + public function unregister(int $reader): void + { + $readerMask = ($this->readers & 1 | 1) << $reader; + if (($this->readers & $readerMask) == 0) { + return; + } + + $this->delta->collect($reader); + + $this->readers ^= $readerMask; + if (($this->cumulative & $readerMask) != 0) { + $this->cumulative ^= $readerMask; + } + } + + public function collect(int $reader, ?int $timestamp): DataInterface + { + if ($timestamp !== null) { + $this->delta->add( + $this->metricAggregator->collect($this->timestamp), + $this->readers, + ); + $this->timestamp = $timestamp; + } + + $cumulative = ($this->cumulative >> $reader & 1) != 0; + $metric = $this->delta->collect($reader, $cumulative) ?? new Metric([], [], $this->timestamp, -1); + + $temporality = $cumulative + ? Temporality::CUMULATIVE + : Temporality::DELTA; + + return $this->aggregation->toData( + $metric->attributes, + $metric->summaries, + $this->metricAggregator->exemplars($metric), + $metric->timestamp, + $this->timestamp, + $temporality, + ); + } +} diff --git a/src/SDK/Metrics/Stream/WritableMetricStreamInterface.php b/src/SDK/Metrics/Stream/WritableMetricStreamInterface.php new file mode 100644 index 000000000..3bcf2a3c3 --- /dev/null +++ b/src/SDK/Metrics/Stream/WritableMetricStreamInterface.php @@ -0,0 +1,19 @@ +writer = $writer; + $this->referenceCounter = $referenceCounter; + $this->clock = $clock; + + $this->referenceCounter->acquire(); } - /** - * Returns the current value - * - * @access public - * @return int - */ - public function getValue(): int + public function __destruct() { - return $this->value; + $this->referenceCounter->release(); } - /** - * Updates the UpDownCounter's value with the specified increment then returns the current value. - * - * @access public - * - * @param int|float $increment, accepts INTs or FLOATs. If increment is a float, it is truncated. - * - * @return int $value - */ - - public function add($increment): int + public function add($amount, iterable $attributes = [], $context = null): void { - if (is_float($increment)) { - /* - * - * todo: send the following message to the log when logger is implemented: - * Floating point detected, ignoring the fractional decimal places. - */ - $increment = (int) $increment; - } - if (!is_int($increment)) { - throw new InvalidArgumentException('Only numerical values can be used to update the UpDownCounter.'); - } - $this->value += $increment; - - return $this->value; + $this->writer->record($amount, $attributes, $context, $this->clock->now()); } } diff --git a/src/SDK/Metrics/ValueRecorder.php b/src/SDK/Metrics/ValueRecorder.php deleted file mode 100644 index bde98adb4..000000000 --- a/src/SDK/Metrics/ValueRecorder.php +++ /dev/null @@ -1,131 +0,0 @@ -valueSum; - } - - /** - * Returns the min of the values - * - * @access public - */ - public function getMin(): float - { - return $this->valueMin; - } - - /** - * Returns the max of the values - * - * @access public - */ - public function getMax(): float - { - return $this->valueMax; - } - - /** - * Returns the mean of the values - * - * @access public - */ - public function getMean(): float - { - if (0 == $this->valueCount) { - return 0; - } - - return ($this->valueSum/$this->valueCount); - } - - /** - * Returns the count of the values - * - * @access public - */ - public function getCount(): int - { - return $this->valueCount; - } - - /** - * Updates the ValueRecorder's value with the specified value. - * - * @access public - */ - public function record(float $value): void - { - $value = round($value, $this->decimalPointPrecision); - - $this->valueSum += $value; - $this->valueMin = min($this->valueMin, $value); - $this->valueMax = max($this->valueMax, $value); - $this->valueCount++; - } -} diff --git a/src/SDK/Metrics/View/CriteriaViewRegistry.php b/src/SDK/Metrics/View/CriteriaViewRegistry.php new file mode 100644 index 000000000..f387abf9c --- /dev/null +++ b/src/SDK/Metrics/View/CriteriaViewRegistry.php @@ -0,0 +1,40 @@ + */ + private array $criteria = []; + /** @var list */ + private array $views = []; + + public function register(SelectionCriteriaInterface $criteria, ViewTemplate $view): void + { + $this->criteria[] = $criteria; + $this->views[] = $view; + } + + public function find(Instrument $instrument, InstrumentationScopeInterface $instrumentationScope): ?iterable + { + $views = $this->generateViews($instrument, $instrumentationScope); + + return $views->valid() ? $views : null; + } + + private function generateViews(Instrument $instrument, InstrumentationScopeInterface $instrumentationScope): Generator + { + foreach ($this->criteria as $i => $criteria) { + if ($criteria->accepts($instrument, $instrumentationScope)) { + yield $this->views[$i]->project($instrument); + } + } + } +} diff --git a/src/SDK/Metrics/View/SelectionCriteria/AllCriteria.php b/src/SDK/Metrics/View/SelectionCriteria/AllCriteria.php new file mode 100644 index 000000000..438297324 --- /dev/null +++ b/src/SDK/Metrics/View/SelectionCriteria/AllCriteria.php @@ -0,0 +1,33 @@ + $criteria + */ + public function __construct(iterable $criteria) + { + $this->criteria = $criteria; + } + + public function accepts(Instrument $instrument, InstrumentationScopeInterface $instrumentationScope): bool + { + foreach ($this->criteria as $criterion) { + if (!$criterion->accepts($instrument, $instrumentationScope)) { + return false; + } + } + + return true; + } +} diff --git a/src/SDK/Metrics/View/SelectionCriteria/InstrumentNameCriteria.php b/src/SDK/Metrics/View/SelectionCriteria/InstrumentNameCriteria.php new file mode 100644 index 000000000..ed6034755 --- /dev/null +++ b/src/SDK/Metrics/View/SelectionCriteria/InstrumentNameCriteria.php @@ -0,0 +1,28 @@ +pattern = sprintf('/^%s$/', strtr(preg_quote($name, '/'), ['\\?' => '.', '\\*' => '.*'])); + } + + public function accepts(Instrument $instrument, InstrumentationScopeInterface $instrumentationScope): bool + { + return (bool) preg_match($this->pattern, $instrument->name); + } +} diff --git a/src/SDK/Metrics/View/SelectionCriteria/InstrumentTypeCriteria.php b/src/SDK/Metrics/View/SelectionCriteria/InstrumentTypeCriteria.php new file mode 100644 index 000000000..46a88def0 --- /dev/null +++ b/src/SDK/Metrics/View/SelectionCriteria/InstrumentTypeCriteria.php @@ -0,0 +1,29 @@ +instrumentTypes = (array) $instrumentType; + } + + public function accepts(Instrument $instrument, InstrumentationScopeInterface $instrumentationScope): bool + { + return in_array($instrument->type, $this->instrumentTypes, true); + } +} diff --git a/src/SDK/Metrics/View/SelectionCriteria/InstrumentationScopeNameCriteria.php b/src/SDK/Metrics/View/SelectionCriteria/InstrumentationScopeNameCriteria.php new file mode 100644 index 000000000..201d1a7b2 --- /dev/null +++ b/src/SDK/Metrics/View/SelectionCriteria/InstrumentationScopeNameCriteria.php @@ -0,0 +1,24 @@ +name = $name; + } + + public function accepts(Instrument $instrument, InstrumentationScopeInterface $instrumentationScope): bool + { + return $this->name === $instrumentationScope->getName(); + } +} diff --git a/src/SDK/Metrics/View/SelectionCriteria/InstrumentationScopeSchemaUrlCriteria.php b/src/SDK/Metrics/View/SelectionCriteria/InstrumentationScopeSchemaUrlCriteria.php new file mode 100644 index 000000000..a11a1d589 --- /dev/null +++ b/src/SDK/Metrics/View/SelectionCriteria/InstrumentationScopeSchemaUrlCriteria.php @@ -0,0 +1,24 @@ +schemaUrl = $schemaUrl; + } + + public function accepts(Instrument $instrument, InstrumentationScopeInterface $instrumentationScope): bool + { + return $this->schemaUrl === $instrumentationScope->getSchemaUrl(); + } +} diff --git a/src/SDK/Metrics/View/SelectionCriteria/InstrumentationScopeVersionCriteria.php b/src/SDK/Metrics/View/SelectionCriteria/InstrumentationScopeVersionCriteria.php new file mode 100644 index 000000000..37d180f99 --- /dev/null +++ b/src/SDK/Metrics/View/SelectionCriteria/InstrumentationScopeVersionCriteria.php @@ -0,0 +1,24 @@ +version = $version; + } + + public function accepts(Instrument $instrument, InstrumentationScopeInterface $instrumentationScope): bool + { + return $this->version === $instrumentationScope->getVersion(); + } +} diff --git a/src/SDK/Metrics/View/SelectionCriteriaInterface.php b/src/SDK/Metrics/View/SelectionCriteriaInterface.php new file mode 100644 index 000000000..8abd6fa69 --- /dev/null +++ b/src/SDK/Metrics/View/SelectionCriteriaInterface.php @@ -0,0 +1,13 @@ + + */ + private ?array $attributeKeys = null; + private ?AggregationInterface $aggregation = null; + + private function __construct() + { + } + + public static function create(): self + { + static $instance; + + return $instance ??= new self(); + } + + public function withName(string $name): self + { + $self = clone $this; + $self->name = $name; + + return $self; + } + + public function withDescription(string $description): self + { + $self = clone $this; + $self->description = $description; + + return $self; + } + + /** + * @param list $attributeKeys + */ + public function withAttributeKeys(array $attributeKeys): self + { + $self = clone $this; + $self->attributeKeys = $attributeKeys; + + return $self; + } + + public function withAggregation(?AggregationInterface $aggregation): self + { + $self = clone $this; + $self->aggregation = $aggregation; + + return $self; + } + + public function project(Instrument $instrument): ViewProjection + { + return new ViewProjection( + $this->name ?? $instrument->name, + $instrument->unit, + $this->description ?? $instrument->description, + $this->attributeKeys, + $this->aggregation, + ); + } +} diff --git a/src/SDK/Metrics/ViewProjection.php b/src/SDK/Metrics/ViewProjection.php new file mode 100644 index 000000000..046bd6bb1 --- /dev/null +++ b/src/SDK/Metrics/ViewProjection.php @@ -0,0 +1,47 @@ +|null + */ + public ?array $attributeKeys; + /** + * @readonly + */ + public ?AggregationInterface $aggregation; + + /** + * @param list|null $attributeKeys + */ + public function __construct( + string $name, + ?string $unit, + ?string $description, + ?array $attributeKeys, + ?AggregationInterface $aggregation + ) { + $this->name = $name; + $this->unit = $unit; + $this->description = $description; + $this->attributeKeys = $attributeKeys; + $this->aggregation = $aggregation; + } +} diff --git a/src/SDK/Metrics/ViewRegistryInterface.php b/src/SDK/Metrics/ViewRegistryInterface.php new file mode 100644 index 000000000..19d8f9ffd --- /dev/null +++ b/src/SDK/Metrics/ViewRegistryInterface.php @@ -0,0 +1,15 @@ +|null + */ + public function find(Instrument $instrument, InstrumentationScopeInterface $instrumentationScope): ?iterable; +} diff --git a/src/SDK/composer.json b/src/SDK/composer.json index 8695ab4b0..043bb7c51 100644 --- a/src/SDK/composer.json +++ b/src/SDK/composer.json @@ -34,6 +34,7 @@ ] }, "suggest": { + "ext-gmp": "To support unlimited number of synchronous metric readers", "ext-mbstring": "To increase performance of string operations" } } diff --git a/tests/Integration/SDK/MeterProviderTest.php b/tests/Integration/SDK/MeterProviderTest.php new file mode 100644 index 000000000..6cbfb3b00 --- /dev/null +++ b/tests/Integration/SDK/MeterProviderTest.php @@ -0,0 +1,101 @@ +meterProvider($reader, $clock); + + /** @noinspection PhpUnusedLocalVariableInspection */ + $instance = new class($meterProvider) { + public function __construct(API\MeterProviderInterface $meterProvider) + { + $meterProvider + ->getMeter('test') + ->createObservableUpDownCounter('test') + ->observe(fn (ObserverInterface $observer) => $observer->observe($this->count()), true); + } + public function count(): int + { + return 5; + } + }; + + $reader->collect(); + $this->assertEquals( + [ + new Metric( + new InstrumentationScope('test', null, null, Attributes::create([])), + ResourceInfoFactory::emptyResource(), + 'test', + null, + null, + new Sum([ + new NumberDataPoint(5, Attributes::create([]), TestClock::DEFAULT_START_EPOCH, TestClock::DEFAULT_START_EPOCH), + ], Temporality::CUMULATIVE, false), + ), + ], + $exporter->collect(true), + ); + + $instance = null; + $reader->collect(); + $this->assertEquals( + [ + ], + $exporter->collect(true), + ); + } + + /** + * @param MetricReaderInterface&MetricSourceRegistryInterface&DefaultAggregationProviderInterface $metricReader + */ + private function meterProvider(MetricReaderInterface $metricReader, ClockInterface $clock): MeterProviderInterface + { + return new MeterProvider( + null, + ResourceInfoFactory::emptyResource(), + $clock, + Attributes::factory(), + new InstrumentationScopeFactory(Attributes::factory()), + [$metricReader], + new CriteriaViewRegistry(), + new WithSampledTraceExemplarFilter(), + new ImmediateStalenessHandlerFactory(), + ); + } +} diff --git a/tests/Unit/API/Common/Instrumentation/InstrumentationTraitTest.php b/tests/Unit/API/Common/Instrumentation/InstrumentationTraitTest.php index 686c0446e..3aa41dbb0 100644 --- a/tests/Unit/API/Common/Instrumentation/InstrumentationTraitTest.php +++ b/tests/Unit/API/Common/Instrumentation/InstrumentationTraitTest.php @@ -8,7 +8,7 @@ use OpenTelemetry\API\Common\Instrumentation\InstrumentationTrait; use OpenTelemetry\API\Metrics\MeterInterface; use OpenTelemetry\API\Metrics\MeterProviderInterface; -use OpenTelemetry\API\Metrics\NoopMeter; +use OpenTelemetry\API\Metrics\Noop\NoopMeter; use OpenTelemetry\API\Trace\NoopTracer; use OpenTelemetry\API\Trace\TracerInterface; use OpenTelemetry\API\Trace\TracerProviderInterface; diff --git a/tests/Unit/API/Metrics/NoopMeterTest.php b/tests/Unit/API/Metrics/NoopMeterTest.php deleted file mode 100644 index 2e8710d3e..000000000 --- a/tests/Unit/API/Metrics/NoopMeterTest.php +++ /dev/null @@ -1,80 +0,0 @@ -instance = new NoopMeter(); - } - - public function test_name(): void - { - $this->assertSame(NoopMeter::NAME, $this->instance->getName()); - } - - public function test_version(): void - { - $this->assertSame(NoopMeter::VERSION, $this->instance->getVersion()); - } - - public function test_counter(): void - { - $name = 'foo'; - $description = 'bar'; - - $counter = $this->instance->newCounter($name, $description); - - $this->assertSame($name, $counter->getName()); - $this->assertSame($description, $counter->getDescription()); - $this->assertSame(MetricKind::COUNTER, $counter->getType()); - $this->assertSame( - 0, - $counter->add(1) - ->increment() - ->getValue() - ); - } - - public function test_up_down_counter(): void - { - $name = 'foo'; - $description = 'bar'; - - $counter = $this->instance->newUpDownCounter($name, $description); - - $this->assertSame( - 0, - $counter->add(1) - ); - } - - public function test_value_recorder(): void - { - $name = 'foo'; - $description = 'bar'; - - $recorder = $this->instance->newValueRecorder($name, $description); - $recorder->record(1); - - $this->assertSame($name, $recorder->getName()); - $this->assertSame($description, $recorder->getDescription()); - $this->assertSame(MetricKind::VALUE_RECORDER, $recorder->getType()); - $this->assertSame(0, $recorder->getCount()); - $this->assertSame(0.0, $recorder->getMax()); - $this->assertSame(0.0, $recorder->getMin()); - $this->assertSame(0.0, $recorder->getSum()); - } -} diff --git a/tests/Unit/Contrib/Otlp/MetricConverterTest.php b/tests/Unit/Contrib/Otlp/MetricConverterTest.php new file mode 100644 index 000000000..b5e4538bf --- /dev/null +++ b/tests/Unit/Contrib/Otlp/MetricConverterTest.php @@ -0,0 +1,208 @@ +assertEquals( + new ExportMetricsServiceRequest(), + (new MetricConverter())->convert([]), + ); + } + + public function test_metrics_are_converted_to_metrics_request(): void + { + $this->assertJsonStringEqualsJsonString( + <<convert([ + new Metric( + new InstrumentationScope('test', null, null, Attributes::create([])), + ResourceInfo::create(Attributes::create(['a' => 'b'])), + 'test-1', + null, + null, + new Sum([ + new NumberDataPoint(5, Attributes::create(['foo' => 'bar']), 17, 42), + ], Temporality::CUMULATIVE, false) + ), + new Metric( + new InstrumentationScope('test', null, null, Attributes::create([])), + ResourceInfo::create(Attributes::create(['a' => 'b'])), + 'test-2', + null, + null, + new Histogram([ + new HistogramDataPoint(2, 7, 3, 4, [2, 0], [5], Attributes::create([]), 17, 42), + ], Temporality::DELTA) + ), + new Metric( + new InstrumentationScope('test', null, null, Attributes::create([])), + ResourceInfo::create(Attributes::create(['a' => 'b'])), + 'test-3', + null, + null, + new Gauge([ + new NumberDataPoint(9.5, Attributes::create([]), 17, 42), + ]) + ), + ])->serializeToJsonString(), + ); + } + + public function test_sum_exemplars_are_converted(): void + { + $this->assertJsonStringEqualsJsonString( + <<convert([ + new Metric( + new InstrumentationScope('test', null, null, Attributes::create([])), + ResourceInfoFactory::emptyResource(), + 'test-1', + null, + null, + new Sum([ + new NumberDataPoint(5, Attributes::create(['foo' => 'bar']), 17, 42, [ + new Exemplar(.5, 19, Attributes::create(['key' => 'value']), null, null), + new Exemplar(-3, 37, Attributes::create(['key' => 'other']), '4bf92f3577b34da6a3ce929d0e0e4736', '00f067aa0ba902b7'), + ]), + ], Temporality::DELTA, false) + ), + ])->serializeToJsonString(), + ); + } + + public function test_histogram_exemplars_are_converted(): void + { + $this->assertJsonStringEqualsJsonString( + <<convert([ + new Metric( + new InstrumentationScope('test', null, null, Attributes::create([])), + ResourceInfoFactory::emptyResource(), + 'test-1', + null, + null, + new Histogram([ + new HistogramDataPoint(5, 9, -2, 8, [5], [], Attributes::create(['foo' => 'bar']), 17, 42, [ + new Exemplar(.5, 19, Attributes::create(['key' => 'value']), null, null), + new Exemplar(-3, 37, Attributes::create(['key' => 'other']), '4bf92f3577b34da6a3ce929d0e0e4736', '00f067aa0ba902b7'), + ]), + ], Temporality::DELTA) + ), + ])->serializeToJsonString(), + ); + } + + public function test_gauge_exemplars_are_converted(): void + { + $this->assertJsonStringEqualsJsonString( + <<convert([ + new Metric( + new InstrumentationScope('test', null, null, Attributes::create([])), + ResourceInfoFactory::emptyResource(), + 'test-1', + null, + null, + new Gauge([ + new NumberDataPoint(5, Attributes::create(['foo' => 'bar']), 17, 42, [ + new Exemplar(.5, 19, Attributes::create(['key' => 'value']), null, null), + new Exemplar(-3, 37, Attributes::create(['key' => 'other']), '4bf92f3577b34da6a3ce929d0e0e4736', '00f067aa0ba902b7'), + ]), + ]) + ), + ])->serializeToJsonString(), + ); + } + + public function test_multiple_resources_result_in_multiple_resource_metrics(): void + { + $resourceA = ResourceInfo::create(Attributes::create(['foo' => 'bar'])); + $resourceB = ResourceInfo::create(Attributes::create(['foo' => 'baz'])); + $this->assertCount( + 2, + (new MetricConverter())->convert([ + new Metric( + $this->createMock(InstrumentationScopeInterface::class), + $resourceA, + 'test', + null, + null, + $this->createMock(DataInterface::class), + ), + new Metric( + $this->createMock(InstrumentationScopeInterface::class), + $resourceB, + 'test', + null, + null, + $this->createMock(DataInterface::class), + ), + ])->getResourceMetrics(), + ); + } +} diff --git a/tests/Unit/Contrib/Otlp/StreamMetricExporterTest.php b/tests/Unit/Contrib/Otlp/StreamMetricExporterTest.php new file mode 100644 index 000000000..7e01dd45e --- /dev/null +++ b/tests/Unit/Contrib/Otlp/StreamMetricExporterTest.php @@ -0,0 +1,87 @@ +export([ + new Metric( + new InstrumentationScope('test', null, null, Attributes::create([])), + ResourceInfoFactory::emptyResource(), + 'test', + null, + null, + new Sum([ + new NumberDataPoint(5, Attributes::create([]), 17, 42), + ], Temporality::DELTA, false) + ), + ]); + + fseek($stream, 0); + $this->assertSame(<<export([ + new Metric( + new InstrumentationScope('test', null, null, Attributes::create([])), + ResourceInfoFactory::emptyResource(), + 'test', + null, + null, + new Sum([ + new NumberDataPoint(5, Attributes::create([]), 17, 42), + ], Temporality::DELTA, false) + ), + ]); + $exporter->export([ + new Metric( + new InstrumentationScope('test', null, null, Attributes::create([])), + ResourceInfoFactory::emptyResource(), + 'test', + null, + null, + new Sum([ + new NumberDataPoint(7, Attributes::create([]), 42, 57), + ], Temporality::DELTA, false) + ), + ]); + + fseek($stream, 0); + $this->assertSame(<<createMock(CollectorRegistry::class)); - - $this->assertEquals( - API\ExporterInterface::SUCCESS, - $exporter->export([]) - ); - } - - /** - * @dataProvider provideCounterData - */ - public function test_prometheus_registry_method_is_called_for_counter_export(int $count, array $labels): void - { - $counter = new Counter('prometheus_test_counter'); - $counter->add($count); - $counter->setLabels($labels); - - $prometheusCouner = $this->createMock(PrometheusCounter::class); - $prometheusCouner->expects($this->once()) - ->method('incBy') - ->with($count, $labels); - - $registry = $this->createMock(CollectorRegistry::class); - $registry->expects($this->once()) - ->method('getOrRegisterCounter') - ->willReturn($prometheusCouner); - - $exporter = new PrometheusExporter($registry); - - $this->assertEquals( - API\ExporterInterface::SUCCESS, - $exporter->export([$counter]) - ); - } - - public function provideCounterData(): array - { - return [ - [1, []], - [7, ['label_a']], - [12, ['label_a', 'label_b']], - ]; - } -} diff --git a/tests/Unit/SDK/Metrics/Aggregation/ExplicitBucketHistogramAggregationTest.php b/tests/Unit/SDK/Metrics/Aggregation/ExplicitBucketHistogramAggregationTest.php new file mode 100644 index 000000000..9d9032540 --- /dev/null +++ b/tests/Unit/SDK/Metrics/Aggregation/ExplicitBucketHistogramAggregationTest.php @@ -0,0 +1,149 @@ +assertEquals( + new ExplicitBucketHistogramSummary(0, 0, INF, -INF, [0, 0, 0]), + (new ExplicitBucketHistogramAggregation([0, 5]))->initialize() + ); + } + + public function test_record(): void + { + $summary = new ExplicitBucketHistogramSummary(2, 9, 3, 6, [0, 1, 1]); + (new ExplicitBucketHistogramAggregation([0, 5]))->record($summary, 5, Attributes::create([]), Context::getRoot(), 1); + $this->assertEquals( + new ExplicitBucketHistogramSummary(3, 14, 3, 6, [0, 2, 1]), + $summary, + ); + } + + public function test_merge(): void + { + $this->assertEquals( + new ExplicitBucketHistogramSummary(4, 17, 3, 6, [0, 3, 1]), + (new ExplicitBucketHistogramAggregation([0, 5]))->merge( + new ExplicitBucketHistogramSummary(1, 4, 4, 4, [0, 1, 0]), + new ExplicitBucketHistogramSummary(3, 13, 3, 6, [0, 2, 1]), + ), + ); + } + + public function test_diff(): void + { + $this->assertEquals( + new ExplicitBucketHistogramSummary(2, 9, 3, 6, [0, 1, 1]), + (new ExplicitBucketHistogramAggregation([0, 5]))->diff( + new ExplicitBucketHistogramSummary(1, 4, 4, 4, [0, 1, 0]), + new ExplicitBucketHistogramSummary(3, 13, 3, 6, [0, 2, 1]), + ), + ); + } + + public function test_diff_with_current_min_drops_min(): void + { + $this->assertNan( + (new ExplicitBucketHistogramAggregation([0, 5]))->diff( + new ExplicitBucketHistogramSummary(1, 3, 3, 3, [0, 1, 0]), + new ExplicitBucketHistogramSummary(3, 13, 3, 6, [0, 2, 1]), + )->min, + ); + } + + public function test_diff_with_current_max_drops_max(): void + { + $this->assertNan( + (new ExplicitBucketHistogramAggregation([0, 5]))->diff( + new ExplicitBucketHistogramSummary(1, 6, 6, 6, [0, 0, 1]), + new ExplicitBucketHistogramSummary(3, 13, 3, 6, [0, 2, 1]), + )->max, + ); + } + + public function test_to_data(): void + { + $this->assertEquals( + new Histogram( + [ + new HistogramDataPoint( + 3, + 13, + 3, + 6, + [0, 2, 1], + [0, 5], + Attributes::create([]), + 0, + 1, + ), + ], + Temporality::DELTA, + ), + (new ExplicitBucketHistogramAggregation([0, 5]))->toData( + [Attributes::create([])], + [new ExplicitBucketHistogramSummary(3, 13, 3, 6, [0, 2, 1])], + [], + 0, + 1, + Temporality::DELTA, + ), + ); + } + + public function test_to_data_empty(): void + { + $this->assertEquals( + new Histogram( + [ + ], + Temporality::DELTA, + ), + (new ExplicitBucketHistogramAggregation([0, 5]))->toData( + [Attributes::create([])], + [new ExplicitBucketHistogramSummary(0, 0, INF, -INF, [0, 0, 0])], + [], + 0, + 1, + Temporality::DELTA, + ), + ); + } + + public function test_exemplar_reservoir(): void + { + $this->assertEquals( + new HistogramBucketReservoir(Attributes::factory(), [0, 5]), + (new ExplicitBucketHistogramAggregation([0, 5]))->exemplarReservoir(Attributes::factory()), + ); + } + + public function test_exemplar_reservoir_single_bucket_returns_fixed_size_reservoir(): void + { + $this->assertEquals( + new FixedSizeReservoir(Attributes::factory()), + (new ExplicitBucketHistogramAggregation([]))->exemplarReservoir(Attributes::factory()), + ); + } +} diff --git a/tests/Unit/SDK/Metrics/Aggregation/LastValueAggregationTest.php b/tests/Unit/SDK/Metrics/Aggregation/LastValueAggregationTest.php new file mode 100644 index 000000000..ab70389df --- /dev/null +++ b/tests/Unit/SDK/Metrics/Aggregation/LastValueAggregationTest.php @@ -0,0 +1,132 @@ +assertEquals( + new LastValueSummary(null, 0), + (new LastValueAggregation())->initialize(), + ); + } + + public function test_record(): void + { + $summary = new LastValueSummary(3, 0); + (new LastValueAggregation())->record($summary, 5, Attributes::create([]), Context::getRoot(), 1); + $this->assertEquals( + new LastValueSummary(5, 1), + $summary, + ); + } + + public function test_record_older_timestamp(): void + { + $summary = new LastValueSummary(3, 2); + (new LastValueAggregation())->record($summary, 5, Attributes::create([]), Context::getRoot(), 1); + $this->assertEquals( + new LastValueSummary(3, 2), + $summary, + ); + } + + public function test_merge(): void + { + $this->assertEquals( + new LastValueSummary(5, 1), + (new LastValueAggregation())->merge(new LastValueSummary(8, 0), new LastValueSummary(5, 1)), + ); + } + + public function test_merge_older_timestamp(): void + { + $this->assertEquals( + new LastValueSummary(8, 2), + (new LastValueAggregation())->merge(new LastValueSummary(8, 2), new LastValueSummary(5, 1)), + ); + } + + public function test_diff(): void + { + $this->assertEquals( + new LastValueSummary(5, 1), + (new LastValueAggregation())->diff(new LastValueSummary(8, 0), new LastValueSummary(5, 1)), + ); + } + + public function test_diff_older_timestamp(): void + { + $this->assertEquals( + new LastValueSummary(8, 2), + (new LastValueAggregation())->diff(new LastValueSummary(8, 2), new LastValueSummary(5, 1)), + ); + } + + public function test_to_data(): void + { + $this->assertEquals( + new Gauge( + [ + new NumberDataPoint( + 5, + Attributes::create([]), + 0, + 1, + ), + ], + ), + (new LastValueAggregation())->toData( + [Attributes::create([])], + [new LastValueSummary(5, 1)], + [], + 0, + 1, + Temporality::DELTA, + ), + ); + } + + public function test_to_data_empty(): void + { + $this->assertEquals( + new Gauge( + [ + ], + ), + (new LastValueAggregation())->toData( + [Attributes::create([])], + [new LastValueSummary(null, 0)], + [], + 0, + 1, + Temporality::DELTA, + ), + ); + } + + public function test_exemplar_reservoir(): void + { + $this->assertEquals( + new FixedSizeReservoir(Attributes::factory()), + (new LastValueAggregation())->exemplarReservoir(Attributes::factory()), + ); + } +} diff --git a/tests/Unit/SDK/Metrics/Aggregation/SumAggregationTest.php b/tests/Unit/SDK/Metrics/Aggregation/SumAggregationTest.php new file mode 100644 index 000000000..dedda3f7f --- /dev/null +++ b/tests/Unit/SDK/Metrics/Aggregation/SumAggregationTest.php @@ -0,0 +1,96 @@ +assertEquals( + new SumSummary(0), + (new SumAggregation())->initialize(), + ); + } + + public function test_record(): void + { + $summary = new SumSummary(3); + (new SumAggregation())->record($summary, 5, Attributes::create([]), Context::getRoot(), 1); + $this->assertEquals( + new SumSummary(8), + $summary, + ); + } + + public function test_merge(): void + { + $this->assertEquals( + new SumSummary(13), + (new SumAggregation())->merge( + new SumSummary(8), + new SumSummary(5), + ), + ); + } + + public function test_diff(): void + { + $this->assertEquals( + new SumSummary(-3), + (new SumAggregation())->diff( + new SumSummary(8), + new SumSummary(5), + ), + ); + } + + public function test_to_data(): void + { + $this->assertEquals( + new Sum( + [ + new NumberDataPoint( + 5, + Attributes::create([]), + 0, + 1, + ), + ], + Temporality::DELTA, + false, + ), + (new SumAggregation())->toData( + [Attributes::create([])], + [new SumSummary(5)], + [], + 0, + 1, + Temporality::DELTA, + ), + ); + } + + public function test_exemplar_reservoir(): void + { + $this->assertEquals( + new FixedSizeReservoir(Attributes::factory()), + (new SumAggregation())->exemplarReservoir(Attributes::factory()), + ); + } +} diff --git a/tests/Unit/SDK/Metrics/AttributeProcessor/FilteredAttributeProcessorTest.php b/tests/Unit/SDK/Metrics/AttributeProcessor/FilteredAttributeProcessorTest.php new file mode 100644 index 000000000..8164b5bae --- /dev/null +++ b/tests/Unit/SDK/Metrics/AttributeProcessor/FilteredAttributeProcessorTest.php @@ -0,0 +1,26 @@ +assertEquals( + ['foo' => 3], + (new AttributeProcessor\FilteredAttributeProcessor(Attributes::factory(), ['foo'])) + ->process(Attributes::create(['foo' => 3, 'bar' => 5]), Context::getRoot()) + ->toArray(), + ); + } +} diff --git a/tests/Unit/SDK/Metrics/AttributeProcessor/IdentityAttributeProcessorTest.php b/tests/Unit/SDK/Metrics/AttributeProcessor/IdentityAttributeProcessorTest.php new file mode 100644 index 000000000..f526e503b --- /dev/null +++ b/tests/Unit/SDK/Metrics/AttributeProcessor/IdentityAttributeProcessorTest.php @@ -0,0 +1,26 @@ +assertEquals( + ['foo' => 3, 'bar' => 5], + (new AttributeProcessor\IdentityAttributeProcessor()) + ->process(Attributes::create(['foo' => 3, 'bar' => 5]), Context::getRoot()) + ->toArray(), + ); + } +} diff --git a/tests/Unit/SDK/Metrics/CounterTest.php b/tests/Unit/SDK/Metrics/CounterTest.php deleted file mode 100644 index 0caf8c9e1..000000000 --- a/tests/Unit/SDK/Metrics/CounterTest.php +++ /dev/null @@ -1,49 +0,0 @@ -counter = new Counter('some_counter'); - } - - public function test_get_type(): void - { - $this->assertSame(API\MetricKind::COUNTER, $this->counter->getType()); - } - - public function test_counter_increments(): void - { - $this->assertSame(0, $this->counter->getValue()); - - $this->counter->increment(); - - $this->assertSame(1, $this->counter->getValue()); - } - - public function test_counter_does_not_accept_negative_numbers(): void - { - $this->expectException(\InvalidArgumentException::class); - - $this->counter->add(-1); - } - - public function test_add(): void - { - $this->counter->add(5); - $this->assertSame(5, $this->counter->getValue()); - } -} diff --git a/tests/Unit/SDK/Metrics/Exemplar/BucketStorageTest.php b/tests/Unit/SDK/Metrics/Exemplar/BucketStorageTest.php new file mode 100644 index 000000000..98737cfe2 --- /dev/null +++ b/tests/Unit/SDK/Metrics/Exemplar/BucketStorageTest.php @@ -0,0 +1,92 @@ +assertEquals([], $storage->collect([], 0, 1)); + } + + public function test_storage_returns_stored_exemplars(): void + { + $storage = new BucketStorage(Attributes::factory()); + + $storage->store(0, 0, 5, Attributes::create([]), Context::getRoot(), 7, 0); + $storage->store(1, 1, 3, Attributes::create([]), Context::getRoot(), 8, 0); + $storage->store(2, 0, 4, Attributes::create([]), Context::getRoot(), 9, 0); + + $this->assertEquals([ + 0 => [ + new Exemplar(5, 7, Attributes::create([]), null, null), + new Exemplar(4, 9, Attributes::create([]), null, null), + ], + 1 => [ + new Exemplar(3, 8, Attributes::create([]), null, null), + ], + ], $storage->collect([0 => Attributes::create([]), 1 => Attributes::create([])], 0, 1)); + } + + public function test_storage_stores_trace_information(): void + { + $storage = new BucketStorage(Attributes::factory()); + + $context = AbstractSpan::wrap(SpanContext::create('12345678901234567890123456789012', '1234567890123456')) + ->storeInContext(Context::getRoot()); + + $storage->store(0, 0, 5, Attributes::create([]), $context, 7, 0); + + $this->assertEquals([ + 0 => [ + new Exemplar(5, 7, Attributes::create([]), '12345678901234567890123456789012', '1234567890123456'), + ], + ], $storage->collect([0 => Attributes::create([])], 0, 1)); + } + + public function test_storage_returns_filtered_attributes(): void + { + $storage = new BucketStorage(Attributes::factory()); + + $storage->store(0, 0, 5, Attributes::create(['foo' => 5, 'bar' => 7]), Context::getRoot(), 7, 0); + + $this->assertEquals([ + 0 => [ + new Exemplar(5, 7, Attributes::create(['bar' => 7]), null, null), + ], + ], $storage->collect([0 => Attributes::create(['foo' => 5]), 1 => Attributes::create([])], 0, 1)); + } + + public function test_storage_doesnt_return_exemplars_with_revision_out_of_requested_range(): void + { + $storage = new BucketStorage(Attributes::factory()); + + $storage->store(0, 0, 5, Attributes::create([]), Context::getRoot(), 7, 0); + $storage->store(1, 1, 3, Attributes::create([]), Context::getRoot(), 8, 0); + $storage->store(2, 0, 4, Attributes::create([]), Context::getRoot(), 9, 1); + + $this->assertEquals([ + 0 => [ + new Exemplar(5, 7, Attributes::create([]), null, null), + ], + 1 => [ + new Exemplar(3, 8, Attributes::create([]), null, null), + ], + ], $storage->collect([0 => Attributes::create([]), 1 => Attributes::create([])], 0, 1)); + } +} diff --git a/tests/Unit/SDK/Metrics/Exemplar/FilteredReservoirTest.php b/tests/Unit/SDK/Metrics/Exemplar/FilteredReservoirTest.php new file mode 100644 index 000000000..4c13dc037 --- /dev/null +++ b/tests/Unit/SDK/Metrics/Exemplar/FilteredReservoirTest.php @@ -0,0 +1,87 @@ +offer(0, 5, Attributes::create([]), Context::getRoot(), 7, 0); + + $this->assertEquals([ + 0 => [ + new Exemplar(5, 7, Attributes::create([]), null, null), + ], + ], $reservoir->collect([0 => Attributes::create([])], 0, 1)); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\Exemplar\ExemplarFilter\NoneExemplarFilter + */ + public function test_none_reservoir_doesnt_return_exemplars(): void + { + $reservoir = new FilteredReservoir(new FixedSizeReservoir(Attributes::factory(), 4), new NoneExemplarFilter()); + $reservoir->offer(0, 5, Attributes::create([]), Context::getRoot(), 7, 0); + + $this->assertEquals([ + ], $reservoir->collect([0 => Attributes::create([])], 0, 1)); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\Exemplar\ExemplarFilter\WithSampledTraceExemplarFilter + */ + public function test_with_sampled_trace_reservoir_returns_sampled_exemplars(): void + { + $reservoir = new FilteredReservoir(new FixedSizeReservoir(Attributes::factory(), 4), new WithSampledTraceExemplarFilter()); + + $context = AbstractSpan::wrap(SpanContext::create('12345678901234567890123456789012', '1234567890123456', SpanContextInterface::TRACE_FLAG_SAMPLED)) + ->storeInContext(Context::getRoot()); + + $reservoir->offer(0, 5, Attributes::create([]), $context, 7, 0); + + $this->assertEquals([ + 0 => [ + new Exemplar(5, 7, Attributes::create([]), '12345678901234567890123456789012', '1234567890123456'), + ], + ], $reservoir->collect([0 => Attributes::create([])], 0, 1)); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\Exemplar\ExemplarFilter\WithSampledTraceExemplarFilter + */ + public function test_with_sampled_trace_reservoir_doesnt_return_not_sampled_exemplars(): void + { + $reservoir = new FilteredReservoir(new FixedSizeReservoir(Attributes::factory(), 4), new WithSampledTraceExemplarFilter()); + + $context = AbstractSpan::wrap(SpanContext::create('12345678901234567890123456789012', '1234567890123456')) + ->storeInContext(Context::getRoot()); + + $reservoir->offer(0, 5, Attributes::create([]), $context, 7, 0); + + $this->assertEquals([ + ], $reservoir->collect([0 => Attributes::create([])], 0, 1)); + } +} diff --git a/tests/Unit/SDK/Metrics/Exemplar/FixedSizeReservoirTest.php b/tests/Unit/SDK/Metrics/Exemplar/FixedSizeReservoirTest.php new file mode 100644 index 000000000..891b6946d --- /dev/null +++ b/tests/Unit/SDK/Metrics/Exemplar/FixedSizeReservoirTest.php @@ -0,0 +1,29 @@ +offer(0, 5, Attributes::create([]), Context::getRoot(), 7, 0); + + $this->assertEquals([ + 0 => [ + new Exemplar(5, 7, Attributes::create([]), null, null), + ], + ], $reservoir->collect([0 => Attributes::create([])], 0, 1)); + } +} diff --git a/tests/Unit/SDK/Metrics/Exemplar/HistogramBucketReservoirTest.php b/tests/Unit/SDK/Metrics/Exemplar/HistogramBucketReservoirTest.php new file mode 100644 index 000000000..ec86e8568 --- /dev/null +++ b/tests/Unit/SDK/Metrics/Exemplar/HistogramBucketReservoirTest.php @@ -0,0 +1,32 @@ +offer(0, 5, Attributes::create([]), Context::getRoot(), 7, 0); + $reservoir->offer(0, -5, Attributes::create([]), Context::getRoot(), 8, 0); + $reservoir->offer(0, 7, Attributes::create([]), Context::getRoot(), 9, 0); + + $this->assertEquals([ + 0 => [ + new Exemplar(-5, 8, Attributes::create([]), null, null), + new Exemplar(7, 9, Attributes::create([]), null, null), + ], + ], $reservoir->collect([0 => Attributes::create([])], 0, 1)); + } +} diff --git a/tests/Unit/SDK/Metrics/Exemplar/NoopReservoirTest.php b/tests/Unit/SDK/Metrics/Exemplar/NoopReservoirTest.php new file mode 100644 index 000000000..51fb06d29 --- /dev/null +++ b/tests/Unit/SDK/Metrics/Exemplar/NoopReservoirTest.php @@ -0,0 +1,24 @@ +offer(0, 5, Attributes::create([]), Context::getRoot(), 7, 0); + + $this->assertCount(0, $reservoir->collect([0 => Attributes::create([])], 0, 1)); + } +} diff --git a/tests/Unit/SDK/Metrics/Exporters/AbstractExporterTest.php b/tests/Unit/SDK/Metrics/Exporters/AbstractExporterTest.php deleted file mode 100644 index 02ef76894..000000000 --- a/tests/Unit/SDK/Metrics/Exporters/AbstractExporterTest.php +++ /dev/null @@ -1,43 +0,0 @@ -assertEquals( - API\ExporterInterface::SUCCESS, - $this->getExporter()->export([]) - ); - } - - public function test_error_returns_if_trying_to_export_not_a_metric(): void - { - /** - * @phpstan-ignore-next-line - * @psalm-suppress InvalidArgument - */ - $export = $this->getExporter()->export([1]); - - $this->assertEquals(API\ExporterInterface::FAILED_NOT_RETRYABLE, $export); - } - - protected function getExporter(): AbstractExporter - { - return new class() extends AbstractExporter { - protected function doExport(iterable $metrics): void - { - } - }; - } -} diff --git a/tests/Unit/SDK/Metrics/HasLabelTest.php b/tests/Unit/SDK/Metrics/HasLabelTest.php deleted file mode 100644 index 3ffa5ddf9..000000000 --- a/tests/Unit/SDK/Metrics/HasLabelTest.php +++ /dev/null @@ -1,41 +0,0 @@ -labelable = new class() { - use HasLabelsTrait; - }; - } - - public function test_has_label_accepts_values(): void - { - $this->assertEmpty($this->labelable->getLabels()); - - $expected = ['label_one', 'label_two']; - - $this->labelable->setLabels($expected); - - $this->assertSame($expected, $this->labelable->getLabels()); - } - - public function test_has_label_accepts_only_strings(): void - { - $this->expectException(\InvalidArgumentException::class); - - $this->labelable->setLabels([new \stdClass()]); - } -} diff --git a/tests/Unit/SDK/Metrics/InstrumentTest.php b/tests/Unit/SDK/Metrics/InstrumentTest.php new file mode 100644 index 000000000..61c9b3afa --- /dev/null +++ b/tests/Unit/SDK/Metrics/InstrumentTest.php @@ -0,0 +1,255 @@ +writable()), new NoopStalenessHandler(), ClockFactory::getDefault()); + $r = $s->register(Temporality::DELTA); + + $c->add(5); + $c->add(7); + $c->add(3); + + $this->assertEquals(new Data\Sum( + [ + new Data\NumberDataPoint( + 15, + Attributes::create([]), + 0, + 1, + ), + ], + Temporality::DELTA, + true, + ), $s->collect($r, 1)); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\ObservableCounter + */ + public function test_asynchronous_counter(): void + { + $o = new MultiObserver(); + $s = new AsynchronousMetricStream(Attributes::factory(), null, new SumAggregation(true), null, $o, 0); + $c = new ObservableCounter($o, new NoopStalenessHandler()); + $r = $s->register(Temporality::CUMULATIVE); + + $c->observe(static function (ObserverInterface $observer): void { + $observer->observe(5); + }); + + $this->assertEquals(new Data\Sum( + [ + new Data\NumberDataPoint( + 5, + Attributes::create([]), + 0, + 1, + ), + ], + Temporality::CUMULATIVE, + true, + ), $s->collect($r, 1)); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\ObservableCounter + */ + public function test_asynchronous_counter_weaken(): void + { + $o = new MultiObserver(); + $s = new AsynchronousMetricStream(Attributes::factory(), null, new SumAggregation(true), null, $o, 0); + $c = new ObservableCounter($o, new NoopStalenessHandler()); + $r = $s->register(Temporality::CUMULATIVE); + + $instance = new class() { + public function __invoke(ObserverInterface $observer) + { + $observer->observe(5); + } + }; + + $c->observe($instance, true); + $instance = null; + + $this->assertEquals(new Data\Sum( + [], + Temporality::CUMULATIVE, + true, + ), $s->collect($r, 1)); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\UpDownCounter + */ + public function test_up_down_counter(): void + { + $s = new SynchronousMetricStream(null, new SumAggregation(false), null, 0); + $c = new UpDownCounter(new StreamWriter(null, Attributes::factory(), $s->writable()), new NoopStalenessHandler(), ClockFactory::getDefault()); + $r = $s->register(Temporality::DELTA); + + $c->add(5); + $c->add(7); + $c->add(-8); + + $this->assertEquals(new Data\Sum( + [ + new Data\NumberDataPoint( + 4, + Attributes::create([]), + 0, + 1, + ), + ], + Temporality::DELTA, + false, + ), $s->collect($r, 1)); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\Histogram + */ + public function test_histogram(): void + { + $s = new SynchronousMetricStream(null, new ExplicitBucketHistogramAggregation([3, 6, 9]), null, 0); + $h = new Histogram(new StreamWriter(null, Attributes::factory(), $s->writable()), new NoopStalenessHandler(), ClockFactory::getDefault()); + $r = $s->register(Temporality::DELTA); + + $h->record(1); + $h->record(7); + $h->record(9); + $h->record(12); + $h->record(15); + $h->record(8); + $h->record(7); + + $this->assertEquals(new Data\Histogram( + [ + new Data\HistogramDataPoint( + 7, + 59, + 1, + 15, + [1, 0, 4, 2], + [3, 6, 9], + Attributes::create([]), + 0, + 1, + ), + ], + Temporality::DELTA, + ), $s->collect($r, 1)); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\ObservableCallback + */ + public function test_observable_callback_releases_on_detach(): void + { + $metricObserver = $this->createMock(MetricObserverInterface::class); + $metricObserver->method('has')->with(1)->willReturnOnConsecutiveCalls(true, false); + $metricObserver->expects($this->once())->method('cancel')->with(1); + $referenceCounter = $this->createMock(ReferenceCounterInterface::class); + $referenceCounter->expects($this->once())->method('release'); + + $callback = new ObservableCallback($metricObserver, $referenceCounter, 1, null); + $callback->detach(); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\ObservableCallback + */ + public function test_observable_callback_removes_callback_destructor_token_on_detach(): void + { + $metricObserver = $this->createMock(MetricObserverInterface::class); + $metricObserver->method('has')->with(1)->willReturnOnConsecutiveCalls(true, false); + $referenceCounter = $this->createMock(ReferenceCounterInterface::class); + + $callbackDestructor = new CallbackDestructor($metricObserver, $referenceCounter); + $callbackDestructor->tokens[1] = 1; + + $callback = new ObservableCallback($metricObserver, $referenceCounter, 1, $callbackDestructor); + $callback->detach(); + + $this->assertArrayNotHasKey(1, $callbackDestructor->tokens); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\ObservableCallback + */ + public function test_observable_callback_does_not_release_on_detach_if_invalid_token(): void + { + $metricObserver = $this->createMock(MetricObserverInterface::class); + $metricObserver->method('has')->with(1)->willReturn(false); + $metricObserver->expects($this->never())->method('cancel')->with(1); + $referenceCounter = $this->createMock(ReferenceCounterInterface::class); + $referenceCounter->expects($this->never())->method('release'); + + $callback = new ObservableCallback($metricObserver, $referenceCounter, 1, null); + $callback->detach(); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\ObservableCallback + */ + public function test_observable_callback_acquires_persistent_on_destruct(): void + { + $metricObserver = $this->createMock(MetricObserverInterface::class); + $metricObserver->method('has')->with(1)->willReturn(true); + $referenceCounter = $this->createMock(ReferenceCounterInterface::class); + $referenceCounter->expects($this->once())->method('acquire')->with(true); + $referenceCounter->expects($this->once())->method('release'); + + /** @noinspection PhpExpressionResultUnusedInspection */ + new ObservableCallback($metricObserver, $referenceCounter, 1, null); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\ObservableCallback + */ + public function test_observable_callback_does_not_acquire_persistent_on_destruct_if_callback_destructor_set(): void + { + $metricObserver = $this->createMock(MetricObserverInterface::class); + $metricObserver->method('has')->with(1)->willReturn(true); + $referenceCounter = $this->createMock(ReferenceCounterInterface::class); + $referenceCounter->expects($this->never())->method('acquire')->with(true); + + $callbackDestructor = new CallbackDestructor($metricObserver, $referenceCounter); + $callbackDestructor->tokens[1] = 1; + + /** @noinspection PhpExpressionResultUnusedInspection */ + new ObservableCallback($metricObserver, $referenceCounter, 1, $callbackDestructor); + } +} diff --git a/tests/Unit/SDK/Metrics/MeterProviderTest.php b/tests/Unit/SDK/Metrics/MeterProviderTest.php new file mode 100644 index 000000000..7615771f9 --- /dev/null +++ b/tests/Unit/SDK/Metrics/MeterProviderTest.php @@ -0,0 +1,134 @@ +prophesize(InstrumentationScopeFactoryInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $instrumentationScopeFactory + ->create() + ->shouldBeCalledOnce() + ->withArguments([ + 'name', + '0.0.1', + 'https://schema-url.test', + [], + ]) + ->willReturn(new InstrumentationScope('name', '0.0.1', 'https://schema-url.test', Attributes::create([]))); + + /** @noinspection PhpParamsInspection */ + $meterProvider = new MeterProvider( + null, + ResourceInfoFactory::emptyResource(), + ClockFactory::getDefault(), + Attributes::factory(), + $instrumentationScopeFactory->reveal(), + [], + new CriteriaViewRegistry(), + null, + new ImmediateStalenessHandlerFactory(), + ); + $meterProvider->getMeter('name', '0.0.1', 'https://schema-url.test'); + } + + public function test_get_meter_returns_noop_meter_after_shutdown(): void + { + $meterProvider = new MeterProvider( + null, + ResourceInfoFactory::emptyResource(), + ClockFactory::getDefault(), + Attributes::factory(), + new InstrumentationScopeFactory(Attributes::factory()), + [], + new CriteriaViewRegistry(), + null, + new ImmediateStalenessHandlerFactory(), + ); + $meterProvider->shutdown(); + + $this->assertInstanceOf(NoopMeter::class, $meterProvider->getMeter('name')); + } + + public function test_shutdown_calls_metric_reader_shutdown(): void + { + /** @psalm-suppress TooFewArguments */ + $metricReader = $this->prophesize() + ->willImplement(MetricSourceRegistryInterface::class) + ->willImplement(MetricReaderInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $metricReader + ->shutdown() + ->shouldBeCalledOnce() + ->willReturn(true); + + $meterProvider = new MeterProvider( + null, + ResourceInfoFactory::emptyResource(), + ClockFactory::getDefault(), + Attributes::factory(), + new InstrumentationScopeFactory(Attributes::factory()), + /** @phpstan-ignore-next-line */ + [$metricReader->reveal()], + new CriteriaViewRegistry(), + null, + new ImmediateStalenessHandlerFactory(), + ); + $this->assertTrue($meterProvider->shutdown()); + } + + public function test_force_flush_calls_metric_reader_force_flush(): void + { + /** @psalm-suppress TooFewArguments */ + $metricReader = $this->prophesize() + ->willImplement(MetricSourceRegistryInterface::class) + ->willImplement(MetricReaderInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $metricReader + ->forceFlush() + ->shouldBeCalledOnce() + ->willReturn(true); + + /** @noinspection PhpParamsInspection */ + $meterProvider = new MeterProvider( + null, + ResourceInfoFactory::emptyResource(), + ClockFactory::getDefault(), + Attributes::factory(), + new InstrumentationScopeFactory(Attributes::factory()), + /** @phpstan-ignore-next-line */ + [$metricReader->reveal()], + new CriteriaViewRegistry(), + null, + new ImmediateStalenessHandlerFactory(), + ); + $this->assertTrue($meterProvider->forceFlush()); + } +} diff --git a/tests/Unit/SDK/Metrics/MeterTest.php b/tests/Unit/SDK/Metrics/MeterTest.php index 4f0c7bfd6..47b650665 100644 --- a/tests/Unit/SDK/Metrics/MeterTest.php +++ b/tests/Unit/SDK/Metrics/MeterTest.php @@ -4,58 +4,405 @@ namespace OpenTelemetry\Tests\Unit\SDK\Metrics; -use OpenTelemetry\SDK\Metrics\Counter; -use OpenTelemetry\SDK\Metrics\Meter; -use OpenTelemetry\SDK\Metrics\UpDownCounter; -use OpenTelemetry\SDK\Metrics\ValueRecorder; +use function func_get_arg; +use OpenTelemetry\API\Metrics\ObserverInterface; +use OpenTelemetry\SDK\Common\Attribute\Attributes; +use OpenTelemetry\SDK\Common\Instrumentation\InstrumentationScope; +use OpenTelemetry\SDK\Common\Instrumentation\InstrumentationScopeFactory; +use OpenTelemetry\SDK\Common\Time\ClockFactory; +use OpenTelemetry\SDK\Metrics\AggregationInterface; +use OpenTelemetry\SDK\Metrics\DefaultAggregationProviderInterface; +use OpenTelemetry\SDK\Metrics\Instrument; +use OpenTelemetry\SDK\Metrics\InstrumentType; +use OpenTelemetry\SDK\Metrics\MeterProvider; +use OpenTelemetry\SDK\Metrics\MetricFactoryInterface; +use OpenTelemetry\SDK\Metrics\MetricObserverInterface; +use OpenTelemetry\SDK\Metrics\MetricReaderInterface; +use OpenTelemetry\SDK\Metrics\MetricSourceRegistryInterface; +use OpenTelemetry\SDK\Metrics\MetricWriterInterface; +use OpenTelemetry\SDK\Metrics\StalenessHandler\ImmediateStalenessHandlerFactory; +use OpenTelemetry\SDK\Metrics\View\CriteriaViewRegistry; +use OpenTelemetry\SDK\Metrics\ViewProjection; +use OpenTelemetry\SDK\Metrics\ViewRegistryInterface; +use OpenTelemetry\SDK\Resource\ResourceInfoFactory; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; /** - * @covers OpenTelemetry\SDK\Metrics\Meter + * @covers \OpenTelemetry\SDK\Metrics\Meter */ -class MeterTest extends TestCase +final class MeterTest extends TestCase { - public function test_meter(): void + use ProphecyTrait; + + public function test_create_counter(): void + { + $metricFactory = $this->prophesize(MetricFactoryInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $metricFactory + ->createSynchronousWriter() + ->shouldBeCalledOnce() + ->withArguments([ + ResourceInfoFactory::emptyResource(), + new InstrumentationScope('test', null, null, Attributes::create([])), + new Instrument(InstrumentType::COUNTER, 'name', 'unit', 'description'), + Argument::cetera(), + ]); + + /** @noinspection PhpParamsInspection */ + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory->reveal()); + $meter = $meterProvider->getMeter('test'); + $meter->createCounter('name', 'unit', 'description'); + } + + public function test_create_histogram(): void { - $meter = new Meter('Meter', '0.1'); + $metricFactory = $this->prophesize(MetricFactoryInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $metricFactory + ->createSynchronousWriter() + ->shouldBeCalledOnce() + ->withArguments([ + ResourceInfoFactory::emptyResource(), + new InstrumentationScope('test', null, null, Attributes::create([])), + new Instrument(InstrumentType::HISTOGRAM, 'name', 'unit', 'description'), + Argument::cetera(), + ]); - $this->assertSame('Meter', $meter->getName()); - $this->assertSame('0.1', $meter->getVersion()); + /** @noinspection PhpParamsInspection */ + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory->reveal()); + $meter = $meterProvider->getMeter('test'); + $meter->createHistogram('name', 'unit', 'description'); } - public function test_meter_counter(): void + public function test_create_up_down_counter(): void { - $meter = new Meter('Meter', '0.1'); + $metricFactory = $this->prophesize(MetricFactoryInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $metricFactory + ->createSynchronousWriter() + ->shouldBeCalledOnce() + ->withArguments([ + ResourceInfoFactory::emptyResource(), + new InstrumentationScope('test', null, null, Attributes::create([])), + new Instrument(InstrumentType::UP_DOWN_COUNTER, 'name', 'unit', 'description'), + Argument::cetera(), + ]); - $counterName = 'Counter'; - $counterDescription = 'A counter'; - $counter = $meter->newCounter($counterName, $counterDescription); - $this->assertInstanceOf(Counter::class, $counter); - $this->assertEquals($counterName, $counter->getName()); - $this->assertEquals($counterDescription, $counter->getDescription()); + /** @noinspection PhpParamsInspection */ + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory->reveal()); + $meter = $meterProvider->getMeter('test'); + $meter->createUpDownCounter('name', 'unit', 'description'); } - public function test_meter_up_down_counter(): void + public function test_create_observable_counter(): void { - $meter = new Meter('Meter', '0.1'); + $metricFactory = $this->prophesize(MetricFactoryInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $metricFactory + ->createAsynchronousObserver() + ->shouldBeCalledOnce() + ->withArguments([ + ResourceInfoFactory::emptyResource(), + new InstrumentationScope('test', null, null, Attributes::create([])), + new Instrument(InstrumentType::ASYNCHRONOUS_COUNTER, 'name', 'unit', 'description'), + Argument::cetera(), + ]); - $upDownCounterName = 'Updowncounter'; - $upDownCounterDescription = 'An up/down counter'; - $upDownCounter = $meter->newUpDownCounter($upDownCounterName, $upDownCounterDescription); - $this->assertInstanceOf(UpDownCounter::class, $upDownCounter); - $this->assertEquals($upDownCounterName, $upDownCounter->getName()); - $this->assertEquals($upDownCounterDescription, $upDownCounter->getDescription()); + /** @noinspection PhpParamsInspection */ + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory->reveal()); + $meter = $meterProvider->getMeter('test'); + $meter->createObservableCounter('name', 'unit', 'description'); } - public function test_meter_value_recorder(): void + public function test_create_observable_gauge(): void { - $meter = new Meter('Meter', '0.1'); + $metricFactory = $this->prophesize(MetricFactoryInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $metricFactory + ->createAsynchronousObserver() + ->shouldBeCalledOnce() + ->withArguments([ + ResourceInfoFactory::emptyResource(), + new InstrumentationScope('test', null, null, Attributes::create([])), + new Instrument(InstrumentType::ASYNCHRONOUS_GAUGE, 'name', 'unit', 'description'), + Argument::cetera(), + ]); + + /** @noinspection PhpParamsInspection */ + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory->reveal()); + $meter = $meterProvider->getMeter('test'); + $meter->createObservableGauge('name', 'unit', 'description'); + } + + public function test_create_observable_up_down_counter(): void + { + $metricFactory = $this->prophesize(MetricFactoryInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $metricFactory + ->createAsynchronousObserver() + ->shouldBeCalledOnce() + ->withArguments([ + ResourceInfoFactory::emptyResource(), + new InstrumentationScope('test', null, null, Attributes::create([])), + new Instrument(InstrumentType::ASYNCHRONOUS_UP_DOWN_COUNTER, 'name', 'unit', 'description'), + Argument::cetera(), + ]); + + /** @noinspection PhpParamsInspection */ + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory->reveal()); + $meter = $meterProvider->getMeter('test'); + $meter->createObservableUpDownCounter('name', 'unit', 'description'); + } + + public function test_create_observable_counter_register_permanent_callback(): void + { + $callable = fn (ObserverInterface $observer) => $observer->observe(0); + $metricFactory = $this->createMock(MetricFactoryInterface::class); + $metricFactory->method('createAsynchronousObserver') + ->willReturnCallback(function () use ($callable) { + $observer = $this->createMock(MetricObserverInterface::class); + $observer->expects($this->once())->method('observe')->with($callable); + + return $observer; + }); + + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory); + $meter = $meterProvider->getMeter('test'); + $meter->createObservableCounter('name', 'unit', 'description', $callable); + } + + public function test_create_observable_gauge_register_permanent_callback(): void + { + $callable = fn (ObserverInterface $observer) => $observer->observe(0); + $metricFactory = $this->createMock(MetricFactoryInterface::class); + $metricFactory->method('createAsynchronousObserver') + ->willReturnCallback(function () use ($callable) { + $observer = $this->createMock(MetricObserverInterface::class); + $observer->expects($this->once())->method('observe')->with($callable); + + return $observer; + }); - $valueRecorderName = 'ValueRecorder'; - $valueRecorderDescription = 'A value recorder'; - $valueRecorder = $meter->newValueRecorder($valueRecorderName, $valueRecorderDescription); - $this->assertInstanceOf(ValueRecorder::class, $valueRecorder); - $this->assertEquals($valueRecorderName, $valueRecorder->getName()); - $this->assertEquals($valueRecorderDescription, $valueRecorder->getDescription()); + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory); + $meter = $meterProvider->getMeter('test'); + $meter->createObservableGauge('name', 'unit', 'description', $callable); } + + public function test_create_observable_up_down_counter_register_permanent_callback(): void + { + $callable = fn (ObserverInterface $observer) => $observer->observe(0); + $metricFactory = $this->createMock(MetricFactoryInterface::class); + $metricFactory->method('createAsynchronousObserver') + ->willReturnCallback(function () use ($callable) { + $observer = $this->createMock(MetricObserverInterface::class); + $observer->expects($this->once())->method('observe')->with($callable); + + return $observer; + }); + + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory); + $meter = $meterProvider->getMeter('test'); + $meter->createObservableUpDownCounter('name', 'unit', 'description', $callable); + } + + /** @noinspection PhpUnusedLocalVariableInspection */ + public function test_reuses_writer_when_not_stale(): void + { + $metricFactory = $this->prophesize(MetricFactoryInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $metricFactory + ->createSynchronousWriter() + ->shouldBeCalledTimes(1) + ->withArguments([ + ResourceInfoFactory::emptyResource(), + new InstrumentationScope('test', null, null, Attributes::create([])), + new Instrument(InstrumentType::COUNTER, 'name', 'unit', 'description'), + Argument::cetera(), + ]); + + /** @noinspection PhpParamsInspection */ + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory->reveal()); + $meter = $meterProvider->getMeter('test'); + $counter = $meter->createCounter('name', 'unit', 'description'); + $counter = $meter->createCounter('name', 'unit', 'description'); + } + + public function test_releases_writer_on_stale(): void + { + $metricFactory = $this->prophesize(MetricFactoryInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $metricFactory + ->createSynchronousWriter() + ->shouldBeCalledTimes(2) + ->withArguments([ + ResourceInfoFactory::emptyResource(), + new InstrumentationScope('test', null, null, Attributes::create([])), + new Instrument(InstrumentType::COUNTER, 'name', 'unit', 'description'), + Argument::cetera(), + ]); + + /** @noinspection PhpParamsInspection */ + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory->reveal()); + $meter = $meterProvider->getMeter('test'); + $meter->createCounter('name', 'unit', 'description'); + $meter->createCounter('name', 'unit', 'description'); + } + + /** @noinspection PhpUnusedLocalVariableInspection */ + public function test_reuses_observer_when_not_stale(): void + { + $metricFactory = $this->prophesize(MetricFactoryInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $metricFactory + ->createAsynchronousObserver() + ->shouldBeCalledTimes(1) + ->withArguments([ + ResourceInfoFactory::emptyResource(), + new InstrumentationScope('test', null, null, Attributes::create([])), + new Instrument(InstrumentType::ASYNCHRONOUS_COUNTER, 'name', 'unit', 'description'), + Argument::cetera(), + ]); + + /** @noinspection PhpParamsInspection */ + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory->reveal()); + $meter = $meterProvider->getMeter('test'); + $observer = $meter->createObservableCounter('name', 'unit', 'description'); + $observer = $meter->createObservableCounter('name', 'unit', 'description'); + } + + public function test_releases_observer_on_stale(): void + { + $metricFactory = $this->prophesize(MetricFactoryInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $metricFactory + ->createAsynchronousObserver() + ->shouldBeCalledTimes(2) + ->withArguments([ + ResourceInfoFactory::emptyResource(), + new InstrumentationScope('test', null, null, Attributes::create([])), + new Instrument(InstrumentType::ASYNCHRONOUS_COUNTER, 'name', 'unit', 'description'), + Argument::cetera(), + ]); + + /** @noinspection PhpParamsInspection */ + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory->reveal()); + $meter = $meterProvider->getMeter('test'); + $meter->createObservableCounter('name', 'unit', 'description'); + $meter->createObservableCounter('name', 'unit', 'description'); + } + + public function test_uses_view_registry_to_create_views(): void + { + $aggregation = $this->createMock(AggregationInterface::class); + + $metricReader = $this->createMock(MeterMetricReaderInterface::class); + + $viewRegistry = $this->createMock(ViewRegistryInterface::class); + $viewRegistry->method('find')->willReturn([ + new ViewProjection('view-1', null, null, null, $aggregation), + new ViewProjection('view-2', null, null, null, $aggregation), + ]); + + $metricFactory = $this->createMock(MetricFactoryInterface::class); + $metricFactory->expects($this->exactly(1))->method('createSynchronousWriter') + ->willReturnCallback(function () use ($aggregation): MetricWriterInterface { + [[$v1], [$v2]] = [...func_get_arg(4)]; + + $this->assertEquals(new ViewProjection('view-1', null, null, null, $aggregation), $v1); + $this->assertEquals(new ViewProjection('view-2', null, null, null, $aggregation), $v2); + + return $this->createMock(MetricWriterInterface::class); + }); + + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory, $viewRegistry, [$metricReader]); + $meter = $meterProvider->getMeter('test'); + $meter->createCounter('name'); + } + + public function test_uses_default_aggregation_if_view_aggregation_null(): void + { + $aggregation = $this->createMock(AggregationInterface::class); + + $metricReader = $this->createMock(MeterMetricReaderInterface::class); + $metricReader->method('defaultAggregation')->willReturn($aggregation); + + $viewRegistry = $this->createMock(ViewRegistryInterface::class); + $viewRegistry->method('find')->willReturn([ + new ViewProjection('view-1', null, null, null, null), + ]); + + $metricFactory = $this->createMock(MetricFactoryInterface::class); + $metricFactory->expects($this->exactly(1))->method('createSynchronousWriter') + ->willReturnCallback(function () use ($aggregation): MetricWriterInterface { + [[$v1]] = [...func_get_arg(4)]; + + $this->assertEquals(new ViewProjection('view-1', null, null, null, $aggregation), $v1); + + return $this->createMock(MetricWriterInterface::class); + }); + + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory, $viewRegistry, [$metricReader]); + $meter = $meterProvider->getMeter('test'); + $meter->createCounter('name'); + } + + public function test_uses_default_view_if_null_views_returned(): void + { + $aggregation = $this->createMock(AggregationInterface::class); + + $metricReader = $this->createMock(MeterMetricReaderInterface::class); + $metricReader->method('defaultAggregation')->willReturn($aggregation); + + $viewRegistry = $this->createMock(ViewRegistryInterface::class); + $viewRegistry->method('find')->willReturn(null); + + $metricFactory = $this->createMock(MetricFactoryInterface::class); + $metricFactory->expects($this->exactly(1))->method('createSynchronousWriter') + ->willReturnCallback(function () use ($aggregation): MetricWriterInterface { + [[$v1]] = [...func_get_arg(4)]; + + $this->assertEquals(new ViewProjection('name', null, null, null, $aggregation), $v1); + + return $this->createMock(MetricWriterInterface::class); + }); + + $meterProvider = $this->createMeterProviderForMetricFactory($metricFactory, $viewRegistry, [$metricReader]); + $meter = $meterProvider->getMeter('test'); + $meter->createCounter('name'); + } + + /** + * @param iterable $metricReaders + */ + private function createMeterProviderForMetricFactory(MetricFactoryInterface $metricFactory, ViewRegistryInterface $viewRegistry = null, iterable $metricReaders = []): MeterProvider + { + return new MeterProvider( + null, + ResourceInfoFactory::emptyResource(), + ClockFactory::getDefault(), + Attributes::factory(), + new InstrumentationScopeFactory(Attributes::factory()), + $metricReaders, + $viewRegistry ?? new CriteriaViewRegistry(), + null, + new ImmediateStalenessHandlerFactory(), + $metricFactory, + ); + } +} + +interface MeterMetricReaderInterface extends MetricReaderInterface, MetricSourceRegistryInterface, DefaultAggregationProviderInterface +{ } diff --git a/tests/Unit/SDK/Metrics/MetricExporter/InMemoryExporterTest.php b/tests/Unit/SDK/Metrics/MetricExporter/InMemoryExporterTest.php new file mode 100644 index 000000000..54f495434 --- /dev/null +++ b/tests/Unit/SDK/Metrics/MetricExporter/InMemoryExporterTest.php @@ -0,0 +1,85 @@ +assertSame([], $exporter->collect()); + } + + public function test_exporter_collect_returns_exported_metrics(): void + { + $exporter = new InMemoryExporter(); + $metrics = $this->generateMetrics(2); + + $exporter->export($metrics); + $this->assertSame($metrics, $exporter->collect()); + } + + public function test_exporter_collect_retains_exported_metrics(): void + { + $exporter = new InMemoryExporter(); + $metrics = $this->generateMetrics(2); + + $exporter->export($metrics); + $this->assertSame($metrics, $exporter->collect()); + $this->assertSame($metrics, $exporter->collect()); + } + + public function test_exporter_collect_reset_resets_exported_metrics(): void + { + $exporter = new InMemoryExporter(); + $metrics = $this->generateMetrics(2); + + $exporter->export($metrics); + $this->assertSame($metrics, $exporter->collect(true)); + $this->assertSame([], $exporter->collect()); + } + + public function test_exporter_collect_returns_all_exported_metrics(): void + { + $exporter = new InMemoryExporter(); + $metrics = $this->generateMetrics(4); + + $exporter->export([$metrics[0], $metrics[1]]); + $exporter->export([$metrics[2], $metrics[3]]); + $this->assertSame($metrics, $exporter->collect()); + } + + /** + * @return list + */ + private function generateMetrics(int $count): array + { + $metrics = []; + for ($i = 0; $i < $count; $i++) { + $metrics[] = new Metric( + $this->createMock(InstrumentationScopeInterface::class), + $this->createMock(ResourceInfo::class), + sprintf('test-%d', $i), + null, + null, + $this->createMock(DataInterface::class), + ); + } + + return $metrics; + } +} diff --git a/tests/Unit/SDK/Metrics/MetricFactory/StreamFactoryTest.php b/tests/Unit/SDK/Metrics/MetricFactory/StreamFactoryTest.php new file mode 100644 index 000000000..af13dc48c --- /dev/null +++ b/tests/Unit/SDK/Metrics/MetricFactory/StreamFactoryTest.php @@ -0,0 +1,162 @@ +createAsynchronousObserver( + $resource, + $instrumentationScope, + new Instrument(InstrumentType::ASYNCHRONOUS_UP_DOWN_COUNTER, 'name', '{unit}', 'description'), + 1, + [ + [new ViewProjection( + 'view-name', + 'view-unit', + 'view-description', + null, + new SumAggregation(), + ), new RegistryRegistration($registry, new NoopStalenessHandler())], + ], + Attributes::factory(), + new NoneExemplarFilter(), + ); + + $this->assertCount(1, $registry->sources); + [$provider, $metadata] = $registry->sources[0]; + + $this->assertSame(InstrumentType::ASYNCHRONOUS_UP_DOWN_COUNTER, $metadata->instrumentType()); + $this->assertSame('view-name', $metadata->name()); + $this->assertSame('view-unit', $metadata->unit()); + $this->assertSame('view-description', $metadata->description()); + $this->assertSame(Temporality::CUMULATIVE, $metadata->temporality()); + + $source = $provider->create(Temporality::CUMULATIVE); + $observer->observe(static fn (ObserverInterface $observer) => $observer->observe(5)); + + $this->assertEquals( + new Metric( + $instrumentationScope, + $resource, + 'view-name', + 'view-unit', + 'view-description', + new Sum( + [ + new NumberDataPoint(5, Attributes::create([]), 1, 3), + ], + Temporality::CUMULATIVE, + false + ), + ), + $source->collect(3), + ); + $this->assertSame(3, $source->collectionTimestamp()); + } + + public function test_create_synchronous_observer(): void + { + $resource = ResourceInfoFactory::emptyResource(); + $instrumentationScope = new InstrumentationScope('test', null, null, Attributes::create([])); + + $registry = new CollectingSourceRegistry(); + $writer = (new StreamFactory())->createSynchronousWriter( + $resource, + $instrumentationScope, + new Instrument(InstrumentType::UP_DOWN_COUNTER, 'name', '{unit}', 'description'), + 1, + [ + [new ViewProjection( + 'view-name', + 'view-unit', + 'view-description', + null, + new SumAggregation(), + ), new RegistryRegistration($registry, new NoopStalenessHandler())], + ], + Attributes::factory(), + new NoneExemplarFilter(), + ); + + $this->assertCount(1, $registry->sources); + [$provider, $metadata] = $registry->sources[0]; + + $this->assertSame(InstrumentType::UP_DOWN_COUNTER, $metadata->instrumentType()); + $this->assertSame('view-name', $metadata->name()); + $this->assertSame('view-unit', $metadata->unit()); + $this->assertSame('view-description', $metadata->description()); + $this->assertSame(Temporality::DELTA, $metadata->temporality()); + + $source = $provider->create(Temporality::DELTA); + $writer->record(5, [], null, 2); + + $this->assertEquals( + new Metric( + $instrumentationScope, + $resource, + 'view-name', + 'view-unit', + 'view-description', + new Sum( + [ + new NumberDataPoint(5, Attributes::create([]), 1, 3), + ], + Temporality::DELTA, + false + ), + ), + $source->collect(3), + ); + $this->assertSame(3, $source->collectionTimestamp()); + } +} + +final class CollectingSourceRegistry implements MetricSourceRegistryInterface +{ + + /** + * @var list + */ + public array $sources = []; + + public function add(MetricSourceProviderInterface $provider, MetricMetadataInterface $metadata, StalenessHandlerInterface $stalenessHandler): void + { + $this->sources[] = func_get_args(); + } +} diff --git a/tests/Unit/SDK/Metrics/MetricObserver/CallbackDestructorTest.php b/tests/Unit/SDK/Metrics/MetricObserver/CallbackDestructorTest.php new file mode 100644 index 000000000..bd3c3805e --- /dev/null +++ b/tests/Unit/SDK/Metrics/MetricObserver/CallbackDestructorTest.php @@ -0,0 +1,47 @@ +createMock(MetricObserverInterface::class); + $observer->expects($this->exactly(2))->method('has')->withConsecutive([1], [2])->willReturn(true); + $observer->expects($this->exactly(2))->method('cancel')->withConsecutive([1], [2]); + + $referenceCounter = $this->createMock(ReferenceCounterInterface::class); + $referenceCounter->expects($this->exactly(2))->method('release'); + + /** @noinspection PhpObjectFieldsAreOnlyWrittenInspection */ + $destructor = new CallbackDestructor($observer, $referenceCounter); + $destructor->tokens[] = 1; + $destructor->tokens[] = 2; + $destructor = null; + } + + public function test_callback_destructor_skips_not_active_token(): void + { + $observer = $this->createMock(MetricObserverInterface::class); + $observer->expects($this->exactly(2))->method('has')->willReturn(false); + + $referenceCounter = $this->createMock(ReferenceCounterInterface::class); + $referenceCounter->expects($this->never())->method('release'); + + /** @noinspection PhpObjectFieldsAreOnlyWrittenInspection */ + $destructor = new CallbackDestructor($observer, $referenceCounter); + $destructor->tokens[] = 1; + $destructor->tokens[] = 2; + $destructor = null; + } +} diff --git a/tests/Unit/SDK/Metrics/MetricObserver/MultiObserverTest.php b/tests/Unit/SDK/Metrics/MetricObserver/MultiObserverTest.php new file mode 100644 index 000000000..eac4be05e --- /dev/null +++ b/tests/Unit/SDK/Metrics/MetricObserver/MultiObserverTest.php @@ -0,0 +1,133 @@ +observe(static fn (ObserverInterface $observer) => $observer->observe(1)); + $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(2)); + $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(3)); + + $observer = new ValueCollectingObserver(); + $multiObserver($observer); + $this->assertSame([1, 2, 3], $observer->values); + } + + public function test_unregistered_callbacks_are_not_invoked(): void + { + $multiObserver = new MultiObserver(); + + $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(1)); + $token = $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(2)); + $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(3)); + + $multiObserver->cancel($token); + + $observer = new ValueCollectingObserver(); + $multiObserver($observer); + $this->assertSame([1, 3], $observer->values); + } + + public function test_unregister_with_invalid_token_is_noop(): void + { + $multiObserver = new MultiObserver(); + + $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(1)); + $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(2)); + $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(3)); + + $multiObserver->cancel(-1); + + $observer = new ValueCollectingObserver(); + $multiObserver($observer); + $this->assertSame([1, 2, 3], $observer->values); + } + + public function test_unregister_during_collection_is_allowed(): void + { + // E.g. when using weak closure and closure $this is garbage collected during metric collection + $multiObserver = new MultiObserver(); + + $multiObserver->observe(static function (ObserverInterface $observer) use ($multiObserver, &$token): void { + $observer->observe(1); + /** @phpstan-ignore-next-line */ + $multiObserver->cancel($token); + }); + $token = $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(2)); + $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(3)); + + $observer = new ValueCollectingObserver(); + $multiObserver($observer); + $this->assertSame([1, 3], $observer->values); + } + + public function test_unregister_register_during_collection_does_not_trigger_old_callback(): void + { + $multiObserver = new MultiObserver(); + + $multiObserver->observe(static function (ObserverInterface $observer) use ($multiObserver, &$token): void { + $observer->observe(1); + $multiObserver->cancel($token); + $token = $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(2)); + }); + $token = $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(2)); + $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(3)); + + $observer = new ValueCollectingObserver(); + $multiObserver($observer); + $this->assertSame([1, 3], $observer->values); + } + + public function test_has_returns_true_for_registered_callback(): void + { + $multiObserver = new MultiObserver(); + + $token = $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(1)); + $this->assertTrue($multiObserver->has($token)); + } + + public function test_has_returns_false_for_canceled_callback(): void + { + $multiObserver = new MultiObserver(); + + $token = $multiObserver->observe(static fn (ObserverInterface $observer) => $observer->observe(1)); + $multiObserver->cancel($token); + $this->assertFalse($multiObserver->has($token)); + } + + public function test_multiple_calls_to_destructors_return_same_instance(): void + { + $multiObserver = new MultiObserver(); + + $a = $multiObserver->destructors(); + $b = $multiObserver->destructors(); + $this->assertSame($a, $b); + } +} + +final class ValueCollectingObserver implements ObserverInterface +{ + + /** + * @var array + */ + public array $values = []; + + public function observe($amount, iterable $attributes = []): void + { + $this->values[] = $amount; + } +} diff --git a/tests/Unit/SDK/Metrics/MetricReader/ExportingReaderTest.php b/tests/Unit/SDK/Metrics/MetricReader/ExportingReaderTest.php new file mode 100644 index 000000000..d6ca5642c --- /dev/null +++ b/tests/Unit/SDK/Metrics/MetricReader/ExportingReaderTest.php @@ -0,0 +1,199 @@ +collect(); + $this->assertSame([], $exporter->collect()); + } + + public function test_default_aggregation_returns_default_aggregation(): void + { + $exporter = new InMemoryExporter(); + $clock = new TestClock(); + $reader = new ExportingReader($exporter, $clock); + + $this->assertEquals(new SumAggregation(true), $reader->defaultAggregation(InstrumentType::COUNTER)); + $this->assertEquals(new SumAggregation(true), $reader->defaultAggregation(InstrumentType::ASYNCHRONOUS_COUNTER)); + $this->assertEquals(new SumAggregation(), $reader->defaultAggregation(InstrumentType::UP_DOWN_COUNTER)); + $this->assertEquals(new SumAggregation(), $reader->defaultAggregation(InstrumentType::ASYNCHRONOUS_UP_DOWN_COUNTER)); + $this->assertEquals(new ExplicitBucketHistogramAggregation([0, 5, 10, 25, 50, 75, 100, 250, 500, 1000]), $reader->defaultAggregation(InstrumentType::HISTOGRAM)); + $this->assertEquals(new LastValueAggregation(), $reader->defaultAggregation(InstrumentType::ASYNCHRONOUS_GAUGE)); + } + + public function test_default_aggregation_returns_exporter_aggregation_if_default_aggregation_provider(): void + { + $exporter = $this->createMock(DefaultAggregationProviderExporterInterface::class); + $exporter->method('defaultAggregation')->willReturn(new LastValueAggregation()); + $clock = new TestClock(); + $reader = new ExportingReader($exporter, $clock); + + $this->assertEquals(new LastValueAggregation(), $reader->defaultAggregation(InstrumentType::COUNTER)); + } + + public function test_add_creates_metric_source_with_exporter_temporality(): void + { + $exporter = new InMemoryExporter(Temporality::CUMULATIVE); + $clock = new TestClock(); + $reader = new ExportingReader($exporter, $clock); + + $provider = $this->createMock(MetricSourceProviderInterface::class); + $provider->expects($this->once())->method('create')->with(Temporality::CUMULATIVE); + $metricMetadata = $this->createMock(MetricMetadataInterface::class); + $stalenessHandler = $this->createMock(StalenessHandlerInterface::class); + $stalenessHandler->expects($this->once())->method('onStale'); + + $reader->add($provider, $metricMetadata, $stalenessHandler); + } + + public function test_add_does_not_create_metric_source_if_exporter_temporality_null(): void + { + $exporter = $this->createMock(MetricExporterInterface::class); + $exporter->method('temporality')->willReturn(null); + $clock = new TestClock(); + $reader = new ExportingReader($exporter, $clock); + + $provider = $this->createMock(MetricSourceProviderInterface::class); + $provider->expects($this->never())->method('create'); + $metricMetadata = $this->createMock(MetricMetadataInterface::class); + $stalenessHandler = $this->createMock(StalenessHandlerInterface::class); + $stalenessHandler->expects($this->never())->method('onStale'); + + $reader->add($provider, $metricMetadata, $stalenessHandler); + } + + public function test_add_does_not_create_metric_source_if_reader_closed(): void + { + $exporter = new InMemoryExporter(Temporality::CUMULATIVE); + $clock = new TestClock(); + $reader = new ExportingReader($exporter, $clock); + + $provider = $this->createMock(MetricSourceProviderInterface::class); + $provider->expects($this->never())->method('create'); + $metricMetadata = $this->createMock(MetricMetadataInterface::class); + $stalenessHandler = $this->createMock(StalenessHandlerInterface::class); + $stalenessHandler->expects($this->never())->method('onStale'); + + $reader->shutdown(); + $reader->add($provider, $metricMetadata, $stalenessHandler); + } + + public function test_staleness_handler_clears_source(): void + { + $exporter = new InMemoryExporter(Temporality::CUMULATIVE); + $clock = new TestClock(); + $reader = new ExportingReader($exporter, $clock); + + $provider = $this->createMock(MetricSourceProviderInterface::class); + $metricMetadata = $this->createMock(MetricMetadataInterface::class); + $stalenessHandler = new ImmediateStalenessHandler(); + $stalenessHandler->acquire(); + $reader->add($provider, $metricMetadata, $stalenessHandler); + + $stalenessHandler->release(); + $reader->collect(); + $this->assertSame([], $exporter->collect()); + } + + public function test_collect_collects_sources_with_current_timestamp(): void + { + $exporter = new InMemoryExporter(Temporality::CUMULATIVE); + $clock = new TestClock(); + $reader = new ExportingReader($exporter, $clock); + + $metric = new Metric( + $this->createMock(InstrumentationScopeInterface::class), + $this->createMock(ResourceInfo::class), + 'test', + null, + null, + $this->createMock(DataInterface::class), + ); + + $source = $this->createMock(MetricSourceInterface::class); + $source->expects($this->once())->method('collect')->with(TestClock::DEFAULT_START_EPOCH)->willReturn($metric); + $provider = $this->createMock(MetricSourceProviderInterface::class); + $provider->expects($this->once())->method('create')->willReturn($source); + $metricMetadata = $this->createMock(MetricMetadataInterface::class); + $stalenessHandler = $this->createMock(StalenessHandlerInterface::class); + + $reader->add($provider, $metricMetadata, $stalenessHandler); + $reader->collect(); + } + + public function test_shutdown_calls_exporter_shutdown(): void + { + $exporter = $this->createMock(MetricExporterInterface::class); + $exporter->expects($this->once())->method('export')->willReturn(true); + $exporter->expects($this->once())->method('shutdown')->willReturn(true); + $clock = new TestClock(); + $reader = new ExportingReader($exporter, $clock); + + $this->assertTrue($reader->shutdown()); + } + + public function test_force_flush_calls_exporter_force_flush(): void + { + $exporter = $this->createMock(MetricExporterInterface::class); + $exporter->expects($this->once())->method('export')->willReturn(true); + $exporter->expects($this->once())->method('forceFlush')->willReturn(true); + $clock = new TestClock(); + $reader = new ExportingReader($exporter, $clock); + + $this->assertTrue($reader->forceFlush()); + } + + public function test_closed_reader_does_not_call_exporter_methods(): void + { + $exporter = $this->createMock(MetricExporterInterface::class); + $clock = new TestClock(); + $reader = new ExportingReader($exporter, $clock); + + $reader->shutdown(); + + $exporter->expects($this->never())->method('export'); + $exporter->expects($this->never())->method('shutdown'); + $exporter->expects($this->never())->method('forceFlush'); + + $reader->collect(); + $reader->shutdown(); + $reader->forceFlush(); + } +} + +interface DefaultAggregationProviderExporterInterface extends MetricExporterInterface, DefaultAggregationProviderInterface +{ +} diff --git a/tests/Unit/SDK/Metrics/Providers/GlobalMeterProviderTest.php b/tests/Unit/SDK/Metrics/Providers/GlobalMeterProviderTest.php deleted file mode 100644 index ecafbcf75..000000000 --- a/tests/Unit/SDK/Metrics/Providers/GlobalMeterProviderTest.php +++ /dev/null @@ -1,33 +0,0 @@ -assertInstanceOf(MeterProvider::class, $defaultProvider); - - $meter = GlobalMeterProvider::getMeter('test'); - - $this->assertInstanceOf(Meter::class, $meter); - - $customGlobalProvider = new MeterProvider(); - - GlobalMeterProvider::setGlobalProvider($customGlobalProvider); - - $this->assertSame($customGlobalProvider, GlobalMeterProvider::getGlobalProvider()); - } -} diff --git a/tests/Unit/SDK/Metrics/StalenessHandler/DelayedStalenessHandlerTest.php b/tests/Unit/SDK/Metrics/StalenessHandler/DelayedStalenessHandlerTest.php new file mode 100644 index 000000000..f8f3fc28f --- /dev/null +++ b/tests/Unit/SDK/Metrics/StalenessHandler/DelayedStalenessHandlerTest.php @@ -0,0 +1,88 @@ +create(); + $handler->onStale(static function () use (&$called): void { + $called = true; + }); + + $handler->acquire(); + $handler->acquire(); + $handler->release(); + $handler->release(); + + $clock->advance(100_000_000); + $factory->create(); + $this->assertFalse($called); + + $clock->advance(100_000_000); + $factory->create(); + /** @phpstan-ignore-next-line */ + $this->assertTrue($called); + } + + public function test_on_stale_acquire_does_not_trigger_callbacks(): void + { + $called = false; + $clock = new TestClock(); + $factory = new DelayedStalenessHandlerFactory($clock, .15); + $handler = $factory->create(); + $handler->onStale(static function () use (&$called): void { + $called = true; + }); + + $handler->acquire(); + $handler->release(); + + $clock->advance(100_000_000); + $factory->create(); + $this->assertFalse($called); + + $clock->advance(100_000_000); + $handler->acquire(); + $factory->create(); + $this->assertFalse($called); + } + + public function test_releases_callbacks_on_persistent_acquire(): void + { + $handler = (new DelayedStalenessHandlerFactory(new TestClock(), 0))->create(); + + $object = new stdClass(); + $reference = WeakReference::create($object); + /** @phpstan-ignore-next-line */ + $handler->onStale(static function () use ($object): void { + }); + $handler->acquire(true); + $object = null; + $this->assertNull($reference->get()); + + $object = new stdClass(); + $reference = WeakReference::create($object); + /** @phpstan-ignore-next-line */ + $handler->onStale(static function () use ($object): void { + }); + $object = null; + $this->assertNull($reference->get()); + } +} diff --git a/tests/Unit/SDK/Metrics/StalenessHandler/ImmediateStalenessHandlerTest.php b/tests/Unit/SDK/Metrics/StalenessHandler/ImmediateStalenessHandlerTest.php new file mode 100644 index 000000000..8cac806ce --- /dev/null +++ b/tests/Unit/SDK/Metrics/StalenessHandler/ImmediateStalenessHandlerTest.php @@ -0,0 +1,59 @@ +create(); + $handler->onStale(static function () use (&$called): void { + $called = true; + }); + + $handler->acquire(); + $handler->acquire(); + $this->assertFalse($called); + + $handler->release(); + $this->assertFalse($called); + + $handler->release(); + /** @phpstan-ignore-next-line */ + $this->assertTrue($called); + } + + public function test_releases_callbacks_on_persistent_acquire(): void + { + $handler = (new ImmediateStalenessHandlerFactory())->create(); + + $object = new stdClass(); + $reference = WeakReference::create($object); + /** @phpstan-ignore-next-line */ + $handler->onStale(static function () use ($object): void { + }); + $handler->acquire(true); + $object = null; + $this->assertNull($reference->get()); + + $object = new stdClass(); + $reference = WeakReference::create($object); + /** @phpstan-ignore-next-line */ + $handler->onStale(static function () use ($object): void { + }); + $object = null; + $this->assertNull($reference->get()); + } +} diff --git a/tests/Unit/SDK/Metrics/StalenessHandler/NoopStalenessHandlerTest.php b/tests/Unit/SDK/Metrics/StalenessHandler/NoopStalenessHandlerTest.php new file mode 100644 index 000000000..7dbeed001 --- /dev/null +++ b/tests/Unit/SDK/Metrics/StalenessHandler/NoopStalenessHandlerTest.php @@ -0,0 +1,32 @@ +create(); + $handler->onStale(static function () use (&$called): void { + $called = true; + }); + + $handler->acquire(); + $handler->acquire(); + $this->assertFalse($called); + + $handler->release(); + $handler->release(); + $this->assertFalse($called); + } +} diff --git a/tests/Unit/SDK/Metrics/Stream/DeltaStorageTest.php b/tests/Unit/SDK/Metrics/Stream/DeltaStorageTest.php new file mode 100644 index 000000000..7095bdd98 --- /dev/null +++ b/tests/Unit/SDK/Metrics/Stream/DeltaStorageTest.php @@ -0,0 +1,180 @@ +collect(0b1); + $this->assertNull($metric); + } + + public function test_storage_returns_inserted_metric(): void + { + $ds = new DeltaStorage(new SumAggregation()); + + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(3)], 0, 0), 0b1); + + $metric = $ds->collect(0); + $this->assertEquals(new Metric( + [Attributes::create(['a'])], + [new SumSummary(3)], + 0, + 0, + ), $metric); + } + + public function test_storage_returns_inserted_metric_cumulative(): void + { + $ds = new DeltaStorage(new SumAggregation()); + + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(3)], 0, 0), 0b1); + $ds->collect(0, true); + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(5)], 1, 1), 0b1); + for ($i = 0; $i < 2; $i++) { + $metric = $ds->collect(0, true); + $this->assertEquals(new Metric( + [Attributes::create(['a'])], + [new SumSummary(8)], + 0, + 0, + ), $metric); + } + } + + public function test_storage_does_not_return_zero_reader_metric(): void + { + $ds = new DeltaStorage(new SumAggregation()); + + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(3)], 0, 0), 0b0); + + $this->assertNull($ds->collect(0)); + } + + public function test_storage_keeps_metrics_for_additional_readers(): void + { + $ds = new DeltaStorage(new SumAggregation()); + + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(3)], 0, 0), 0b111); + $metric = $ds->collect(0); + $this->assertEquals(new Metric( + [Attributes::create(['a'])], + [new SumSummary(3)], + 0, + 0, + ), $metric); + + $ds->add(new Metric([Attributes::create(['a']), Attributes::create(['b'])], [new SumSummary(7), new SumSummary(12)], 1, 1), 0b111); + $metric = $ds->collect(1); + $this->assertEquals(new Metric( + [Attributes::create(['a']), Attributes::create(['b'])], + [new SumSummary(10), new SumSummary(12)], + 0, + 0, + ), $metric); + + $ds->add(new Metric([Attributes::create(['a']), Attributes::create(['b'])], [new SumSummary(5), new SumSummary(9)], 2, 2), 0b111); + $metric = $ds->collect(1); + $this->assertEquals(new Metric( + [Attributes::create(['a']), Attributes::create(['b'])], + [new SumSummary(5), new SumSummary(9)], + 2, + 2, + ), $metric); + + $metric = $ds->collect(0); + $this->assertEquals(new Metric( + [Attributes::create(['a']), Attributes::create(['b'])], + [new SumSummary(12), new SumSummary(21)], + 1, + 1, + ), $metric); + + $metric = $ds->collect(2); + $this->assertEquals(new Metric( + [Attributes::create(['a']), Attributes::create(['b'])], + [new SumSummary(15), new SumSummary(21)], + 0, + 0, + ), $metric); + } + + public function test_storage_keeps_constant_memory_on_one_active_reader(): void + { + $ds = new DeltaStorage(new SumAggregation()); + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(0)], 0, 0), 0b11); + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(0)], 0, 1), 0b11); + + $memory = memory_get_usage(); + for ($i = 0; $i < 10000; $i++) { + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(1)], 0, 2 + $i), 0b11); + $ds->collect(0); + } + $this->assertLessThan(2000, memory_get_usage() - $memory); + + $metric = $ds->collect(1); + $this->assertSame(10000, $metric->summaries[0]->value ?? null); + } + + public function test_storage_keeps_constant_memory_on_one_active_retaining_reader(): void + { + $ds = new DeltaStorage(new SumAggregation()); + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(0)], 0, 0), 0b01); + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(0)], 0, 1), 0b11); + + $memory = memory_get_usage(); + for ($i = 0; $i < 10000; $i++) { + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(1)], 0, 2 + $i), 0b11); + $ds->collect(0, true); + } + $this->assertLessThan(2000, memory_get_usage() - $memory); + + $metric = $ds->collect(1); + $this->assertSame(10000, $metric->summaries[0]->value ?? null); + $metric = $ds->collect(0); + $this->assertSame(10000, $metric->summaries[0]->value ?? null); + } + + public function test_storage_keeps_constant_memory_on_alternating_active_retaining_readers(): void + { + $ds = new DeltaStorage(new SumAggregation()); + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(0)], 0, 0), 0b100); + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(0)], 0, 1), 0b110); + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(0)], 0, 2), 0b111); + + $memory = memory_get_usage(); + for ($i = 0; $i < 10000; $i++) { + $ds->add(new Metric([Attributes::create(['a'])], [new SumSummary(1)], 0, 3 + $i), 0b111); + $ds->collect($i % 3, true); + } + $this->assertLessThan(2000, memory_get_usage() - $memory); + + $metric = $ds->collect(2); + $this->assertSame(10000, $metric->summaries[0]->value ?? null); + $metric = $ds->collect(1); + $this->assertSame(10000, $metric->summaries[0]->value ?? null); + $metric = $ds->collect(0); + $this->assertSame(10000, $metric->summaries[0]->value ?? null); + } +} diff --git a/tests/Unit/SDK/Metrics/Stream/MetricStreamTest.php b/tests/Unit/SDK/Metrics/Stream/MetricStreamTest.php new file mode 100644 index 000000000..aba5c9195 --- /dev/null +++ b/tests/Unit/SDK/Metrics/Stream/MetricStreamTest.php @@ -0,0 +1,418 @@ +observe($value); + }, 3); + + $d = $s->register(Temporality::DELTA); + $c = $s->register(Temporality::CUMULATIVE); + + $value = 5; + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(5, Attributes::create([]), 3, 5), + ], Temporality::DELTA, false), $s->collect($d, 5)); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(5, Attributes::create([]), 3, 5), + ], Temporality::CUMULATIVE, false), $s->collect($c, 5)); + + $value = 7; + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(2, Attributes::create([]), 5, 8), + ], Temporality::DELTA, false), $s->collect($d, 8)); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(7, Attributes::create([]), 3, 8), + ], Temporality::CUMULATIVE, false), $s->collect($c, 8)); + + $value = 3; + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(-4, Attributes::create([]), 8, 12), + ], Temporality::DELTA, false), $s->collect($d, 12)); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(3, Attributes::create([]), 3, 12), + ], Temporality::CUMULATIVE, false), $s->collect($c, 12)); + } + + public function test_asynchronous_multiple_data_points(): void + { + /** @var int|null $m */ + $m = null; + + $s = new AsynchronousMetricStream(Attributes::factory(), null, new SumAggregation(), null, function (ObserverInterface $observer) use (&$m): void { + if ($m === 0) { + $observer->observe(5, ['status' => 300]); + $observer->observe(2, ['status' => 400]); + } + if ($m === 1) { + $observer->observe(2, ['status' => 300]); + $observer->observe(7, ['status' => 400]); + } + }, 3); + + $d = $s->register(Temporality::DELTA); + $c = $s->register(Temporality::CUMULATIVE); + + $m = 0; + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(5, Attributes::create(['status' => 300]), 3, 5), + new Data\NumberDataPoint(2, Attributes::create(['status' => 400]), 3, 5), + ], Temporality::DELTA, false), $s->collect($d, 5)); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(5, Attributes::create(['status' => 300]), 3, 5), + new Data\NumberDataPoint(2, Attributes::create(['status' => 400]), 3, 5), + ], Temporality::CUMULATIVE, false), $s->collect($c, 5)); + + $m = 1; + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(-3, Attributes::create(['status' => 300]), 5, 8), + new Data\NumberDataPoint(5, Attributes::create(['status' => 400]), 5, 8), + ], Temporality::DELTA, false), $s->collect($d, 8)); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(2, Attributes::create(['status' => 300]), 3, 8), + new Data\NumberDataPoint(7, Attributes::create(['status' => 400]), 3, 8), + ], Temporality::CUMULATIVE, false), $s->collect($c, 8)); + } + + public function test_asynchronous_omit_data_point(): void + { + /** @var int|null $value */ + $value = null; + + $s = new AsynchronousMetricStream(Attributes::factory(), null, new SumAggregation(), null, function (ObserverInterface $observer) use (&$value): void { + if ($value !== null) { + $observer->observe($value); + } + }, 3); + + $d = $s->register(Temporality::DELTA); + $c = $s->register(Temporality::CUMULATIVE); + + $value = 5; + $s->collect($d, 5); + $s->collect($c, 5); + + $value = null; + $this->assertEquals(new Data\Sum([ + ], Temporality::DELTA, false), $s->collect($d, 7)); + $this->assertEquals(new Data\Sum([ + ], Temporality::CUMULATIVE, false), $s->collect($c, 7)); + + $value = 3; + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(3, Attributes::create([]), 7, 12), + ], Temporality::DELTA, false), $s->collect($d, 12)); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(3, Attributes::create([]), 3, 12), + ], Temporality::CUMULATIVE, false), $s->collect($c, 12)); + } + + public function test_synchronous_single_data_point(): void + { + $s = new SynchronousMetricStream(null, new SumAggregation(), null, 3); + $w = new StreamWriter(null, Attributes::factory(), $s->writable()); + + $d = $s->register(Temporality::DELTA); + $c = $s->register(Temporality::CUMULATIVE); + + $w->record(5, [], null, 4); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(5, Attributes::create([]), 3, 5), + ], Temporality::DELTA, false), $s->collect($d, 5)); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(5, Attributes::create([]), 3, 5), + ], Temporality::CUMULATIVE, false), $s->collect($c, 5)); + + $w->record(2, [], null, 7); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(2, Attributes::create([]), 5, 8), + ], Temporality::DELTA, false), $s->collect($d, 8)); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(7, Attributes::create([]), 3, 8), + ], Temporality::CUMULATIVE, false), $s->collect($c, 8)); + + $w->record(-4, [], null, 9); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(-4, Attributes::create([]), 8, 12), + ], Temporality::DELTA, false), $s->collect($d, 12)); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(3, Attributes::create([]), 3, 12), + ], Temporality::CUMULATIVE, false), $s->collect($c, 12)); + } + + public function test_synchronous_multiple_data_points(): void + { + $s = new SynchronousMetricStream(null, new SumAggregation(), null, 3); + $w = new StreamWriter(null, Attributes::factory(), $s->writable()); + + $d = $s->register(Temporality::DELTA); + $c = $s->register(Temporality::CUMULATIVE); + + $w->record(5, ['status' => 300], null, 4); + $w->record(2, ['status' => 400], null, 4); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(5, Attributes::create(['status' => 300]), 3, 5), + new Data\NumberDataPoint(2, Attributes::create(['status' => 400]), 3, 5), + ], Temporality::DELTA, false), $s->collect($d, 5)); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(5, Attributes::create(['status' => 300]), 3, 5), + new Data\NumberDataPoint(2, Attributes::create(['status' => 400]), 3, 5), + ], Temporality::CUMULATIVE, false), $s->collect($c, 5)); + + $w->record(-3, ['status' => 300], null, 7); + $w->record(5, ['status' => 400], null, 7); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(-3, Attributes::create(['status' => 300]), 5, 8), + new Data\NumberDataPoint(5, Attributes::create(['status' => 400]), 5, 8), + ], Temporality::DELTA, false), $s->collect($d, 8)); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(2, Attributes::create(['status' => 300]), 3, 8), + new Data\NumberDataPoint(7, Attributes::create(['status' => 400]), 3, 8), + ], Temporality::CUMULATIVE, false), $s->collect($c, 8)); + } + + public function test_asynchronous_temporality(): void + { + $s = new AsynchronousMetricStream(Attributes::factory(), null, new SumAggregation(), null, fn (ObserverInterface $observer) => $observer->observe(1), 3); + $this->assertSame(Temporality::CUMULATIVE, $s->temporality()); + } + + public function test_synchronous_temporality(): void + { + $s = new SynchronousMetricStream(null, new SumAggregation(), null, 3); + $this->assertSame(Temporality::DELTA, $s->temporality()); + } + + public function test_asynchronous_collection_timestamp_returns_last_collection_timestamp(): void + { + $s = new AsynchronousMetricStream(Attributes::factory(), null, new SumAggregation(), null, fn (ObserverInterface $observer) => $observer->observe(1), 3); + $this->assertSame(3, $s->collectionTimestamp()); + + $s->collect(0, 5); + $this->assertSame(5, $s->collectionTimestamp()); + } + + public function test_synchronous_collection_timestamp_returns_last_collection_timestamp(): void + { + $s = new SynchronousMetricStream(null, new SumAggregation(), null, 3); + $this->assertSame(3, $s->collectionTimestamp()); + + $s->collect(0, 5); + $this->assertSame(5, $s->collectionTimestamp()); + } + + public function test_asynchronous_unregister_removes_reader(): void + { + /** @var int|null $value */ + $value = null; + + $s = new AsynchronousMetricStream(Attributes::factory(), null, new SumAggregation(), null, function (ObserverInterface $observer) use (&$value): void { + if ($value !== null) { + $observer->observe($value); + } + }, 3); + + $value = 5; + $d = $s->register(Temporality::DELTA); + $s->collect($d, 5); + $s->unregister($d); + + // Implementation treats unknown reader as cumulative reader + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(5, Attributes::create([]), 3, 7), + ], Temporality::CUMULATIVE, false), $s->collect($d, 7)); + } + + public function test_synchronous_unregister_removes_reader(): void + { + $s = new SynchronousMetricStream(null, new SumAggregation(), null, 3); + $w = new StreamWriter(null, Attributes::factory(), $s->writable()); + + $c = $s->register(Temporality::CUMULATIVE); + $w->record(5, [], null, 4); + $s->collect($c, 5); + $s->unregister($c); + + $w->record(-5, [], null, 6); + $this->assertEquals(new Data\Sum([ + ], Temporality::DELTA, false), $s->collect($c, 7)); + } + + public function test_asynchronous_unregister_invalid_does_not_affect_reader(): void + { + /** @var int|null $value */ + $value = null; + + $s = new AsynchronousMetricStream(Attributes::factory(), null, new SumAggregation(), null, function (ObserverInterface $observer) use (&$value): void { + if ($value !== null) { + $observer->observe($value); + } + }, 3); + + $value = 5; + $d = $s->register(Temporality::DELTA); + $s->collect($d, 5); + $s->unregister($d + 1); + + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(0, Attributes::create([]), 5, 7), + ], Temporality::DELTA, false), $s->collect($d, 7)); + } + + public function test_synchronous_unregister_invalid_does_not_affect_reader(): void + { + $s = new SynchronousMetricStream(null, new SumAggregation(), null, 3); + $w = new StreamWriter(null, Attributes::factory(), $s->writable()); + + $c = $s->register(Temporality::CUMULATIVE); + $w->record(5, [], null, 4); + $s->collect($c, 5); + $s->unregister($c + 1); + + $w->record(-5, [], null, 6); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(0, Attributes::create([]), 3, 7), + ], Temporality::CUMULATIVE, false), $s->collect($c, 7)); + } + + public function test_synchronous_reader_limit_exceeded_triggers_warning(): void + { + if (extension_loaded('gmp')) { + $this->markTestSkipped(); + } + + $s = new SynchronousMetricStream(null, new SumAggregation(), null, 3); + + for ($i = 0; $i < (PHP_INT_SIZE << 3) - 1; $i++) { + $s->register(Temporality::DELTA); + } + + $this->expectWarning(); + $this->expectWarningMessageMatches('/^GMP extension required to register over \d++ readers$/'); + $s->register(Temporality::DELTA); + } + + public function test_synchronous_reader_limit_exceeded_returns_noop_reader(): void + { + if (extension_loaded('gmp')) { + $this->markTestSkipped(); + } + + $s = new SynchronousMetricStream(null, new SumAggregation(), null, 3); + $w = new StreamWriter(null, Attributes::factory(), $s->writable()); + + for ($i = 0; $i < (PHP_INT_SIZE << 3) - 1; $i++) { + $s->register(Temporality::DELTA); + } + + $d = @$s->register(Temporality::DELTA); + + $w->record(5, [], null, 4); + $this->assertEquals(new Data\Sum([ + ], Temporality::DELTA, false), $s->collect($d, 5)); + } + + public function test_synchronous_reader_limit_does_not_apply_if_gmp_available(): void + { + if (!extension_loaded('gmp')) { + $this->markTestSkipped(); + } + + $s = new SynchronousMetricStream(null, new SumAggregation(), null, 3); + $w = new StreamWriter(null, Attributes::factory(), $s->writable()); + + for ($i = 0; $i < (PHP_INT_SIZE << 3) - 1; $i++) { + $s->register(Temporality::DELTA); + } + + $d = $s->register(Temporality::DELTA); + + $w->record(5, [], null, 4); + $this->assertEquals(new Data\Sum([ + new Data\NumberDataPoint(5, Attributes::create([]), 3, 5), + ], Temporality::DELTA, false), $s->collect($d, 5)); + } + + public function test_metric_aggregator_applies_attribute_filter(): void + { + $aggregator = new MetricAggregator(new FilteredAttributeProcessor(Attributes::factory(), ['foo', 'bar']), new SumAggregation(), null); + $aggregator->record(5, Attributes::create(['foo' => 1, 'bar' => 2, 'baz' => 3]), Context::getRoot(), 0); + + $this->assertEquals( + Attributes::create(['foo' => 1, 'bar' => 2]), + current($aggregator->collect(1)->attributes), + ); + } + + public function test_metric_aggregator_forwards_to_exemplar_filter(): void + { + $exemplarReservoir = $this->createMock(ExemplarReservoirInterface::class); + $exemplarReservoir->expects($this->once())->method('offer')->with($this->anything(), 5, Attributes::create(['foo' => 1]), Context::getRoot(), 3, 0); + $aggregator = new MetricAggregator(new FilteredAttributeProcessor(Attributes::factory(), ['foo', 'bar']), new SumAggregation(), $exemplarReservoir); + $aggregator->record(5, Attributes::create(['foo' => 1]), Context::getRoot(), 3); + } + + public function test_metric_aggregator_exemplars_provides_current_revision_range(): void + { + $exemplars = [ + [new Data\Exemplar(5, 3, Attributes::create([]), null, null)], + ]; + $exemplarReservoir = $this->createMock(ExemplarReservoirInterface::class); + $exemplarReservoir->expects($this->once())->method('collect')->with($this->anything(), 0, 1)->willReturn($exemplars); + $aggregator = new MetricAggregator(new FilteredAttributeProcessor(Attributes::factory(), ['foo', 'bar']), new SumAggregation(), $exemplarReservoir); + $aggregator->record(5, Attributes::create([]), Context::getRoot(), 3); + $metric = $aggregator->collect(2); + + $this->assertSame($exemplars, $aggregator->exemplars($metric)); + } +} diff --git a/tests/Unit/SDK/Metrics/Stream/StreamWriterTest.php b/tests/Unit/SDK/Metrics/Stream/StreamWriterTest.php new file mode 100644 index 000000000..84fbd9a82 --- /dev/null +++ b/tests/Unit/SDK/Metrics/Stream/StreamWriterTest.php @@ -0,0 +1,135 @@ +createMock(WritableMetricStreamInterface::class); + $stream->expects($this->once())->method('record')->with(5, Attributes::create(['foo' => 1]), $this->anything(), 3); + + $w = new StreamWriter(null, Attributes::factory(), $stream); + $w->record(5, ['foo' => 1], null, 3); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\Stream\MultiStreamWriter + */ + public function test_multi_stream_writer(): void + { + $streams = []; + for ($i = 0; $i < 3; $i++) { + $stream = $this->createMock(WritableMetricStreamInterface::class); + $stream->expects($this->once())->method('record')->with(5, Attributes::create(['foo' => 1]), $this->anything(), 3); + + $streams[] = $stream; + } + + $w = new MultiStreamWriter(null, Attributes::factory(), $streams); + $w->record(5, ['foo' => 1], null, 3); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\Stream\StreamWriter + */ + public function test_stream_writer_provides_current_context_on_context_null(): void + { + $context = Context::getRoot()->with(Context::createKey('-'), 5); + $contextStorage = $this->createMock(ContextStorageInterface::class); + $contextStorage->method('current')->willReturn($context); + + $stream = $this->createMock(WritableMetricStreamInterface::class); + $stream->expects($this->once())->method('record')->with(5, Attributes::create(['foo' => 1]), $context, 3); + + $w = new StreamWriter($contextStorage, Attributes::factory(), $stream); + $w->record(5, ['foo' => 1], null, 3); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\Stream\StreamWriter + */ + public function test_stream_writer_context_provides_context_on_context_provided(): void + { + $context = Context::getRoot()->with(Context::createKey('-'), 5); + $contextStorage = $this->createMock(ContextStorageInterface::class); + + $stream = $this->createMock(WritableMetricStreamInterface::class); + $stream->expects($this->once())->method('record')->with(5, Attributes::create(['foo' => 1]), $context, 3); + + $w = new StreamWriter($contextStorage, Attributes::factory(), $stream); + $w->record(5, ['foo' => 1], $context, 3); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\Stream\StreamWriter + */ + public function test_stream_writer_context_provides_root_context_on_context_false(): void + { + $contextStorage = $this->createMock(ContextStorageInterface::class); + + $stream = $this->createMock(WritableMetricStreamInterface::class); + $stream->expects($this->once())->method('record')->with(5, Attributes::create(['foo' => 1]), Context::getRoot(), 3); + + $w = new StreamWriter($contextStorage, Attributes::factory(), $stream); + $w->record(5, ['foo' => 1], false, 3); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\Stream\MultiStreamWriter + */ + public function test_multi_stream_writer_provides_current_context_on_context_null(): void + { + $context = Context::getRoot()->with(Context::createKey('-'), 5); + $contextStorage = $this->createMock(ContextStorageInterface::class); + $contextStorage->method('current')->willReturn($context); + + $stream = $this->createMock(WritableMetricStreamInterface::class); + $stream->expects($this->once())->method('record')->with(5, Attributes::create(['foo' => 1]), $context, 3); + + $w = new MultiStreamWriter($contextStorage, Attributes::factory(), [$stream]); + $w->record(5, ['foo' => 1], null, 3); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\Stream\MultiStreamWriter + */ + public function test_multi_stream_writer_context_provides_context_on_context_provided(): void + { + $context = Context::getRoot()->with(Context::createKey('-'), 5); + $contextStorage = $this->createMock(ContextStorageInterface::class); + + $stream = $this->createMock(WritableMetricStreamInterface::class); + $stream->expects($this->once())->method('record')->with(5, Attributes::create(['foo' => 1]), $context, 3); + + $w = new MultiStreamWriter($contextStorage, Attributes::factory(), [$stream]); + $w->record(5, ['foo' => 1], $context, 3); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\Stream\MultiStreamWriter + */ + public function test_multi_stream_writer_context_provides_root_context_on_context_false(): void + { + $contextStorage = $this->createMock(ContextStorageInterface::class); + + $stream = $this->createMock(WritableMetricStreamInterface::class); + $stream->expects($this->once())->method('record')->with(5, Attributes::create(['foo' => 1]), Context::getRoot(), 3); + + $w = new MultiStreamWriter($contextStorage, Attributes::factory(), [$stream]); + $w->record(5, ['foo' => 1], false, 3); + } +} diff --git a/tests/Unit/SDK/Metrics/UpDownCounterTest.php b/tests/Unit/SDK/Metrics/UpDownCounterTest.php deleted file mode 100644 index 2fd19c521..000000000 --- a/tests/Unit/SDK/Metrics/UpDownCounterTest.php +++ /dev/null @@ -1,103 +0,0 @@ -counter = new UpDownCounter('name', 'description'); - } - - public function test_get_type(): void - { - $this->assertSame(API\MetricKind::UP_DOWN_COUNTER, $this->counter->getType()); - } - - public function test_get_value(): void - { - $this->counter->add(1); - $this->assertSame(1, $this->counter->getValue()); - } - - public function test_valid_positive_int_add(): void - { - $retVal = $this->counter->add(5); - $this->assertEquals(5, $retVal); - $retVal = $this->counter->add(2); - $this->assertEquals(7, $retVal); - } - public function test_valid_negative_int_add(): void - { - $retVal = $this->counter->add(-5); - $this->assertEquals(-5, $retVal); - $retVal = $this->counter->add(-2); - $this->assertEquals(-7, $retVal); - } - - public function test_valid_positive_and_negative_int_add(): void - { - $retVal = $this->counter->add(5); - $this->assertEquals(5, $retVal); - $retVal = $this->counter->add(-2); - $this->assertEquals(3, $retVal); - } - public function test_valid_negative_and_positive_add(): void - { - $retVal = $this->counter->add(-5); - $this->assertEquals(-5, $retVal); - $retVal = $this->counter->add(2); - $this->assertEquals(-3, $retVal); - } - - public function test_valid_positive_float_add(): void - { - $retVal = $this->counter->add(5.2222); - $this->assertEquals(5, $retVal); - $retVal = $this->counter->add(2.6666); - $this->assertEquals(7, $retVal); - } - public function test_valid_negative_float_add(): void - { - $retVal = $this->counter->add(-5.2222); - $this->assertEquals(-5, $retVal); - $retVal = $this->counter->add(-2.6666); - $this->assertEquals(-7, $retVal); - } - - public function test_valid_positive_and_negative_float_add(): void - { - $retVal = $this->counter->add(5.2222); - $this->assertEquals(5, $retVal); - $retVal = $this->counter->add(-2.6666); - $this->assertEquals(3, $retVal); - } - public function test_valid_negative_and_positive_float_add(): void - { - $retVal = $this->counter->add(-5.2222); - $this->assertEquals(-5, $retVal); - $retVal = $this->counter->add(2.6666); - $this->assertEquals(-3, $retVal); - } - public function test_invalid_up_down_counter_add_throws_exception(): void - { - $this->expectException(InvalidArgumentException::class); - /** - * @phpstan-ignore-next-line - * @psalm-suppress InvalidScalarArgument - */ - $this->counter->add('a'); - } -} diff --git a/tests/Unit/SDK/Metrics/ValueRecorderTest.php b/tests/Unit/SDK/Metrics/ValueRecorderTest.php deleted file mode 100644 index e2cb9c187..000000000 --- a/tests/Unit/SDK/Metrics/ValueRecorderTest.php +++ /dev/null @@ -1,126 +0,0 @@ -metric = new ValueRecorder('name', 'description'); - } - - public function test_get_type(): void - { - $this->assertSame(API\MetricKind::VALUE_RECORDER, $this->metric->getType()); - } - - public function test_valid_positive_int_record(): void - { - $this->metric->record(5); - $this->assertMetrics($this->metric, 1, 5, 5, 5); - $this->metric->record(2); - $this->assertMetrics($this->metric, 2, 5, 2, 7); - } - - public function test_valid_negative_int_record(): void - { - $this->metric->record(-5); - $this->assertMetrics($this->metric, 1, -5, -5, -5); - $this->metric->record(-2); - $this->assertMetrics($this->metric, 2, -2, -5, -7); - } - - public function test_valid_positive_and_negative_int_record(): void - { - $this->metric->record(5); - $this->assertMetrics($this->metric, 1, 5, 5, 5); - $this->metric->record(-2); - $this->assertMetrics($this->metric, 2, 5, -2, 3); - } - - public function test_valid_negative_and_positive_record(): void - { - $this->metric->record(-5); - $this->assertMetrics($this->metric, 1, -5, -5, -5); - $this->metric->record(2); - $this->assertMetrics($this->metric, 2, 2, -5, -3); - } - - public function test_valid_positive_float_record(): void - { - $this->metric->record(5.2222); - $this->assertMetrics($this->metric, 1, 5.2222, 5.2222, 5.2222); - $this->metric->record(2.6666); - $this->assertMetrics($this->metric, 2, 5.2222, 2.6666, 7.8888); - } - - public function test_valid_negative_float_record(): void - { - $this->metric->record(-5.2222); - $this->assertMetrics($this->metric, 1, -5.2222, -5.2222, -5.2222); - $this->metric->record(-2.6666); - $this->assertMetrics($this->metric, 2, -2.6666, -5.2222, -7.8888); - } - - public function test_valid_positive_and_negative_float_record(): void - { - $this->metric->record(5.2222); - $this->assertMetrics($this->metric, 1, 5.2222, 5.2222, 5.2222); - $this->metric->record(-2.6666); - $this->assertMetrics($this->metric, 2, 5.2222, -2.6666, 2.5556); - } - - public function test_valid_negative_and_positive_float_record(): void - { - $this->metric->record(-5.2222); - $this->assertMetrics($this->metric, 1, -5.2222, -5.2222, -5.2222); - $this->metric->record(2.6666); - $this->assertMetrics($this->metric, 2, 2.6666, -5.2222, -2.5556); - } - - private function assertMetrics(ValueRecorder $metric, float $count, float $max, float $min, float $sum): void - { - $this->assertEquals($count, $metric->getCount()); - $this->assertEquals($max, $metric->getMax()); - $this->assertEquals($min, $metric->getMin()); - $this->assertEquals($sum, $metric->getSum()); - } - - public function test_value_recorder_initialization(): void - { - $this->assertEquals(0, $this->metric->getCount()); - $this->assertEquals(INF, $this->metric->getMax()); - $this->assertEquals(-INF, $this->metric->getMin()); - $this->assertEquals(0, $this->metric->getSum()); - $this->assertEquals(0, $this->metric->getMean()); - } - - public function test_invalid_value_recorder_record_throws_type_error(): void - { - $this->expectException(TypeError::class); - /** - * @phpstan-ignore-next-line - * @psalm-suppress InvalidArgument - */ - $this->metric->record('a'); - } - - public function test_get_mean(): void - { - $this->metric->record(2); - $this->metric->record(5); - $this->assertSame(3.5, $this->metric->getMean()); - } -} diff --git a/tests/Unit/SDK/Metrics/View/CriteriaViewRegistryTest.php b/tests/Unit/SDK/Metrics/View/CriteriaViewRegistryTest.php new file mode 100644 index 000000000..4ba83804d --- /dev/null +++ b/tests/Unit/SDK/Metrics/View/CriteriaViewRegistryTest.php @@ -0,0 +1,55 @@ +assertNull($views->find( + new Instrument(InstrumentType::COUNTER, 'name', null, null), + new InstrumentationScope('name', null, null, Attributes::create([])), + )); + } + + public function test_registry_returns_matching_entry(): void + { + $views = new CriteriaViewRegistry(); + $views->register(new InstrumentNameCriteria('name'), ViewTemplate::create()); + $this->assertEquals( + [ + new ViewProjection('name', null, null, null, null), + ], + [...$views->find( + new Instrument(InstrumentType::COUNTER, 'name', null, null), + new InstrumentationScope('name', null, null, Attributes::create([])), + )], + ); + } + + public function test_registry_does_not_return_not_matching_entry(): void + { + $views = new CriteriaViewRegistry(); + $views->register(new InstrumentNameCriteria('foo'), ViewTemplate::create()); + $this->assertNull($views->find( + new Instrument(InstrumentType::COUNTER, 'name', null, null), + new InstrumentationScope('name', null, null, Attributes::create([])), + )); + } +} diff --git a/tests/Unit/SDK/Metrics/View/SelectionCriteriaTest.php b/tests/Unit/SDK/Metrics/View/SelectionCriteriaTest.php new file mode 100644 index 000000000..974183e51 --- /dev/null +++ b/tests/Unit/SDK/Metrics/View/SelectionCriteriaTest.php @@ -0,0 +1,157 @@ +assertTrue((new InstrumentationScopeNameCriteria('scopeName'))->accepts( + new Instrument(InstrumentType::COUNTER, 'name', null, null), + new InstrumentationScope('scopeName', null, null, Attributes::create([])), + )); + $this->assertFalse((new InstrumentationScopeNameCriteria('scopeName'))->accepts( + new Instrument(InstrumentType::COUNTER, 'name', null, null), + new InstrumentationScope('scope-name', null, null, Attributes::create([])), + )); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\View\SelectionCriteria\InstrumentationScopeVersionCriteria + */ + public function test_instrument_scope_version_criteria(): void + { + $this->assertTrue((new InstrumentationScopeVersionCriteria('1.0.0'))->accepts( + new Instrument(InstrumentType::COUNTER, 'name', null, null), + new InstrumentationScope('scopeName', '1.0.0', null, Attributes::create([])), + )); + $this->assertFalse((new InstrumentationScopeVersionCriteria('1.0.0'))->accepts( + new Instrument(InstrumentType::COUNTER, 'name', null, null), + new InstrumentationScope('scopeName', '2.0.0', null, Attributes::create([])), + )); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\View\SelectionCriteria\InstrumentationScopeSchemaUrlCriteria + */ + public function test_instrument_scope_schema_url_criteria(): void + { + $this->assertTrue((new InstrumentationScopeSchemaUrlCriteria('https://schema-url.test/1.0'))->accepts( + new Instrument(InstrumentType::COUNTER, 'name', null, null), + new InstrumentationScope('scopeName', null, 'https://schema-url.test/1.0', Attributes::create([])), + )); + $this->assertFalse((new InstrumentationScopeSchemaUrlCriteria('https://schema-url.test/1.0'))->accepts( + new Instrument(InstrumentType::COUNTER, 'name', null, null), + new InstrumentationScope('scopeName', null, 'https://schema-url.test/2.0', Attributes::create([])), + )); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\View\SelectionCriteria\InstrumentNameCriteria + * @dataProvider instrumentNameProvider + */ + public function test_instrument_name_criteria(string $pattern, string $name, bool $expected): void + { + $this->assertSame($expected, (new InstrumentNameCriteria($pattern))->accepts( + new Instrument(InstrumentType::COUNTER, $name, null, null), + new InstrumentationScope('scopeName', null, null, Attributes::create([])), + )); + } + + public function instrumentNameProvider(): iterable + { + yield 'exact - matching' => ['foobar', 'foobar', true]; + yield 'exact - not matching' => ['foobar', 'foobaz', false]; + yield 'wildcard ? - matching' => ['foo?ar', 'foobar', true]; + yield 'wildcard ? - not matching' => ['foo?ar', 'foobaz', false]; + yield 'wildcard ? - not matching (too many)' => ['foo?ar', 'foobaar', false]; + yield 'wildcard * - matching' => ['foo*ar', 'foobar', true]; + yield 'wildcard * - matching (multiple character)' => ['foo*ar', 'foobaar', true]; + yield 'wildcard * - matching (no character)' => ['foo*ar', 'fooar', true]; + yield 'wildcard * - not matching' => ['foo*ar', 'foobaz', false]; + yield 'match all - matching' => ['*', 'foobar', true]; + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\View\SelectionCriteria\InstrumentTypeCriteria + */ + public function test_instrument_type_criteria_wildcard(): void + { + $this->assertTrue((new InstrumentTypeCriteria(InstrumentType::COUNTER))->accepts( + new Instrument(InstrumentType::COUNTER, 'name', null, null), + new InstrumentationScope('scopeName', null, null, Attributes::create([])), + )); + $this->assertFalse((new InstrumentTypeCriteria(InstrumentType::COUNTER))->accepts( + new Instrument(InstrumentType::HISTOGRAM, 'name', null, null), + new InstrumentationScope('scopeName', null, null, Attributes::create([])), + )); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\View\SelectionCriteria\AllCriteria + */ + public function test_all_criteria_accepts_if_all_criteria_accept(): void + { + $instrument = new Instrument(InstrumentType::COUNTER, 'name', null, null); + $instrumentScope = new InstrumentationScope('scopeName', null, null, Attributes::create([])); + + $criterias = []; + for ($i = 0; $i < 3; $i++) { + $criteria = $this->prophesize(SelectionCriteriaInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $criteria + ->accepts() + ->shouldBeCalledOnce() + ->withArguments([$instrument, $instrumentScope]) + ->willReturn(true); + $criterias[] = $criteria->reveal(); + } + + $this->assertTrue((new AllCriteria($criterias))->accepts($instrument, $instrumentScope)); + } + + /** + * @covers \OpenTelemetry\SDK\Metrics\View\SelectionCriteria\AllCriteria + */ + public function test_all_criteria_rejects_if_any_criteria_rejects(): void + { + $instrument = new Instrument(InstrumentType::COUNTER, 'name', null, null); + $instrumentScope = new InstrumentationScope('scopeName', null, null, Attributes::create([])); + + $criterias = []; + for ($i = 0; $i < 3; $i++) { + $criteria = $this->prophesize(SelectionCriteriaInterface::class); + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $criteria + ->accepts() + ->withArguments([$instrument, $instrumentScope]) + ->willReturn(($i & 1) !== 0); + $criterias[] = $criteria->reveal(); + } + + $this->assertFalse((new AllCriteria($criterias))->accepts($instrument, $instrumentScope)); + } +} diff --git a/tests/Unit/SDK/Metrics/View/ViewTemplateTest.php b/tests/Unit/SDK/Metrics/View/ViewTemplateTest.php new file mode 100644 index 000000000..3048fa76f --- /dev/null +++ b/tests/Unit/SDK/Metrics/View/ViewTemplateTest.php @@ -0,0 +1,52 @@ +assertEquals( + new ViewProjection( + 'name', + 'unit', + 'description', + null, + null, + ), + ViewTemplate::create() + ->project(new Instrument(InstrumentType::COUNTER, 'name', 'unit', 'description')), + ); + } + + public function test_template_returns_assigned_values(): void + { + $this->assertEquals( + new ViewProjection( + 'v-name', + 'unit', + 'v-description', + ['foo', 'bar'], + new SumAggregation(), + ), + ViewTemplate::create() + ->withName('v-name') + ->withDescription('v-description') + ->withAttributeKeys(['foo', 'bar']) + ->withAggregation(new SumAggregation()) + ->project(new Instrument(InstrumentType::COUNTER, 'name', 'unit', 'description')), + ); + } +}