From c6dfbc79bfcf9aa5e1b08d432a3f42879c39d468 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:17:39 +0100 Subject: [PATCH 1/3] Add ObservableGauge metric type Implements an asynchronous gauge whose value is collected via a callback at export time, following the OpenTelemetry observable instrument pattern. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 70 +++++++++++++++++-- src/Telemetry/Adapter.php | 10 +++ src/Telemetry/Adapter/None.php | 13 ++++ src/Telemetry/Adapter/OpenTelemetry.php | 36 +++++++++- src/Telemetry/Adapter/Test.php | 26 +++++++ src/Telemetry/ObservableGauge.php | 16 +++++ .../Adapter/OpenTelemetryTestCase.php | 35 ++++++++++ 7 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 src/Telemetry/ObservableGauge.php diff --git a/README.md b/README.md index a14563e..384d232 100644 --- a/README.md +++ b/README.md @@ -22,22 +22,78 @@ Init in your application: require_once __DIR__ . '/vendor/autoload.php'; -// Create a Telemetry instance using OpenTelemetry adapter. use Utopia\Telemetry\Adapter\OpenTelemetry; -$telemetry = new OpenTelemetry('http://localhost:4138', 'namespace', 'app', 'unique-instance-name'); -$counter = $telemetry->createUpDownCounter('http.server.active_requests', '{request}'); +$telemetry = new OpenTelemetry('http://localhost:4318/v1/metrics', 'namespace', 'app', 'unique-instance-id'); -$counter->add(1); -$counter->add(2); - -// Periodically collect metrics and send them to the configured OpenTelemetry endpoint. +// Periodically collect and export metrics to the configured OpenTelemetry endpoint. $telemetry->collect(); // Example using Swoole \Swoole\Timer::tick(60_000, fn () => $telemetry->collect()); ``` +## Metric Types + +### Counter + +A monotonically increasing counter. Only positive increments are allowed. + +```php +$counter = $telemetry->createCounter('http.server.requests', '{request}', 'Total HTTP requests'); + +$counter->add(1); +$counter->add(1, ['method' => 'GET', 'status' => '200']); +``` + +### UpDownCounter + +A counter that can increase or decrease. Useful for tracking values like active connections. + +```php +$upDownCounter = $telemetry->createUpDownCounter('http.server.active_requests', '{request}', 'Active HTTP requests'); + +$upDownCounter->add(1); // request started +$upDownCounter->add(-1); // request finished +``` + +### Histogram + +Records a distribution of values. Useful for measuring latency or payload sizes. + +```php +$histogram = $telemetry->createHistogram('http.server.request.duration', 'ms', 'HTTP request duration'); + +$histogram->record(142); +$histogram->record(98.5, ['route' => '/api/users']); +``` + +### Gauge + +Records an instantaneous measurement. Useful for values that can arbitrarily go up or down. + +```php +$gauge = $telemetry->createGauge('system.memory.usage', 'By', 'Memory usage'); + +$gauge->record(1_073_741_824); +$gauge->record(536_870_912, ['host' => 'server-1']); +``` + +### ObservableGauge + +An asynchronous gauge whose value is collected via a callback at export time. Useful for values that are expensive to compute or come from an external source (e.g. CPU usage, queue depth). + +```php +$observableGauge = $telemetry->createObservableGauge('process.cpu.usage', '%', 'CPU usage'); + +$observableGauge->observe(function (callable $observer): void { + // This callback is invoked each time metrics are collected. + $observer(sys_getloadavg()[0] * 100); + $observer(72.4, ['core' => '0']); + $observer(68.1, ['core' => '1']); +}); +``` + ## System Requirements Utopia Framework requires PHP 8.0 or later. We recommend using the latest PHP version whenever possible. diff --git a/src/Telemetry/Adapter.php b/src/Telemetry/Adapter.php index 616c45e..d8d6e33 100644 --- a/src/Telemetry/Adapter.php +++ b/src/Telemetry/Adapter.php @@ -44,5 +44,15 @@ public function createUpDownCounter( array $advisory = [], ): UpDownCounter; + /** + * @param array $advisory + */ + public function createObservableGauge( + string $name, + ?string $unit = null, + ?string $description = null, + array $advisory = [], + ): ObservableGauge; + public function collect(): bool; } diff --git a/src/Telemetry/Adapter/None.php b/src/Telemetry/Adapter/None.php index cb1285b..4ab8e86 100644 --- a/src/Telemetry/Adapter/None.php +++ b/src/Telemetry/Adapter/None.php @@ -6,6 +6,7 @@ use Utopia\Telemetry\Counter; use Utopia\Telemetry\Gauge; use Utopia\Telemetry\Histogram; +use Utopia\Telemetry\ObservableGauge; use Utopia\Telemetry\UpDownCounter; class None implements Adapter @@ -70,6 +71,18 @@ public function add(float|int $amount, iterable $attributes = []): void }; } + /** + * @param array $advisory + */ + public function createObservableGauge(string $name, ?string $unit = null, ?string $description = null, array $advisory = []): ObservableGauge + { + return new class () extends ObservableGauge { + public function observe(callable $callback): void + { + } + }; + } + public function collect(): bool { return true; diff --git a/src/Telemetry/Adapter/OpenTelemetry.php b/src/Telemetry/Adapter/OpenTelemetry.php index b2ff293..ba641ff 100644 --- a/src/Telemetry/Adapter/OpenTelemetry.php +++ b/src/Telemetry/Adapter/OpenTelemetry.php @@ -6,6 +6,7 @@ use OpenTelemetry\API\Metrics\GaugeInterface; use OpenTelemetry\API\Metrics\HistogramInterface; use OpenTelemetry\API\Metrics\MeterInterface; +use OpenTelemetry\API\Metrics\ObserverInterface; use OpenTelemetry\API\Metrics\UpDownCounterInterface; use OpenTelemetry\Contrib\Otlp\ContentTypes; use OpenTelemetry\Contrib\Otlp\MetricExporter; @@ -25,6 +26,7 @@ use Utopia\Telemetry\Counter; use Utopia\Telemetry\Gauge; use Utopia\Telemetry\Histogram; +use Utopia\Telemetry\ObservableGauge; use Utopia\Telemetry\UpDownCounter; class OpenTelemetry implements Adapter @@ -34,13 +36,14 @@ class OpenTelemetry implements Adapter private MeterInterface $meter; /** - * @var array> + * @var array> */ private array $meterStorage = [ Counter::class => [], UpDownCounter::class => [], Histogram::class => [], Gauge::class => [], + ObservableGauge::class => [], ]; /** @@ -103,12 +106,12 @@ protected function createExporter(TransportInterface $transport): MetricExporter } /** - * @template T of Counter|UpDownCounter|Histogram|Gauge + * @template T of Counter|UpDownCounter|Histogram|Gauge|ObservableGauge * @param class-string $type * @param callable(): T $creator * @return T */ - private function createMeter(string $type, string $name, callable $creator): Counter|UpDownCounter|Histogram|Gauge + private function createMeter(string $type, string $name, callable $creator): Counter|UpDownCounter|Histogram|Gauge|ObservableGauge { if (! isset($this->meterStorage[$type][$name])) { $this->meterStorage[$type][$name] = $creator(); @@ -222,6 +225,33 @@ public function add(float|int $amount, iterable $attributes = []): void }); } + /** + * Create an ObservableGauge metric + * + * @param array $advisory + */ + public function createObservableGauge(string $name, ?string $unit = null, ?string $description = null, array $advisory = []): ObservableGauge + { + return $this->createMeter(ObservableGauge::class, $name, function () use ($name, $unit, $description, $advisory) { + $otelGauge = $this->meter->createObservableGauge($name, $unit, $description, $advisory); + + return new class ($otelGauge) extends ObservableGauge { + public function __construct(private \OpenTelemetry\API\Metrics\ObservableGaugeInterface $gauge) + { + } + + public function observe(callable $callback): void + { + $this->gauge->observe(function (ObserverInterface $observer) use ($callback): void { + $callback(function (float|int $value, iterable $attributes = []) use ($observer): void { + $observer->observe($value, $attributes); + }); + }); + } + }; + }); + } + /** * Collect and export metrics */ diff --git a/src/Telemetry/Adapter/Test.php b/src/Telemetry/Adapter/Test.php index 74df1e0..92d09cd 100644 --- a/src/Telemetry/Adapter/Test.php +++ b/src/Telemetry/Adapter/Test.php @@ -6,6 +6,7 @@ use Utopia\Telemetry\Counter; use Utopia\Telemetry\Gauge; use Utopia\Telemetry\Histogram; +use Utopia\Telemetry\ObservableGauge; use Utopia\Telemetry\UpDownCounter; /** @@ -33,6 +34,11 @@ class Test implements Adapter */ public array $upDownCounters = []; + /** + * @var array + */ + public array $observableGauges = []; + /** * @param array $advisory */ @@ -125,6 +131,26 @@ public function add(float|int $amount, iterable $attributes = []): void return $upDownCounter; } + /** + * @param array $advisory + */ + public function createObservableGauge(string $name, ?string $unit = null, ?string $description = null, array $advisory = []): ObservableGauge + { + $gauge = new class () extends ObservableGauge { + /** + * @var array + */ + public array $callbacks = []; + + public function observe(callable $callback): void + { + $this->callbacks[] = $callback; + } + }; + $this->observableGauges[$name] = $gauge; + return $gauge; + } + public function collect(): bool { return true; diff --git a/src/Telemetry/ObservableGauge.php b/src/Telemetry/ObservableGauge.php new file mode 100644 index 0000000..441e302 --- /dev/null +++ b/src/Telemetry/ObservableGauge.php @@ -0,0 +1,16 @@ +|bool|float|int|string|null>): void): void $callback + */ + abstract public function observe(callable $callback): void; +} diff --git a/tests/Telemetry/Adapter/OpenTelemetryTestCase.php b/tests/Telemetry/Adapter/OpenTelemetryTestCase.php index bab4da3..eca2a43 100644 --- a/tests/Telemetry/Adapter/OpenTelemetryTestCase.php +++ b/tests/Telemetry/Adapter/OpenTelemetryTestCase.php @@ -8,6 +8,7 @@ use Utopia\Telemetry\Counter; use Utopia\Telemetry\Gauge; use Utopia\Telemetry\Histogram; +use Utopia\Telemetry\ObservableGauge; use Utopia\Telemetry\UpDownCounter; /** @@ -159,6 +160,38 @@ public function testUpDownCounterAdd(): void $this->assertTrue(true); } + public function testCreateObservableGauge(): void + { + $gauge = $this->adapter->createObservableGauge( + name: 'test_observable_gauge', + unit: 'bytes', + description: 'Test observable gauge metric' + ); + + $this->assertInstanceOf(ObservableGauge::class, $gauge); + } + + public function testObservableGaugeObserve(): void + { + $gauge = $this->adapter->createObservableGauge('observe_test_gauge'); + + // Should not throw + $gauge->observe(function (callable $observer): void { + $observer(1024); + $observer(2048.5, ['host' => 'server-1']); + }); + + $this->assertTrue(true); + } + + public function testObservableGaugeCaching(): void + { + $gauge1 = $this->adapter->createObservableGauge('cached_observable_gauge'); + $gauge2 = $this->adapter->createObservableGauge('cached_observable_gauge'); + + $this->assertSame($gauge1, $gauge2); + } + public function testMeterCaching(): void { $counter1 = $this->adapter->createCounter('cached_counter'); @@ -229,10 +262,12 @@ public function testNullOptionalParameters(): void $histogram = $this->adapter->createHistogram('minimal_histogram'); $gauge = $this->adapter->createGauge('minimal_gauge'); $upDownCounter = $this->adapter->createUpDownCounter('minimal_updown'); + $observableGauge = $this->adapter->createObservableGauge('minimal_observable_gauge'); $this->assertInstanceOf(Counter::class, $counter); $this->assertInstanceOf(Histogram::class, $histogram); $this->assertInstanceOf(Gauge::class, $gauge); $this->assertInstanceOf(UpDownCounter::class, $upDownCounter); + $this->assertInstanceOf(ObservableGauge::class, $observableGauge); } } From 7cba6732e418830464e69ba222f48c69997970eb Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:24:29 +0100 Subject: [PATCH 2/3] Fix ObservableGauge accumulating OTel callbacks on repeated observe() calls Register the OTel-level callback once in the constructor and delegate to a single replaceable closure, so calling observe() again replaces rather than adds another callback. Co-Authored-By: Claude Sonnet 4.6 --- src/Telemetry/Adapter/OpenTelemetry.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Telemetry/Adapter/OpenTelemetry.php b/src/Telemetry/Adapter/OpenTelemetry.php index ba641ff..ac435ab 100644 --- a/src/Telemetry/Adapter/OpenTelemetry.php +++ b/src/Telemetry/Adapter/OpenTelemetry.php @@ -236,17 +236,23 @@ public function createObservableGauge(string $name, ?string $unit = null, ?strin $otelGauge = $this->meter->createObservableGauge($name, $unit, $description, $advisory); return new class ($otelGauge) extends ObservableGauge { + private ?\Closure $callback = null; + public function __construct(private \OpenTelemetry\API\Metrics\ObservableGaugeInterface $gauge) { + $this->gauge->observe(function (ObserverInterface $observer): void { + if ($this->callback !== null) { + ($this->callback)(function (float|int $value, iterable $attributes = []) use ($observer): void { + /** @var iterable|bool|float|int|string|null> $attributes */ + $observer->observe($value, $attributes); + }); + } + }); } public function observe(callable $callback): void { - $this->gauge->observe(function (ObserverInterface $observer) use ($callback): void { - $callback(function (float|int $value, iterable $attributes = []) use ($observer): void { - $observer->observe($value, $attributes); - }); - }); + $this->callback = \Closure::fromCallable($callback); } }; }); From 5db0fd45d399d8de1c0365d9f9e2dd65ebb5704d Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:42:16 +0100 Subject: [PATCH 3/3] Align Test adapter observe() to replace callback, matching OpenTelemetry adapter Co-Authored-By: Claude Sonnet 4.6 --- src/Telemetry/Adapter/Test.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Telemetry/Adapter/Test.php b/src/Telemetry/Adapter/Test.php index 92d09cd..73978f6 100644 --- a/src/Telemetry/Adapter/Test.php +++ b/src/Telemetry/Adapter/Test.php @@ -137,14 +137,11 @@ public function add(float|int $amount, iterable $attributes = []): void public function createObservableGauge(string $name, ?string $unit = null, ?string $description = null, array $advisory = []): ObservableGauge { $gauge = new class () extends ObservableGauge { - /** - * @var array - */ - public array $callbacks = []; + public ?\Closure $callback = null; public function observe(callable $callback): void { - $this->callbacks[] = $callback; + $this->callback = \Closure::fromCallable($callback); } }; $this->observableGauges[$name] = $gauge;