From a2ca4c1a622f5886bb9e53a3755b9c5e76224a27 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Mon, 29 Jun 2026 12:29:35 +0200 Subject: [PATCH 01/11] feat(metrics): add PHP runtime metrics package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds open-telemetry/opentelemetry-metrics-runtime — a new contrib package that auto-registers OTel observable instruments for PHP runtime internals: memory usage/peak/limit, garbage collection stats (including PHP 8.3+ timing fields), OPcache memory and hit-rate, and CPU time via getrusage(). - Disable entire package: OTEL_PHP_DISABLED_INSTRUMENTATIONS=metrics-runtime - Disable metric groups: OTEL_PHP_DISABLED_METRICS=opcache,cpu - OPcache and CPU metrics are skipped when underlying functions are unavailable - Supports PHP 8.1–8.5; phan stub created via post-install-cmd on PHP 8.5+ --- .github/workflows/php.yml | 1 + .gitsplit.yml | 2 + composer.json | 4 + src/Metrics/Runtime/.gitignore | 2 + src/Metrics/Runtime/.phan/config.php | 62 +++++++ src/Metrics/Runtime/.php-cs-fixer.php | 43 +++++ src/Metrics/Runtime/README.md | 113 ++++++++++++ src/Metrics/Runtime/_register.php | 20 +++ src/Metrics/Runtime/bin/create-phan-stub.php | 15 ++ src/Metrics/Runtime/composer.json | 47 +++++ src/Metrics/Runtime/phpstan.neon.dist | 9 + src/Metrics/Runtime/phpunit.xml.dist | 19 ++ src/Metrics/Runtime/psalm.xml.dist | 19 ++ src/Metrics/Runtime/src/CpuMetrics.php | 64 +++++++ .../Runtime/src/GarbageCollectionMetrics.php | 98 ++++++++++ src/Metrics/Runtime/src/MemoryMetrics.php | 66 +++++++ src/Metrics/Runtime/src/OpcacheMetrics.php | 167 ++++++++++++++++++ src/Metrics/Runtime/src/RuntimeMetrics.php | 64 +++++++ .../Runtime/tests/Unit/CpuMetricsTest.php | 75 ++++++++ .../Unit/GarbageCollectionMetricsTest.php | 111 ++++++++++++ .../Runtime/tests/Unit/MemoryMetricsTest.php | 75 ++++++++ .../Runtime/tests/Unit/OpcacheMetricsTest.php | 43 +++++ .../Runtime/tests/Unit/RuntimeMetricsTest.php | 91 ++++++++++ 23 files changed, 1210 insertions(+) create mode 100644 src/Metrics/Runtime/.gitignore create mode 100644 src/Metrics/Runtime/.phan/config.php create mode 100644 src/Metrics/Runtime/.php-cs-fixer.php create mode 100644 src/Metrics/Runtime/README.md create mode 100644 src/Metrics/Runtime/_register.php create mode 100644 src/Metrics/Runtime/bin/create-phan-stub.php create mode 100644 src/Metrics/Runtime/composer.json create mode 100644 src/Metrics/Runtime/phpstan.neon.dist create mode 100644 src/Metrics/Runtime/phpunit.xml.dist create mode 100644 src/Metrics/Runtime/psalm.xml.dist create mode 100644 src/Metrics/Runtime/src/CpuMetrics.php create mode 100644 src/Metrics/Runtime/src/GarbageCollectionMetrics.php create mode 100644 src/Metrics/Runtime/src/MemoryMetrics.php create mode 100644 src/Metrics/Runtime/src/OpcacheMetrics.php create mode 100644 src/Metrics/Runtime/src/RuntimeMetrics.php create mode 100644 src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php create mode 100644 src/Metrics/Runtime/tests/Unit/GarbageCollectionMetricsTest.php create mode 100644 src/Metrics/Runtime/tests/Unit/MemoryMetricsTest.php create mode 100644 src/Metrics/Runtime/tests/Unit/OpcacheMetricsTest.php create mode 100644 src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 309233d9a..1da315ea8 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -56,6 +56,7 @@ jobs: 'Instrumentation/Symfony', 'Instrumentation/Yii', 'Logs/Monolog', + 'Metrics/Runtime', 'Propagation/CloudTrace', 'Propagation/Instana', 'Propagation/ServerTiming', diff --git a/.gitsplit.yml b/.gitsplit.yml index 6ecd41b82..0a2a7cbc8 100644 --- a/.gitsplit.yml +++ b/.gitsplit.yml @@ -76,6 +76,8 @@ splits: target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-logger-monolog.git" - prefix: "src/MetaPackages/opentelemetry" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/opentelemetry-meta.git" + - prefix: "src/Metrics/Runtime" + target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-metrics-runtime.git" - prefix: "src/Propagation/CloudTrace" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-propagator-cloudtrace.git" - prefix: "src/Propagation/Instana" diff --git a/composer.json b/composer.json index 5b1e64b3a..0a911f9f7 100644 --- a/composer.json +++ b/composer.json @@ -54,6 +54,7 @@ "OpenTelemetry\\Contrib\\Resource\\Detector\\Azure\\": "src/ResourceDetectors/Azure/src", "OpenTelemetry\\Contrib\\Resource\\Detector\\Container\\": "src/ResourceDetectors/Container/src", "OpenTelemetry\\Contrib\\Resource\\Detector\\DigitalOcean\\": "src/ResourceDetectors/DigitalOcean/src", + "OpenTelemetry\\Contrib\\Metrics\\Runtime\\": "src/Metrics/Runtime/src", "OpenTelemetry\\Contrib\\Sampler\\RuleBased\\": "src/Sampler/RuleBased/src", "OpenTelemetry\\Contrib\\Shim\\OpenTracing\\": "src/Shims/OpenTracing/src", "OpenTelemetry\\Contrib\\SqlCommenter\\": "src/SqlCommenter/src", @@ -91,6 +92,7 @@ "src/Instrumentation/Yii/_register.php", "src/Propagation/CloudTrace/_register.php", "src/Propagation/Instana/_register.php", + "src/Metrics/Runtime/_register.php", "src/ResourceDetectors/Azure/_register.php", "src/ResourceDetectors/Container/_register.php", "src/ResourceDetectors/DigitalOcean/_register.php" @@ -127,6 +129,7 @@ "OpenTelemetry\\Tests\\Propagation\\CloudTrace\\": "src/Propagation/CloudTrace/tests", "OpenTelemetry\\Tests\\Resource\\Detector\\Azure\\": "src/ResourceDetectors/Azure/tests", "OpenTelemetry\\Contrib\\Resource\\Detector\\DigitalOcean\\": "src/ResourceDetectors/DigitalOcean/tests", + "OpenTelemetry\\Contrib\\Metrics\\Runtime\\Tests\\": "src/Metrics/Runtime/tests", "OpenTelemetry\\Tests\\Contrib\\SqlCommenter\\": "src/SqlCommenter/tests", "OpenTelemetry\\Tests\\Contrib\\Symfony\\": "src/Symfony/tests", "OpenTelemetry\\TestUtils\\Tests\\": "src/Utils/Test/tests" @@ -175,6 +178,7 @@ "open-telemetry/opentelemetry-propagation-instana": "self.version", "open-telemetry/opentelemetry-propagation-server-timing": "self.version", "open-telemetry/opentelemetry-propagation-traceresponse": "self.version", + "open-telemetry/opentelemetry-metrics-runtime": "self.version", "open-telemetry/opentelemetry-sqlcommenter": "self.version", "open-telemetry/opentracing-shim": "self.version", "open-telemetry/sampler-rule-based": "self.version", diff --git a/src/Metrics/Runtime/.gitignore b/src/Metrics/Runtime/.gitignore new file mode 100644 index 000000000..768d5ef62 --- /dev/null +++ b/src/Metrics/Runtime/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +/var/ diff --git a/src/Metrics/Runtime/.phan/config.php b/src/Metrics/Runtime/.phan/config.php new file mode 100644 index 000000000..c54b9865f --- /dev/null +++ b/src/Metrics/Runtime/.phan/config.php @@ -0,0 +1,62 @@ + '8.1', + + 'allow_missing_properties' => false, + 'null_casts_as_any_type' => false, + 'null_casts_as_array' => true, + 'array_casts_as_null' => true, + 'scalar_implicit_cast' => false, + 'scalar_array_key_cast' => true, + 'scalar_implicit_partial' => [], + 'strict_method_checking' => false, + 'strict_object_checking' => false, + 'strict_param_checking' => false, + 'strict_property_checking' => false, + 'strict_return_checking' => false, + 'ignore_undeclared_variables_in_global_scope' => true, + 'ignore_undeclared_functions_with_known_signatures' => true, + 'backward_compatibility_checks' => false, + 'check_docblock_signature_return_type_match' => false, + 'prefer_narrowed_phpdoc_param_type' => true, + 'prefer_narrowed_phpdoc_return_type' => true, + 'analyze_signature_compatibility' => true, + 'phpdoc_type_mapping' => [], + 'dead_code_detection' => false, + 'unused_variable_detection' => false, + 'redundant_condition_detection' => false, + 'assume_real_types_for_internal_functions' => false, + 'quick_mode' => false, + 'generic_types_enabled' => true, + 'globals_type_map' => [], + 'minimum_severity' => Issue::SEVERITY_LOW, + 'suppress_issue_types' => [], + 'exclude_file_regex' => '@^vendor/.*/(tests?|Tests?)/@', + 'exclude_file_list' => [ + 'vendor/composer/composer/src/Composer/InstalledVersions.php', + ], + 'exclude_analysis_directory_list' => [ + 'vendor/', + ], + 'enable_include_path_checks' => true, + 'processes' => 1, + 'analyzed_file_extensions' => [ + 'php', + ], + 'autoload_internal_extension_signatures' => [], + 'plugins' => [ + 'AlwaysReturnPlugin', + 'PregRegexCheckerPlugin', + 'UnreachableCodePlugin', + ], + 'directory_list' => [ + 'src', + 'vendor', + ], + 'file_list' => [ + '_register.php', + ], +]; diff --git a/src/Metrics/Runtime/.php-cs-fixer.php b/src/Metrics/Runtime/.php-cs-fixer.php new file mode 100644 index 000000000..bbfa04e61 --- /dev/null +++ b/src/Metrics/Runtime/.php-cs-fixer.php @@ -0,0 +1,43 @@ +exclude('vendor') + ->exclude('var/cache') + ->in(__DIR__); + +$config = new PhpCsFixer\Config(); +return $config->setRules([ + 'concat_space' => ['spacing' => 'one'], + 'declare_equal_normalize' => ['space' => 'none'], + 'is_null' => true, + 'modernize_types_casting' => true, + 'ordered_imports' => true, + 'php_unit_construct' => true, + 'single_line_comment_style' => true, + 'yoda_style' => false, + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'blank_line_after_opening_tag' => true, + 'blank_line_before_statement' => true, + 'cast_spaces' => true, + 'declare_strict_types' => true, + 'type_declaration_spaces' => true, + 'include' => true, + 'lowercase_cast' => true, + 'new_with_parentheses' => true, + 'no_extra_blank_lines' => true, + 'no_leading_import_slash' => true, + 'echo_tag_syntax' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'phpdoc_order' => true, + 'phpdoc_scalar' => true, + 'phpdoc_types' => true, + 'short_scalar_cast' => true, + 'blank_lines_before_namespace' => true, + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + ]) + ->setRiskyAllowed(true) + ->setFinder($finder); diff --git a/src/Metrics/Runtime/README.md b/src/Metrics/Runtime/README.md new file mode 100644 index 000000000..152f89e6c --- /dev/null +++ b/src/Metrics/Runtime/README.md @@ -0,0 +1,113 @@ +[![Releases](https://img.shields.io/badge/releases-purple)](https://github.com/open-telemetry/opentelemetry-php-contrib/releases) +[![Issues](https://img.shields.io/badge/issues-pink)](https://github.com/open-telemetry/opentelemetry-php/issues) +[![Source](https://img.shields.io/badge/source-contrib-green)](https://github.com/open-telemetry/opentelemetry-php-contrib/tree/main/src/Metrics/Runtime) +[![Latest Version](http://poser.pugx.org/open-telemetry/opentelemetry-metrics-runtime/v/unstable)](https://packagist.org/packages/open-telemetry/opentelemetry-metrics-runtime/) +[![Stable](http://poser.pugx.org/open-telemetry/opentelemetry-metrics-runtime/v/stable)](https://packagist.org/packages/open-telemetry/opentelemetry-metrics-runtime/) + +This is a read-only subtree split of https://github.com/open-telemetry/opentelemetry-php-contrib. + +# OpenTelemetry PHP Runtime Metrics + +Collects PHP runtime metrics — memory, garbage collection, OPcache, and CPU usage — and exposes them as OpenTelemetry observable instruments. + +## Requirements + +* PHP 8.1+ +* OpenTelemetry SDK + +## Installation + +```bash +composer require open-telemetry/opentelemetry-metrics-runtime +``` + +## Usage + +### Automatic (recommended) + +Metrics are registered automatically via Composer's autoload mechanism as long as the OpenTelemetry SDK is configured. No additional code is required. + +### Manual + +If you need to control when metrics are registered or use a specific meter: + +```php +use OpenTelemetry\Contrib\Metrics\Runtime\RuntimeMetrics; + +RuntimeMetrics::register($meterProvider->getMeter('my-app')); +``` + +## Metrics + +### Memory (`memory`) + +| Metric | Type | Unit | Description | +|--------|------|------|-------------| +| `process.runtime.php.memory.usage` | UpDownCounter | `By` | Current memory usage. Reported for both `real` (OS allocation) and `emalloc` (PHP internal) via the `memory.type` attribute. | +| `process.runtime.php.memory.peak_usage` | UpDownCounter | `By` | Peak memory usage since script start. Same `memory.type` attribute breakdown. | +| `process.runtime.php.memory.limit` | Gauge | `By` | Memory limit from `php.ini`. `-1` means unlimited. | + +### Garbage Collection (`gc`) + +| Metric | Type | Unit | Description | +|--------|------|------|-------------| +| `process.runtime.php.gc.runs` | Counter | `{runs}` | Total number of GC cycles run. | +| `process.runtime.php.gc.collected` | Counter | `{objects}` | Total number of objects collected. | +| `process.runtime.php.gc.roots` | Gauge | `{objects}` | Current number of objects in the root buffer. | +| `process.runtime.php.gc.threshold` | Gauge | `{objects}` | Number of roots required to trigger a GC cycle. | +| `process.runtime.php.gc.collector_time` | Counter | `s` | Cumulative time spent in the GC collector. **PHP 8.3+** | +| `process.runtime.php.gc.destructor_time` | Counter | `s` | Cumulative time spent running destructors during GC. **PHP 8.3+** | +| `process.runtime.php.gc.free_time` | Counter | `s` | Cumulative time spent freeing memory during GC. **PHP 8.3+** | + +### OPcache (`opcache`) + +Registered only when OPcache is enabled (`opcache.enable=1`). In CLI context, also requires `opcache.enable_cli=1`. + +| Metric | Type | Unit | Description | +|--------|------|------|-------------| +| `process.runtime.php.opcache.memory_used` | UpDownCounter | `By` | Memory used by cached scripts. | +| `process.runtime.php.opcache.memory_free` | UpDownCounter | `By` | Free memory in the OPcache buffer. | +| `process.runtime.php.opcache.memory_wasted` | UpDownCounter | `By` | Wasted (fragmented) memory — requires restart to reclaim. | +| `process.runtime.php.opcache.hits` | Counter | `{hits}` | Total cache hits. | +| `process.runtime.php.opcache.misses` | Counter | `{misses}` | Total cache misses. | +| `process.runtime.php.opcache.hit_rate` | Gauge | `%` | Cache hit rate percentage. | +| `process.runtime.php.opcache.cached_scripts` | Gauge | `{scripts}` | Number of scripts currently in cache. | +| `process.runtime.php.opcache.interned_strings.memory_used` | UpDownCounter | `By` | Memory used by interned strings. | +| `process.runtime.php.opcache.interned_strings.memory_free` | UpDownCounter | `By` | Free memory in the interned strings buffer. | +| `process.runtime.php.opcache.interned_strings.count` | Gauge | `{strings}` | Number of interned strings currently stored. | + +### CPU (`cpu`) + +Registered only on platforms where `getrusage()` is available (Linux, macOS). Not available on Windows. + +| Metric | Type | Unit | Description | +|--------|------|------|-------------| +| `process.cpu.time` | Counter | `s` | CPU time consumed. Reported for `user` and `system` modes via the `cpu.mode` attribute. | +| `process.runtime.php.cpu.voluntary_context_switches` | Counter | `{switches}` | Number of times the process voluntarily yielded the CPU. | +| `process.runtime.php.cpu.involuntary_context_switches` | Counter | `{switches}` | Number of times the process was preempted involuntarily. | + +## Configuration + +### Disable the entire package + +```shell +OTEL_PHP_DISABLED_INSTRUMENTATIONS=metrics-runtime +``` + +### Disable individual metric groups + +Use `OTEL_PHP_DISABLED_METRICS` with a comma-separated list of group names. Values are case-insensitive. + +```shell +# Disable OPcache and CPU metrics only +OTEL_PHP_DISABLED_METRICS=opcache,cpu + +# Disable GC metrics only +OTEL_PHP_DISABLED_METRICS=gc +``` + +Available group names: `memory`, `gc`, `opcache`, `cpu`. + +## Maintainer + +[@intuibase](https://github.com/intuibase) diff --git a/src/Metrics/Runtime/_register.php b/src/Metrics/Runtime/_register.php new file mode 100644 index 000000000..a580ef55b --- /dev/null +++ b/src/Metrics/Runtime/_register.php @@ -0,0 +1,20 @@ +getMeter( + RuntimeMetrics::getInstrumentationName(), + null, + Version::VERSION_1_38_0->url(), + ) +); diff --git a/src/Metrics/Runtime/bin/create-phan-stub.php b/src/Metrics/Runtime/bin/create-phan-stub.php new file mode 100644 index 000000000..119e9acca --- /dev/null +++ b/src/Metrics/Runtime/bin/create-phan-stub.php @@ -0,0 +1,15 @@ + + + + + + + + + + + tests/Unit + + + + + src + + + diff --git a/src/Metrics/Runtime/psalm.xml.dist b/src/Metrics/Runtime/psalm.xml.dist new file mode 100644 index 000000000..f79b91604 --- /dev/null +++ b/src/Metrics/Runtime/psalm.xml.dist @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + diff --git a/src/Metrics/Runtime/src/CpuMetrics.php b/src/Metrics/Runtime/src/CpuMetrics.php new file mode 100644 index 000000000..bcad39ff0 --- /dev/null +++ b/src/Metrics/Runtime/src/CpuMetrics.php @@ -0,0 +1,64 @@ +createObservableCounter( + 'process.cpu.time', + 's', + 'CPU time consumed by the process', + ) + ->observe(static function (ObserverInterface $observer): void { + /** @var array $usage */ + $usage = getrusage(); + + $userSeconds = (float) $usage['ru_utime.tv_sec'] + (float) $usage['ru_utime.tv_usec'] / 1_000_000.0; + $systemSeconds = (float) $usage['ru_stime.tv_sec'] + (float) $usage['ru_stime.tv_usec'] / 1_000_000.0; + + $observer->observe($userSeconds, ['cpu.mode' => 'user']); + $observer->observe($systemSeconds, ['cpu.mode' => 'system']); + }); + + $meter + ->createObservableCounter( + 'process.runtime.php.cpu.voluntary_context_switches', + '{switches}', + 'Number of times the process voluntarily yielded the CPU', + ) + ->observe(static function (ObserverInterface $observer): void { + /** @var array $usage */ + $usage = getrusage(); + $observer->observe($usage['ru_nvcsw']); + }); + + $meter + ->createObservableCounter( + 'process.runtime.php.cpu.involuntary_context_switches', + '{switches}', + 'Number of times the process was preempted involuntarily', + ) + ->observe(static function (ObserverInterface $observer): void { + /** @var array $usage */ + $usage = getrusage(); + $observer->observe($usage['ru_nivcsw']); + }); + } +} diff --git a/src/Metrics/Runtime/src/GarbageCollectionMetrics.php b/src/Metrics/Runtime/src/GarbageCollectionMetrics.php new file mode 100644 index 000000000..eac69cb98 --- /dev/null +++ b/src/Metrics/Runtime/src/GarbageCollectionMetrics.php @@ -0,0 +1,98 @@ +createObservableCounter( + 'process.runtime.php.gc.runs', + '{runs}', + 'Total number of garbage collection cycles run', + ) + ->observe(static function (ObserverInterface $observer): void { + $observer->observe(gc_status()['runs']); + }); + + $meter + ->createObservableCounter( + 'process.runtime.php.gc.collected', + '{objects}', + 'Total number of objects collected by the garbage collector', + ) + ->observe(static function (ObserverInterface $observer): void { + $observer->observe(gc_status()['collected']); + }); + + $meter + ->createObservableGauge( + 'process.runtime.php.gc.threshold', + '{objects}', + 'Number of roots needed to trigger a garbage collection cycle', + ) + ->observe(static function (ObserverInterface $observer): void { + $observer->observe(gc_status()['threshold']); + }); + + $meter + ->createObservableGauge( + 'process.runtime.php.gc.roots', + '{objects}', + 'Current number of objects in the root buffer', + ) + ->observe(static function (ObserverInterface $observer): void { + $observer->observe(gc_status()['roots']); + }); + + // Timing metrics available since PHP 8.3 + if (PHP_VERSION_ID >= 80300) { + $meter + ->createObservableCounter( + 'process.runtime.php.gc.collector_time', + 's', + 'Cumulative time spent in the garbage collector', + ) + ->observe(static function (ObserverInterface $observer): void { + /** @var array $status */ + $status = gc_status(); + // @phan-suppress-next-line PhanTypeInvalidDimOffset, PhanTypeMismatchArgument -- fields added in PHP 8.3 + $observer->observe($status['collector_time']); + }); + + $meter + ->createObservableCounter( + 'process.runtime.php.gc.destructor_time', + 's', + 'Cumulative time spent running destructors during GC', + ) + ->observe(static function (ObserverInterface $observer): void { + /** @var array $status */ + $status = gc_status(); + // @phan-suppress-next-line PhanTypeInvalidDimOffset, PhanTypeMismatchArgument -- fields added in PHP 8.3 + $observer->observe($status['destructor_time']); + }); + + $meter + ->createObservableCounter( + 'process.runtime.php.gc.free_time', + 's', + 'Cumulative time spent freeing memory during GC', + ) + ->observe(static function (ObserverInterface $observer): void { + /** @var array $status */ + $status = gc_status(); + // @phan-suppress-next-line PhanTypeInvalidDimOffset, PhanTypeMismatchArgument -- fields added in PHP 8.3 + $observer->observe($status['free_time']); + }); + } + } +} diff --git a/src/Metrics/Runtime/src/MemoryMetrics.php b/src/Metrics/Runtime/src/MemoryMetrics.php new file mode 100644 index 000000000..9d03501d6 --- /dev/null +++ b/src/Metrics/Runtime/src/MemoryMetrics.php @@ -0,0 +1,66 @@ +createObservableUpDownCounter( + 'process.runtime.php.memory.usage', + 'By', + 'Current memory usage (real allocation from OS)', + ) + ->observe(static function (ObserverInterface $observer): void { + $observer->observe(memory_get_usage(true), ['memory.type' => 'real']); + $observer->observe(memory_get_usage(false), ['memory.type' => 'emalloc']); + }); + + $meter + ->createObservableUpDownCounter( + 'process.runtime.php.memory.peak_usage', + 'By', + 'Peak memory usage since script start', + ) + ->observe(static function (ObserverInterface $observer): void { + $observer->observe(memory_get_peak_usage(true), ['memory.type' => 'real']); + $observer->observe(memory_get_peak_usage(false), ['memory.type' => 'emalloc']); + }); + + $meter + ->createObservableGauge( + 'process.runtime.php.memory.limit', + 'By', + 'Memory limit configured in php.ini (-1 means unlimited)', + ) + ->observe(static function (ObserverInterface $observer): void { + $limit = ini_get('memory_limit'); + $observer->observe(self::parseMemoryLimit((string) $limit)); + }); + } + + public static function parseMemoryLimit(string $limit): int + { + if ($limit === '-1') { + return -1; + } + + $value = (int) $limit; + $unit = strtolower(substr($limit, -1)); + + return match ($unit) { + 'g' => $value * 1024 * 1024 * 1024, + 'm' => $value * 1024 * 1024, + 'k' => $value * 1024, + default => $value, + }; + } +} diff --git a/src/Metrics/Runtime/src/OpcacheMetrics.php b/src/Metrics/Runtime/src/OpcacheMetrics.php new file mode 100644 index 000000000..938d19eb1 --- /dev/null +++ b/src/Metrics/Runtime/src/OpcacheMetrics.php @@ -0,0 +1,167 @@ +createObservableUpDownCounter( + 'process.runtime.php.opcache.memory_used', + 'By', + 'OPcache memory used by cached scripts', + ) + ->observe(static function (ObserverInterface $observer): void { + $status = opcache_get_status(false); + if ($status === false) { + return; + } + $observer->observe((int) $status['memory_usage']['used_memory']); + }); + + $meter + ->createObservableUpDownCounter( + 'process.runtime.php.opcache.memory_free', + 'By', + 'OPcache memory free', + ) + ->observe(static function (ObserverInterface $observer): void { + $status = opcache_get_status(false); + if ($status === false) { + return; + } + $observer->observe((int) $status['memory_usage']['free_memory']); + }); + + $meter + ->createObservableUpDownCounter( + 'process.runtime.php.opcache.memory_wasted', + 'By', + 'OPcache memory wasted (fragmented, not usable without restart)', + ) + ->observe(static function (ObserverInterface $observer): void { + $status = opcache_get_status(false); + if ($status === false) { + return; + } + $observer->observe((int) $status['memory_usage']['wasted_memory']); + }); + + $meter + ->createObservableGauge( + 'process.runtime.php.opcache.hit_rate', + '%', + 'OPcache hit rate percentage', + ) + ->observe(static function (ObserverInterface $observer): void { + $status = opcache_get_status(false); + if ($status === false) { + return; + } + $observer->observe((float) $status['opcache_statistics']['opcache_hit_rate']); + }); + + $meter + ->createObservableCounter( + 'process.runtime.php.opcache.hits', + '{hits}', + 'Total OPcache hits', + ) + ->observe(static function (ObserverInterface $observer): void { + $status = opcache_get_status(false); + if ($status === false) { + return; + } + $observer->observe((int) $status['opcache_statistics']['hits']); + }); + + $meter + ->createObservableCounter( + 'process.runtime.php.opcache.misses', + '{misses}', + 'Total OPcache misses', + ) + ->observe(static function (ObserverInterface $observer): void { + $status = opcache_get_status(false); + if ($status === false) { + return; + } + $observer->observe((int) $status['opcache_statistics']['misses']); + }); + + $meter + ->createObservableGauge( + 'process.runtime.php.opcache.cached_scripts', + '{scripts}', + 'Number of scripts currently cached in OPcache', + ) + ->observe(static function (ObserverInterface $observer): void { + $status = opcache_get_status(false); + if ($status === false) { + return; + } + $observer->observe((int) $status['opcache_statistics']['num_cached_scripts']); + }); + + $meter + ->createObservableUpDownCounter( + 'process.runtime.php.opcache.interned_strings.memory_used', + 'By', + 'Memory used by OPcache interned strings', + ) + ->observe(static function (ObserverInterface $observer): void { + $status = opcache_get_status(false); + if ($status === false) { + return; + } + $observer->observe((int) $status['interned_strings_usage']['used_memory']); + }); + + $meter + ->createObservableUpDownCounter( + 'process.runtime.php.opcache.interned_strings.memory_free', + 'By', + 'Memory free in OPcache interned strings buffer', + ) + ->observe(static function (ObserverInterface $observer): void { + $status = opcache_get_status(false); + if ($status === false) { + return; + } + $observer->observe((int) $status['interned_strings_usage']['free_memory']); + }); + + $meter + ->createObservableGauge( + 'process.runtime.php.opcache.interned_strings.count', + '{strings}', + 'Number of interned strings currently stored in OPcache', + ) + ->observe(static function (ObserverInterface $observer): void { + $status = opcache_get_status(false); + if ($status === false) { + return; + } + $observer->observe((int) $status['interned_strings_usage']['number_of_strings']); + }); + } +} diff --git a/src/Metrics/Runtime/src/RuntimeMetrics.php b/src/Metrics/Runtime/src/RuntimeMetrics.php new file mode 100644 index 000000000..d26dca3d0 --- /dev/null +++ b/src/Metrics/Runtime/src/RuntimeMetrics.php @@ -0,0 +1,64 @@ + */ + private static function disabledGroups(): array + { + $value = getenv(self::ENV_DISABLED_METRICS); + if ($value === false || $value === '') { + return []; + } + + return array_map('trim', explode(',', strtolower($value))); + } +} diff --git a/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php b/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php new file mode 100644 index 000000000..b2669ad67 --- /dev/null +++ b/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php @@ -0,0 +1,75 @@ +assertIsBool(CpuMetrics::isAvailable()); + } + + public function test_register_creates_all_instruments(): void + { + if (!CpuMetrics::isAvailable()) { + $this->markTestSkipped('getrusage() not available on this platform'); + } + + $meter = $this->createMock(MeterInterface::class); + + $meter->expects($this->exactly(3)) + ->method('createObservableCounter') + ->willReturn($this->createMock(ObservableCounterInterface::class)); + + CpuMetrics::register($meter); + } + + public function test_cpu_time_callback_observes_user_and_system_modes(): void + { + if (!CpuMetrics::isAvailable()) { + $this->markTestSkipped('getrusage() not available on this platform'); + } + + $capturedCallback = null; + + $callbackStub = $this->createMock(ObservableCallbackInterface::class); + $counter = $this->createMock(ObservableCounterInterface::class); + $counter->method('observe') + ->willReturnCallback(function (callable $cb) use (&$capturedCallback, $callbackStub): ObservableCallbackInterface { + if ($capturedCallback === null) { + $capturedCallback = $cb; + } + + return $callbackStub; + }); + + $meter = $this->createMock(MeterInterface::class); + $meter->method('createObservableCounter')->willReturn($counter); + + CpuMetrics::register($meter); + + assert($capturedCallback !== null); + + $observer = $this->createMock(ObserverInterface::class); + $observer->expects($this->exactly(2)) + ->method('observe') + ->with( + $this->isType('float'), + $this->logicalOr( + $this->equalTo(['cpu.mode' => 'user']), + $this->equalTo(['cpu.mode' => 'system']), + ), + ); + + $capturedCallback($observer); + } +} diff --git a/src/Metrics/Runtime/tests/Unit/GarbageCollectionMetricsTest.php b/src/Metrics/Runtime/tests/Unit/GarbageCollectionMetricsTest.php new file mode 100644 index 000000000..c93418414 --- /dev/null +++ b/src/Metrics/Runtime/tests/Unit/GarbageCollectionMetricsTest.php @@ -0,0 +1,111 @@ +createMock(MeterInterface::class); + + $expectedCounters = PHP_VERSION_ID >= 80300 ? 5 : 2; + + $meter->expects($this->exactly($expectedCounters)) + ->method('createObservableCounter') + ->willReturn($this->createMock(ObservableCounterInterface::class)); + + $meter->expects($this->exactly(2)) + ->method('createObservableGauge') + ->willReturn($this->createMock(ObservableGaugeInterface::class)); + + GarbageCollectionMetrics::register($meter); + } + + public function test_gc_runs_callback_observes_integer(): void + { + $capturedCallback = null; + + $callbackStub = $this->createMock(ObservableCallbackInterface::class); + $counter = $this->createMock(ObservableCounterInterface::class); + $counter->method('observe') + ->willReturnCallback(function (callable $cb) use (&$capturedCallback, $callbackStub): ObservableCallbackInterface { + if ($capturedCallback === null) { + $capturedCallback = $cb; + } + + return $callbackStub; + }); + + $meter = $this->createMock(MeterInterface::class); + $meter->method('createObservableCounter')->willReturn($counter); + $meter->method('createObservableGauge')->willReturn($this->createMock(ObservableGaugeInterface::class)); + + GarbageCollectionMetrics::register($meter); + + assert($capturedCallback !== null); + + $observer = $this->createMock(ObserverInterface::class); + $observer->expects($this->once()) + ->method('observe') + ->with($this->isType('int')); + + $capturedCallback($observer); + } + + public function test_gc_timing_metrics_registered_on_php83(): void + { + if (PHP_VERSION_ID < 80300) { + $this->markTestSkipped('GC timing metrics require PHP 8.3+'); + } + + $meter = $this->createMock(MeterInterface::class); + $meter->expects($this->exactly(5)) + ->method('createObservableCounter') + ->willReturn($this->createMock(ObservableCounterInterface::class)); + $meter->method('createObservableGauge') + ->willReturn($this->createMock(ObservableGaugeInterface::class)); + + GarbageCollectionMetrics::register($meter); + } + + public function test_gc_timing_callback_observes_float(): void + { + if (PHP_VERSION_ID < 80300) { + $this->markTestSkipped('GC timing metrics require PHP 8.3+'); + } + + $callbacks = []; + $callbackStub = $this->createMock(ObservableCallbackInterface::class); + $counter = $this->createMock(ObservableCounterInterface::class); + $counter->method('observe') + ->willReturnCallback(function (callable $cb) use (&$callbacks, $callbackStub): ObservableCallbackInterface { + $callbacks[] = $cb; + + return $callbackStub; + }); + + $meter = $this->createMock(MeterInterface::class); + $meter->method('createObservableCounter')->willReturn($counter); + $meter->method('createObservableGauge')->willReturn($this->createMock(ObservableGaugeInterface::class)); + + GarbageCollectionMetrics::register($meter); + + // callbacks[2] is collector_time (index 0=runs, 1=collected, 2=collector_time) + $observer = $this->createMock(ObserverInterface::class); + $observer->expects($this->once()) + ->method('observe') + ->with($this->isType('float')); + + $callbacks[2]($observer); + } +} diff --git a/src/Metrics/Runtime/tests/Unit/MemoryMetricsTest.php b/src/Metrics/Runtime/tests/Unit/MemoryMetricsTest.php new file mode 100644 index 000000000..61ac79ae2 --- /dev/null +++ b/src/Metrics/Runtime/tests/Unit/MemoryMetricsTest.php @@ -0,0 +1,75 @@ +createMock(MeterInterface::class); + + $meter->expects($this->exactly(2)) + ->method('createObservableUpDownCounter') + ->willReturn($this->createMock(ObservableUpDownCounterInterface::class)); + + $meter->expects($this->once()) + ->method('createObservableGauge') + ->willReturn($this->createMock(ObservableGaugeInterface::class)); + + MemoryMetrics::register($meter); + } + + public function test_memory_usage_callback_observes_real_and_emalloc(): void + { + $capturedCallbacks = []; + + $callbackStub = $this->createMock(ObservableCallbackInterface::class); + $upDownCounter = $this->createMock(ObservableUpDownCounterInterface::class); + $upDownCounter->method('observe') + ->willReturnCallback(function (callable $cb) use (&$capturedCallbacks, $callbackStub): ObservableCallbackInterface { + $capturedCallbacks[] = $cb; + + return $callbackStub; + }); + + $meter = $this->createMock(MeterInterface::class); + $meter->method('createObservableUpDownCounter')->willReturn($upDownCounter); + $meter->method('createObservableGauge')->willReturn($this->createMock(ObservableGaugeInterface::class)); + + MemoryMetrics::register($meter); + + $observer = $this->createMock(ObserverInterface::class); + $observer->expects($this->exactly(2))->method('observe'); + + $capturedCallbacks[0]($observer); + } + + /** + * @dataProvider memoryLimitProvider + */ + public function test_memory_limit_parsed_correctly(string $limit, int $expected): void + { + $this->assertSame($expected, MemoryMetrics::parseMemoryLimit($limit)); + } + + public static function memoryLimitProvider(): array + { + return [ + 'megabytes' => ['128M', 128 * 1024 * 1024], + 'kilobytes' => ['512K', 512 * 1024], + 'gigabytes' => ['2G', 2 * 1024 * 1024 * 1024], + 'bytes' => ['1048576', 1048576], + 'unlimited' => ['-1', -1], + ]; + } +} diff --git a/src/Metrics/Runtime/tests/Unit/OpcacheMetricsTest.php b/src/Metrics/Runtime/tests/Unit/OpcacheMetricsTest.php new file mode 100644 index 000000000..1aae96072 --- /dev/null +++ b/src/Metrics/Runtime/tests/Unit/OpcacheMetricsTest.php @@ -0,0 +1,43 @@ +assertSame($expected, OpcacheMetrics::isAvailable()); + } + + public function test_register_creates_all_instruments(): void + { + $meter = $this->createMock(MeterInterface::class); + + // 3 memory + 2 interned_strings = 5 UpDownCounters + $meter->expects($this->exactly(5)) + ->method('createObservableUpDownCounter') + ->willReturn($this->createMock(ObservableUpDownCounterInterface::class)); + + // hit_rate + cached_scripts + interned_strings.count = 3 Gauges + $meter->expects($this->exactly(3)) + ->method('createObservableGauge') + ->willReturn($this->createMock(ObservableGaugeInterface::class)); + + // hits + misses = 2 Counters + $meter->expects($this->exactly(2)) + ->method('createObservableCounter') + ->willReturn($this->createMock(ObservableCounterInterface::class)); + + OpcacheMetrics::register($meter); + } +} diff --git a/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php b/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php new file mode 100644 index 000000000..c5c6506a1 --- /dev/null +++ b/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php @@ -0,0 +1,91 @@ +createMock(MeterInterface::class); + $meter->method('createObservableUpDownCounter') + ->willReturn($this->createMock(ObservableUpDownCounterInterface::class)); + $meter->method('createObservableGauge') + ->willReturn($this->createMock(ObservableGaugeInterface::class)); + $meter->method('createObservableCounter') + ->willReturn($this->createMock(ObservableCounterInterface::class)); + + $this->expectNotToPerformAssertions(); + RuntimeMetrics::register($meter); + } + + public function test_instrumentation_name_is_non_empty_string(): void + { + $this->assertNotEmpty(RuntimeMetrics::getInstrumentationName()); + } + + public function test_disabled_memory_group_skips_memory_instruments(): void + { + putenv('OTEL_PHP_DISABLED_METRICS=memory'); + + $meter = $this->createMock(MeterInterface::class); + $meter->expects($this->never())->method('createObservableUpDownCounter'); + $meter->method('createObservableGauge') + ->willReturn($this->createMock(ObservableGaugeInterface::class)); + $meter->method('createObservableCounter') + ->willReturn($this->createMock(ObservableCounterInterface::class)); + + RuntimeMetrics::register($meter); + } + + public function test_disabled_gc_group_skips_gc_instruments(): void + { + putenv('OTEL_PHP_DISABLED_METRICS=gc,cpu'); + + $meter = $this->createMock(MeterInterface::class); + $meter->method('createObservableUpDownCounter') + ->willReturn($this->createMock(ObservableUpDownCounterInterface::class)); + $meter->method('createObservableGauge') + ->willReturn($this->createMock(ObservableGaugeInterface::class)); + $meter->expects($this->never())->method('createObservableCounter'); + + RuntimeMetrics::register($meter); + } + + public function test_disabled_multiple_groups_via_comma_list(): void + { + putenv('OTEL_PHP_DISABLED_METRICS=memory,gc,opcache,cpu'); + + $meter = $this->createMock(MeterInterface::class); + $meter->expects($this->never())->method('createObservableUpDownCounter'); + $meter->expects($this->never())->method('createObservableCounter'); + $meter->expects($this->never())->method('createObservableGauge'); + + RuntimeMetrics::register($meter); + } + + public function test_disabled_env_is_case_insensitive(): void + { + putenv('OTEL_PHP_DISABLED_METRICS=Memory,GC,Opcache,CPU'); + + $meter = $this->createMock(MeterInterface::class); + $meter->expects($this->never())->method('createObservableUpDownCounter'); + $meter->expects($this->never())->method('createObservableCounter'); + $meter->expects($this->never())->method('createObservableGauge'); + + RuntimeMetrics::register($meter); + } +} From 2f7d894abe0e7ff2eff10ea7e64f3fd13ff7e8f4 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Mon, 29 Jun 2026 12:37:44 +0200 Subject: [PATCH 02/11] feat(metrics/runtime): use phan 6.x for PHP 8.5 compatibility --- src/Metrics/Runtime/bin/create-phan-stub.php | 15 --------------- src/Metrics/Runtime/composer.json | 6 +----- 2 files changed, 1 insertion(+), 20 deletions(-) delete mode 100644 src/Metrics/Runtime/bin/create-phan-stub.php diff --git a/src/Metrics/Runtime/bin/create-phan-stub.php b/src/Metrics/Runtime/bin/create-phan-stub.php deleted file mode 100644 index 119e9acca..000000000 --- a/src/Metrics/Runtime/bin/create-phan-stub.php +++ /dev/null @@ -1,15 +0,0 @@ - Date: Mon, 29 Jun 2026 13:40:43 +0200 Subject: [PATCH 03/11] feat(metrics/runtime): composer fix --- src/Metrics/Runtime/composer.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Metrics/Runtime/composer.json b/src/Metrics/Runtime/composer.json index 00a86207e..cf4c7e82c 100644 --- a/src/Metrics/Runtime/composer.json +++ b/src/Metrics/Runtime/composer.json @@ -15,7 +15,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3", - "phan/phan": "^5|^6.0", + "phan/phan": "^5.0|^6.0", "phpstan/phpstan": "^1.1", "phpstan/phpstan-phpunit": "^1.0", "psalm/plugin-phpunit": "^0.19", @@ -37,7 +37,8 @@ }, "config": { "allow-plugins": { - "php-http/discovery": false + "php-http/discovery": false, + "tbachert/spi": false } } } From 9bff3f415999c01993faadbb494ff8d723296632 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Tue, 30 Jun 2026 15:05:45 +0200 Subject: [PATCH 04/11] feat(metrics/runtime): address PR review feedback - Use batchObserve() in CpuMetrics, GarbageCollectionMetrics and OpcacheMetrics so getrusage()/gc_status()/opcache_get_status() are called only once per collection cycle - Handle missing ru_nvcsw/ru_nivcsw keys on OSes that omit them (macOS) - Fix instrument units to singular form per OTel semconv: {run}, {object}, {switch}, {hit}, {miss}, {script}, {string} - Replace custom parseMemoryLimit() with ini_parse_quantity(); add symfony/polyfill-php82 to support PHP 8.1 - Register via Instrumentation SPI instead of autoload.files so metrics are registered after SdkAutoloader initialises Globals; remove _register.php - Use separate meters per group (io.opentelemetry.contrib.php.runtime. {group}) to allow disabling via OTEL_CONFIG_FILE meter configurators or views; keep OTEL_PHP_DISABLED_METRICS as a lightweight fallback - Depend on open-telemetry/api ^1.1 instead of sdk; move sdk to require-dev --- src/Metrics/Runtime/README.md | 57 +++-- src/Metrics/Runtime/_register.php | 20 -- src/Metrics/Runtime/composer.json | 20 +- src/Metrics/Runtime/psalm.xml.dist | 6 + src/Metrics/Runtime/src/CpuMetrics.php | 68 +++--- .../Runtime/src/GarbageCollectionMetrics.php | 139 ++++++----- src/Metrics/Runtime/src/MemoryMetrics.php | 18 +- src/Metrics/Runtime/src/OpcacheMetrics.php | 221 +++++++----------- src/Metrics/Runtime/src/RuntimeMetrics.php | 40 ++-- .../src/RuntimeMetricsInstrumentation.php | 18 ++ .../Runtime/tests/Unit/CpuMetricsTest.php | 30 +-- .../Unit/GarbageCollectionMetricsTest.php | 66 +++--- .../Runtime/tests/Unit/OpcacheMetricsTest.php | 5 + .../Runtime/tests/Unit/RuntimeMetricsTest.php | 69 +++--- 14 files changed, 386 insertions(+), 391 deletions(-) delete mode 100644 src/Metrics/Runtime/_register.php create mode 100644 src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php diff --git a/src/Metrics/Runtime/README.md b/src/Metrics/Runtime/README.md index 152f89e6c..7944f4e59 100644 --- a/src/Metrics/Runtime/README.md +++ b/src/Metrics/Runtime/README.md @@ -13,7 +13,7 @@ Collects PHP runtime metrics — memory, garbage collection, OPcache, and CPU us ## Requirements * PHP 8.1+ -* OpenTelemetry SDK +* OpenTelemetry API ## Installation @@ -25,16 +25,22 @@ composer require open-telemetry/opentelemetry-metrics-runtime ### Automatic (recommended) -Metrics are registered automatically via Composer's autoload mechanism as long as the OpenTelemetry SDK is configured. No additional code is required. +The package registers itself via [SPI](https://github.com/Nevay/spi) as an `Instrumentation`. When the OpenTelemetry SDK calls `SdkAutoloader::autoload()`, metrics are registered automatically with the configured `MeterProvider`. No additional code is required. + +```shell +OTEL_PHP_AUTOLOAD_ENABLED=true +OTEL_METRICS_EXPORTER=otlp +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +``` ### Manual -If you need to control when metrics are registered or use a specific meter: +If you need to control when metrics are registered: ```php use OpenTelemetry\Contrib\Metrics\Runtime\RuntimeMetrics; -RuntimeMetrics::register($meterProvider->getMeter('my-app')); +RuntimeMetrics::register($meterProvider); ``` ## Metrics @@ -51,10 +57,10 @@ RuntimeMetrics::register($meterProvider->getMeter('my-app')); | Metric | Type | Unit | Description | |--------|------|------|-------------| -| `process.runtime.php.gc.runs` | Counter | `{runs}` | Total number of GC cycles run. | -| `process.runtime.php.gc.collected` | Counter | `{objects}` | Total number of objects collected. | -| `process.runtime.php.gc.roots` | Gauge | `{objects}` | Current number of objects in the root buffer. | -| `process.runtime.php.gc.threshold` | Gauge | `{objects}` | Number of roots required to trigger a GC cycle. | +| `process.runtime.php.gc.runs` | Counter | `{run}` | Total number of GC cycles run. | +| `process.runtime.php.gc.collected` | Counter | `{object}` | Total number of objects collected. | +| `process.runtime.php.gc.roots` | Gauge | `{object}` | Current number of objects in the root buffer. | +| `process.runtime.php.gc.threshold` | Gauge | `{object}` | Number of roots required to trigger a GC cycle. | | `process.runtime.php.gc.collector_time` | Counter | `s` | Cumulative time spent in the GC collector. **PHP 8.3+** | | `process.runtime.php.gc.destructor_time` | Counter | `s` | Cumulative time spent running destructors during GC. **PHP 8.3+** | | `process.runtime.php.gc.free_time` | Counter | `s` | Cumulative time spent freeing memory during GC. **PHP 8.3+** | @@ -68,23 +74,23 @@ Registered only when OPcache is enabled (`opcache.enable=1`). In CLI context, al | `process.runtime.php.opcache.memory_used` | UpDownCounter | `By` | Memory used by cached scripts. | | `process.runtime.php.opcache.memory_free` | UpDownCounter | `By` | Free memory in the OPcache buffer. | | `process.runtime.php.opcache.memory_wasted` | UpDownCounter | `By` | Wasted (fragmented) memory — requires restart to reclaim. | -| `process.runtime.php.opcache.hits` | Counter | `{hits}` | Total cache hits. | -| `process.runtime.php.opcache.misses` | Counter | `{misses}` | Total cache misses. | +| `process.runtime.php.opcache.hits` | Counter | `{hit}` | Total cache hits. | +| `process.runtime.php.opcache.misses` | Counter | `{miss}` | Total cache misses. | | `process.runtime.php.opcache.hit_rate` | Gauge | `%` | Cache hit rate percentage. | -| `process.runtime.php.opcache.cached_scripts` | Gauge | `{scripts}` | Number of scripts currently in cache. | +| `process.runtime.php.opcache.cached_scripts` | Gauge | `{script}` | Number of scripts currently in cache. | | `process.runtime.php.opcache.interned_strings.memory_used` | UpDownCounter | `By` | Memory used by interned strings. | | `process.runtime.php.opcache.interned_strings.memory_free` | UpDownCounter | `By` | Free memory in the interned strings buffer. | -| `process.runtime.php.opcache.interned_strings.count` | Gauge | `{strings}` | Number of interned strings currently stored. | +| `process.runtime.php.opcache.interned_strings.count` | Gauge | `{string}` | Number of interned strings currently stored. | ### CPU (`cpu`) -Registered only on platforms where `getrusage()` is available (Linux, macOS). Not available on Windows. +Registered only on platforms where `getrusage()` is available (Linux, macOS, Windows). | Metric | Type | Unit | Description | |--------|------|------|-------------| | `process.cpu.time` | Counter | `s` | CPU time consumed. Reported for `user` and `system` modes via the `cpu.mode` attribute. | -| `process.runtime.php.cpu.voluntary_context_switches` | Counter | `{switches}` | Number of times the process voluntarily yielded the CPU. | -| `process.runtime.php.cpu.involuntary_context_switches` | Counter | `{switches}` | Number of times the process was preempted involuntarily. | +| `process.runtime.php.cpu.voluntary_context_switches` | Counter | `{switch}` | Number of times the process voluntarily yielded the CPU. | +| `process.runtime.php.cpu.involuntary_context_switches` | Counter | `{switch}` | Number of times the process was preempted involuntarily. | ## Configuration @@ -96,7 +102,7 @@ OTEL_PHP_DISABLED_INSTRUMENTATIONS=metrics-runtime ### Disable individual metric groups -Use `OTEL_PHP_DISABLED_METRICS` with a comma-separated list of group names. Values are case-insensitive. +**Option 1: via `OTEL_PHP_DISABLED_METRICS`** — lightweight, no config file needed: ```shell # Disable OPcache and CPU metrics only @@ -106,7 +112,24 @@ OTEL_PHP_DISABLED_METRICS=opcache,cpu OTEL_PHP_DISABLED_METRICS=gc ``` -Available group names: `memory`, `gc`, `opcache`, `cpu`. +Available group names: `memory`, `gc`, `opcache`, `cpu`. Values are case-insensitive. + +**Option 2: via `OTEL_CONFIG_FILE`** — standard OTel SDK configuration, supports fine-grained control (e.g. filtering by attributes). Each group uses its own meter named `io.opentelemetry.contrib.php.runtime.{group}`, which can be targeted with meter configurators or views: + +```yaml +# otel-config.yaml +meter_provider: + views: + - selector: + meter_name: io.opentelemetry.contrib.php.runtime.opcache + stream: + aggregation: + drop: +``` + +```shell +OTEL_CONFIG_FILE=/path/to/otel-config.yaml +``` ## Maintainer diff --git a/src/Metrics/Runtime/_register.php b/src/Metrics/Runtime/_register.php deleted file mode 100644 index a580ef55b..000000000 --- a/src/Metrics/Runtime/_register.php +++ /dev/null @@ -1,20 +0,0 @@ -getMeter( - RuntimeMetrics::getInstrumentationName(), - null, - Version::VERSION_1_38_0->url(), - ) -); diff --git a/src/Metrics/Runtime/composer.json b/src/Metrics/Runtime/composer.json index cf4c7e82c..0be3ffdc9 100644 --- a/src/Metrics/Runtime/composer.json +++ b/src/Metrics/Runtime/composer.json @@ -10,10 +10,12 @@ "prefer-stable": true, "require": { "php": "^8.1", - "open-telemetry/sdk": "^1.0", - "open-telemetry/sem-conv": "^1.38" + "open-telemetry/api": "^1.1", + "open-telemetry/sem-conv": "^1.38", + "symfony/polyfill-php82": "^1.0" }, "require-dev": { + "open-telemetry/sdk": "^1.0", "friendsofphp/php-cs-fixer": "^3", "phan/phan": "^5.0|^6.0", "phpstan/phpstan": "^1.1", @@ -25,10 +27,7 @@ "autoload": { "psr-4": { "OpenTelemetry\\Contrib\\Metrics\\Runtime\\": "src/" - }, - "files": [ - "_register.php" - ] + } }, "autoload-dev": { "psr-4": { @@ -38,7 +37,14 @@ "config": { "allow-plugins": { "php-http/discovery": false, - "tbachert/spi": false + "tbachert/spi": true + } + }, + "extra": { + "spi": { + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\Instrumentation": [ + "OpenTelemetry\\Contrib\\Metrics\\Runtime\\RuntimeMetricsInstrumentation" + ] } } } diff --git a/src/Metrics/Runtime/psalm.xml.dist b/src/Metrics/Runtime/psalm.xml.dist index f79b91604..be4e36bac 100644 --- a/src/Metrics/Runtime/psalm.xml.dist +++ b/src/Metrics/Runtime/psalm.xml.dist @@ -15,5 +15,11 @@ + + + + + + diff --git a/src/Metrics/Runtime/src/CpuMetrics.php b/src/Metrics/Runtime/src/CpuMetrics.php index bcad39ff0..de64af457 100644 --- a/src/Metrics/Runtime/src/CpuMetrics.php +++ b/src/Metrics/Runtime/src/CpuMetrics.php @@ -20,45 +20,45 @@ public static function register(MeterInterface $meter): void { // getrusage() times in microseconds (ru_utime.tv_usec, ru_stime.tv_usec) // combined with seconds (ru_utime.tv_sec, ru_stime.tv_sec) - $meter - ->createObservableCounter( - 'process.cpu.time', - 's', - 'CPU time consumed by the process', - ) - ->observe(static function (ObserverInterface $observer): void { - /** @var array $usage */ - $usage = getrusage(); + $cpuTime = $meter->createObservableCounter( + 'process.cpu.time', + 's', + 'CPU time consumed by the process', + ); - $userSeconds = (float) $usage['ru_utime.tv_sec'] + (float) $usage['ru_utime.tv_usec'] / 1_000_000.0; - $systemSeconds = (float) $usage['ru_stime.tv_sec'] + (float) $usage['ru_stime.tv_usec'] / 1_000_000.0; + $voluntaryCS = $meter->createObservableCounter( + 'process.runtime.php.cpu.voluntary_context_switches', + '{switch}', + 'Number of times the process voluntarily yielded the CPU', + ); - $observer->observe($userSeconds, ['cpu.mode' => 'user']); - $observer->observe($systemSeconds, ['cpu.mode' => 'system']); - }); + $involuntaryCS = $meter->createObservableCounter( + 'process.runtime.php.cpu.involuntary_context_switches', + '{switch}', + 'Number of times the process was preempted involuntarily', + ); - $meter - ->createObservableCounter( - 'process.runtime.php.cpu.voluntary_context_switches', - '{switches}', - 'Number of times the process voluntarily yielded the CPU', - ) - ->observe(static function (ObserverInterface $observer): void { + $meter->batchObserve( + static function ( + ObserverInterface $cpuObserver, + ObserverInterface $vcsObserver, + ObserverInterface $ivcsObserver, + ): void { /** @var array $usage */ $usage = getrusage(); - $observer->observe($usage['ru_nvcsw']); - }); - $meter - ->createObservableCounter( - 'process.runtime.php.cpu.involuntary_context_switches', - '{switches}', - 'Number of times the process was preempted involuntarily', - ) - ->observe(static function (ObserverInterface $observer): void { - /** @var array $usage */ - $usage = getrusage(); - $observer->observe($usage['ru_nivcsw']); - }); + $userSeconds = (float) $usage['ru_utime.tv_sec'] + (float) $usage['ru_utime.tv_usec'] / 1_000_000.0; + $systemSeconds = (float) $usage['ru_stime.tv_sec'] + (float) $usage['ru_stime.tv_usec'] / 1_000_000.0; + + $cpuObserver->observe($userSeconds, ['cpu.mode' => 'user']); + $cpuObserver->observe($systemSeconds, ['cpu.mode' => 'system']); + // ru_nvcsw and ru_nivcsw may be absent on some operating systems (e.g. macOS) + $vcsObserver->observe($usage['ru_nvcsw'] ?? 0); + $ivcsObserver->observe($usage['ru_nivcsw'] ?? 0); + }, + $cpuTime, + $voluntaryCS, + $involuntaryCS, + ); } } diff --git a/src/Metrics/Runtime/src/GarbageCollectionMetrics.php b/src/Metrics/Runtime/src/GarbageCollectionMetrics.php index eac69cb98..5e65d5d9f 100644 --- a/src/Metrics/Runtime/src/GarbageCollectionMetrics.php +++ b/src/Metrics/Runtime/src/GarbageCollectionMetrics.php @@ -13,86 +13,83 @@ class GarbageCollectionMetrics public static function register(MeterInterface $meter): void { - $meter - ->createObservableCounter( - 'process.runtime.php.gc.runs', - '{runs}', - 'Total number of garbage collection cycles run', - ) - ->observe(static function (ObserverInterface $observer): void { - $observer->observe(gc_status()['runs']); - }); + $runs = $meter->createObservableCounter( + 'process.runtime.php.gc.runs', + '{run}', + 'Total number of garbage collection cycles run', + ); + $collected = $meter->createObservableCounter( + 'process.runtime.php.gc.collected', + '{object}', + 'Total number of objects collected by the garbage collector', + ); + $threshold = $meter->createObservableGauge( + 'process.runtime.php.gc.threshold', + '{object}', + 'Number of roots needed to trigger a garbage collection cycle', + ); + $roots = $meter->createObservableGauge( + 'process.runtime.php.gc.roots', + '{object}', + 'Current number of objects in the root buffer', + ); - $meter - ->createObservableCounter( - 'process.runtime.php.gc.collected', - '{objects}', - 'Total number of objects collected by the garbage collector', - ) - ->observe(static function (ObserverInterface $observer): void { - $observer->observe(gc_status()['collected']); - }); - - $meter - ->createObservableGauge( - 'process.runtime.php.gc.threshold', - '{objects}', - 'Number of roots needed to trigger a garbage collection cycle', - ) - ->observe(static function (ObserverInterface $observer): void { - $observer->observe(gc_status()['threshold']); - }); - - $meter - ->createObservableGauge( - 'process.runtime.php.gc.roots', - '{objects}', - 'Current number of objects in the root buffer', - ) - ->observe(static function (ObserverInterface $observer): void { - $observer->observe(gc_status()['roots']); - }); + $meter->batchObserve( + static function ( + ObserverInterface $runsObs, + ObserverInterface $collectedObs, + ObserverInterface $thresholdObs, + ObserverInterface $rootsObs, + ): void { + $status = gc_status(); + $runsObs->observe($status['runs']); + $collectedObs->observe($status['collected']); + $thresholdObs->observe($status['threshold']); + $rootsObs->observe($status['roots']); + }, + $runs, + $collected, + $threshold, + $roots, + ); // Timing metrics available since PHP 8.3 if (PHP_VERSION_ID >= 80300) { - $meter - ->createObservableCounter( - 'process.runtime.php.gc.collector_time', - 's', - 'Cumulative time spent in the garbage collector', - ) - ->observe(static function (ObserverInterface $observer): void { - /** @var array $status */ - $status = gc_status(); - // @phan-suppress-next-line PhanTypeInvalidDimOffset, PhanTypeMismatchArgument -- fields added in PHP 8.3 - $observer->observe($status['collector_time']); - }); + $collectorTime = $meter->createObservableCounter( + 'process.runtime.php.gc.collector_time', + 's', + 'Cumulative time spent in the garbage collector', + ); + $destructorTime = $meter->createObservableCounter( + 'process.runtime.php.gc.destructor_time', + 's', + 'Cumulative time spent running destructors during GC', + ); + $freeTime = $meter->createObservableCounter( + 'process.runtime.php.gc.free_time', + 's', + 'Cumulative time spent freeing memory during GC', + ); - $meter - ->createObservableCounter( - 'process.runtime.php.gc.destructor_time', - 's', - 'Cumulative time spent running destructors during GC', - ) - ->observe(static function (ObserverInterface $observer): void { + $meter->batchObserve( + static function ( + ObserverInterface $collectorObs, + ObserverInterface $destructorObs, + ObserverInterface $freeObs, + ): void { /** @var array $status */ $status = gc_status(); // @phan-suppress-next-line PhanTypeInvalidDimOffset, PhanTypeMismatchArgument -- fields added in PHP 8.3 - $observer->observe($status['destructor_time']); - }); - - $meter - ->createObservableCounter( - 'process.runtime.php.gc.free_time', - 's', - 'Cumulative time spent freeing memory during GC', - ) - ->observe(static function (ObserverInterface $observer): void { - /** @var array $status */ - $status = gc_status(); + $collectorObs->observe($status['collector_time']); + // @phan-suppress-next-line PhanTypeInvalidDimOffset, PhanTypeMismatchArgument -- fields added in PHP 8.3 + $destructorObs->observe($status['destructor_time']); // @phan-suppress-next-line PhanTypeInvalidDimOffset, PhanTypeMismatchArgument -- fields added in PHP 8.3 - $observer->observe($status['free_time']); - }); + $freeObs->observe($status['free_time']); + }, + $collectorTime, + $destructorTime, + $freeTime, + ); } } } diff --git a/src/Metrics/Runtime/src/MemoryMetrics.php b/src/Metrics/Runtime/src/MemoryMetrics.php index 9d03501d6..53187ebf5 100644 --- a/src/Metrics/Runtime/src/MemoryMetrics.php +++ b/src/Metrics/Runtime/src/MemoryMetrics.php @@ -42,25 +42,13 @@ public static function register(MeterInterface $meter): void 'Memory limit configured in php.ini (-1 means unlimited)', ) ->observe(static function (ObserverInterface $observer): void { - $limit = ini_get('memory_limit'); - $observer->observe(self::parseMemoryLimit((string) $limit)); + $limit = ini_get('memory_limit') ?: '-1'; + $observer->observe(ini_parse_quantity($limit)); }); } public static function parseMemoryLimit(string $limit): int { - if ($limit === '-1') { - return -1; - } - - $value = (int) $limit; - $unit = strtolower(substr($limit, -1)); - - return match ($unit) { - 'g' => $value * 1024 * 1024 * 1024, - 'm' => $value * 1024 * 1024, - 'k' => $value * 1024, - default => $value, - }; + return ini_parse_quantity($limit !== '' ? $limit : '-1'); } } diff --git a/src/Metrics/Runtime/src/OpcacheMetrics.php b/src/Metrics/Runtime/src/OpcacheMetrics.php index 938d19eb1..c6c370f6e 100644 --- a/src/Metrics/Runtime/src/OpcacheMetrics.php +++ b/src/Metrics/Runtime/src/OpcacheMetrics.php @@ -24,144 +24,95 @@ public static function isAvailable(): bool public static function register(MeterInterface $meter): void { - $meter - ->createObservableUpDownCounter( - 'process.runtime.php.opcache.memory_used', - 'By', - 'OPcache memory used by cached scripts', - ) - ->observe(static function (ObserverInterface $observer): void { + $memoryUsed = $meter->createObservableUpDownCounter( + 'process.runtime.php.opcache.memory_used', + 'By', + 'OPcache memory used by cached scripts', + ); + $memoryFree = $meter->createObservableUpDownCounter( + 'process.runtime.php.opcache.memory_free', + 'By', + 'OPcache memory free', + ); + $memoryWasted = $meter->createObservableUpDownCounter( + 'process.runtime.php.opcache.memory_wasted', + 'By', + 'OPcache memory wasted (fragmented, not usable without restart)', + ); + $hitRate = $meter->createObservableGauge( + 'process.runtime.php.opcache.hit_rate', + '%', + 'OPcache hit rate percentage', + ); + $hits = $meter->createObservableCounter( + 'process.runtime.php.opcache.hits', + '{hit}', + 'Total OPcache hits', + ); + $misses = $meter->createObservableCounter( + 'process.runtime.php.opcache.misses', + '{miss}', + 'Total OPcache misses', + ); + $cachedScripts = $meter->createObservableGauge( + 'process.runtime.php.opcache.cached_scripts', + '{script}', + 'Number of scripts currently cached in OPcache', + ); + $internedStringsMemoryUsed = $meter->createObservableUpDownCounter( + 'process.runtime.php.opcache.interned_strings.memory_used', + 'By', + 'Memory used by OPcache interned strings', + ); + $internedStringsMemoryFree = $meter->createObservableUpDownCounter( + 'process.runtime.php.opcache.interned_strings.memory_free', + 'By', + 'Memory free in OPcache interned strings buffer', + ); + $internedStringsCount = $meter->createObservableGauge( + 'process.runtime.php.opcache.interned_strings.count', + '{string}', + 'Number of interned strings currently stored in OPcache', + ); + + $meter->batchObserve( + static function ( + ObserverInterface $memUsedObs, + ObserverInterface $memFreeObs, + ObserverInterface $memWastedObs, + ObserverInterface $hitRateObs, + ObserverInterface $hitsObs, + ObserverInterface $missesObs, + ObserverInterface $cachedScriptsObs, + ObserverInterface $internedMemUsedObs, + ObserverInterface $internedMemFreeObs, + ObserverInterface $internedCountObs, + ): void { $status = opcache_get_status(false); if ($status === false) { return; } - $observer->observe((int) $status['memory_usage']['used_memory']); - }); - - $meter - ->createObservableUpDownCounter( - 'process.runtime.php.opcache.memory_free', - 'By', - 'OPcache memory free', - ) - ->observe(static function (ObserverInterface $observer): void { - $status = opcache_get_status(false); - if ($status === false) { - return; - } - $observer->observe((int) $status['memory_usage']['free_memory']); - }); - - $meter - ->createObservableUpDownCounter( - 'process.runtime.php.opcache.memory_wasted', - 'By', - 'OPcache memory wasted (fragmented, not usable without restart)', - ) - ->observe(static function (ObserverInterface $observer): void { - $status = opcache_get_status(false); - if ($status === false) { - return; - } - $observer->observe((int) $status['memory_usage']['wasted_memory']); - }); - - $meter - ->createObservableGauge( - 'process.runtime.php.opcache.hit_rate', - '%', - 'OPcache hit rate percentage', - ) - ->observe(static function (ObserverInterface $observer): void { - $status = opcache_get_status(false); - if ($status === false) { - return; - } - $observer->observe((float) $status['opcache_statistics']['opcache_hit_rate']); - }); - - $meter - ->createObservableCounter( - 'process.runtime.php.opcache.hits', - '{hits}', - 'Total OPcache hits', - ) - ->observe(static function (ObserverInterface $observer): void { - $status = opcache_get_status(false); - if ($status === false) { - return; - } - $observer->observe((int) $status['opcache_statistics']['hits']); - }); - - $meter - ->createObservableCounter( - 'process.runtime.php.opcache.misses', - '{misses}', - 'Total OPcache misses', - ) - ->observe(static function (ObserverInterface $observer): void { - $status = opcache_get_status(false); - if ($status === false) { - return; - } - $observer->observe((int) $status['opcache_statistics']['misses']); - }); - - $meter - ->createObservableGauge( - 'process.runtime.php.opcache.cached_scripts', - '{scripts}', - 'Number of scripts currently cached in OPcache', - ) - ->observe(static function (ObserverInterface $observer): void { - $status = opcache_get_status(false); - if ($status === false) { - return; - } - $observer->observe((int) $status['opcache_statistics']['num_cached_scripts']); - }); - - $meter - ->createObservableUpDownCounter( - 'process.runtime.php.opcache.interned_strings.memory_used', - 'By', - 'Memory used by OPcache interned strings', - ) - ->observe(static function (ObserverInterface $observer): void { - $status = opcache_get_status(false); - if ($status === false) { - return; - } - $observer->observe((int) $status['interned_strings_usage']['used_memory']); - }); - - $meter - ->createObservableUpDownCounter( - 'process.runtime.php.opcache.interned_strings.memory_free', - 'By', - 'Memory free in OPcache interned strings buffer', - ) - ->observe(static function (ObserverInterface $observer): void { - $status = opcache_get_status(false); - if ($status === false) { - return; - } - $observer->observe((int) $status['interned_strings_usage']['free_memory']); - }); - - $meter - ->createObservableGauge( - 'process.runtime.php.opcache.interned_strings.count', - '{strings}', - 'Number of interned strings currently stored in OPcache', - ) - ->observe(static function (ObserverInterface $observer): void { - $status = opcache_get_status(false); - if ($status === false) { - return; - } - $observer->observe((int) $status['interned_strings_usage']['number_of_strings']); - }); + $memUsedObs->observe((int) $status['memory_usage']['used_memory']); + $memFreeObs->observe((int) $status['memory_usage']['free_memory']); + $memWastedObs->observe((int) $status['memory_usage']['wasted_memory']); + $hitRateObs->observe((float) $status['opcache_statistics']['opcache_hit_rate']); + $hitsObs->observe((int) $status['opcache_statistics']['hits']); + $missesObs->observe((int) $status['opcache_statistics']['misses']); + $cachedScriptsObs->observe((int) $status['opcache_statistics']['num_cached_scripts']); + $internedMemUsedObs->observe((int) $status['interned_strings_usage']['used_memory']); + $internedMemFreeObs->observe((int) $status['interned_strings_usage']['free_memory']); + $internedCountObs->observe((int) $status['interned_strings_usage']['number_of_strings']); + }, + $memoryUsed, + $memoryFree, + $memoryWasted, + $hitRate, + $hits, + $misses, + $cachedScripts, + $internedStringsMemoryUsed, + $internedStringsMemoryFree, + $internedStringsCount, + ); } } diff --git a/src/Metrics/Runtime/src/RuntimeMetrics.php b/src/Metrics/Runtime/src/RuntimeMetrics.php index d26dca3d0..75218d46b 100644 --- a/src/Metrics/Runtime/src/RuntimeMetrics.php +++ b/src/Metrics/Runtime/src/RuntimeMetrics.php @@ -4,7 +4,8 @@ namespace OpenTelemetry\Contrib\Metrics\Runtime; -use OpenTelemetry\API\Metrics\MeterInterface; +use OpenTelemetry\API\Metrics\MeterProviderInterface; +use OpenTelemetry\SemConv\Version; class RuntimeMetrics { @@ -14,9 +15,12 @@ class RuntimeMetrics private const ENV_DISABLED_METRICS = 'OTEL_PHP_DISABLED_METRICS'; /** - * Register available runtime metric observers on the given meter. + * Register available runtime metric observers on the given meter provider. * - * Individual groups can be disabled via the OTEL_PHP_DISABLED_METRICS + * Each group gets its own meter (io.opentelemetry.contrib.php.runtime.{group}), + * which allows disabling them via SDK meter configurators or views in OTEL_CONFIG_FILE. + * + * Individual groups can also be disabled via the OTEL_PHP_DISABLED_METRICS * environment variable as a comma-separated list of group names: * OTEL_PHP_DISABLED_METRICS=opcache,cpu * @@ -25,24 +29,30 @@ class RuntimeMetrics * OPcache and CPU metrics are also skipped when the underlying PHP * functions are unavailable, regardless of this setting. */ - public static function register(MeterInterface $meter): void + public static function register(MeterProviderInterface $meterProvider): void { + $schemaUrl = Version::VERSION_1_38_0->url(); $disabled = self::disabledGroups(); - if (!in_array(MemoryMetrics::GROUP, $disabled, true)) { - MemoryMetrics::register($meter); + if (!\in_array(MemoryMetrics::GROUP, $disabled, true)) { + MemoryMetrics::register( + $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.memory', null, $schemaUrl), + ); } - - if (!in_array(GarbageCollectionMetrics::GROUP, $disabled, true)) { - GarbageCollectionMetrics::register($meter); + if (!\in_array(GarbageCollectionMetrics::GROUP, $disabled, true)) { + GarbageCollectionMetrics::register( + $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.gc', null, $schemaUrl), + ); } - - if (!in_array(OpcacheMetrics::GROUP, $disabled, true) && OpcacheMetrics::isAvailable()) { - OpcacheMetrics::register($meter); + if (!\in_array(OpcacheMetrics::GROUP, $disabled, true) && OpcacheMetrics::isAvailable()) { + OpcacheMetrics::register( + $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.opcache', null, $schemaUrl), + ); } - - if (!in_array(CpuMetrics::GROUP, $disabled, true) && CpuMetrics::isAvailable()) { - CpuMetrics::register($meter); + if (!\in_array(CpuMetrics::GROUP, $disabled, true) && CpuMetrics::isAvailable()) { + CpuMetrics::register( + $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.cpu', null, $schemaUrl), + ); } } diff --git a/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php b/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php new file mode 100644 index 000000000..e8b873b60 --- /dev/null +++ b/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php @@ -0,0 +1,18 @@ +meterProvider); + } +} diff --git a/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php b/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php index b2669ad67..c884cfa80 100644 --- a/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php +++ b/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php @@ -30,6 +30,10 @@ public function test_register_creates_all_instruments(): void ->method('createObservableCounter') ->willReturn($this->createMock(ObservableCounterInterface::class)); + $meter->expects($this->once()) + ->method('batchObserve') + ->willReturn($this->createMock(ObservableCallbackInterface::class)); + CpuMetrics::register($meter); } @@ -41,26 +45,21 @@ public function test_cpu_time_callback_observes_user_and_system_modes(): void $capturedCallback = null; - $callbackStub = $this->createMock(ObservableCallbackInterface::class); - $counter = $this->createMock(ObservableCounterInterface::class); - $counter->method('observe') - ->willReturnCallback(function (callable $cb) use (&$capturedCallback, $callbackStub): ObservableCallbackInterface { - if ($capturedCallback === null) { - $capturedCallback = $cb; - } + $meter = $this->createMock(MeterInterface::class); + $meter->method('createObservableCounter')->willReturn($this->createMock(ObservableCounterInterface::class)); + $meter->method('batchObserve') + ->willReturnCallback(function (callable $cb) use (&$capturedCallback): ObservableCallbackInterface { + $capturedCallback = $cb; - return $callbackStub; + return $this->createMock(ObservableCallbackInterface::class); }); - $meter = $this->createMock(MeterInterface::class); - $meter->method('createObservableCounter')->willReturn($counter); - CpuMetrics::register($meter); assert($capturedCallback !== null); - $observer = $this->createMock(ObserverInterface::class); - $observer->expects($this->exactly(2)) + $cpuObserver = $this->createMock(ObserverInterface::class); + $cpuObserver->expects($this->exactly(2)) ->method('observe') ->with( $this->isType('float'), @@ -70,6 +69,9 @@ public function test_cpu_time_callback_observes_user_and_system_modes(): void ), ); - $capturedCallback($observer); + $vcsObserver = $this->createMock(ObserverInterface::class); + $ivcsObserver = $this->createMock(ObserverInterface::class); + + $capturedCallback($cpuObserver, $vcsObserver, $ivcsObserver); } } diff --git a/src/Metrics/Runtime/tests/Unit/GarbageCollectionMetricsTest.php b/src/Metrics/Runtime/tests/Unit/GarbageCollectionMetricsTest.php index c93418414..da1852e9a 100644 --- a/src/Metrics/Runtime/tests/Unit/GarbageCollectionMetricsTest.php +++ b/src/Metrics/Runtime/tests/Unit/GarbageCollectionMetricsTest.php @@ -19,7 +19,6 @@ public function test_register_creates_all_instruments(): void $meter = $this->createMock(MeterInterface::class); $expectedCounters = PHP_VERSION_ID >= 80300 ? 5 : 2; - $meter->expects($this->exactly($expectedCounters)) ->method('createObservableCounter') ->willReturn($this->createMock(ObservableCounterInterface::class)); @@ -28,6 +27,11 @@ public function test_register_creates_all_instruments(): void ->method('createObservableGauge') ->willReturn($this->createMock(ObservableGaugeInterface::class)); + $expectedBatchObserve = PHP_VERSION_ID >= 80300 ? 2 : 1; + $meter->expects($this->exactly($expectedBatchObserve)) + ->method('batchObserve') + ->willReturn($this->createMock(ObservableCallbackInterface::class)); + GarbageCollectionMetrics::register($meter); } @@ -35,31 +39,31 @@ public function test_gc_runs_callback_observes_integer(): void { $capturedCallback = null; - $callbackStub = $this->createMock(ObservableCallbackInterface::class); - $counter = $this->createMock(ObservableCounterInterface::class); - $counter->method('observe') - ->willReturnCallback(function (callable $cb) use (&$capturedCallback, $callbackStub): ObservableCallbackInterface { + $meter = $this->createMock(MeterInterface::class); + $meter->method('createObservableCounter')->willReturn($this->createMock(ObservableCounterInterface::class)); + $meter->method('createObservableGauge')->willReturn($this->createMock(ObservableGaugeInterface::class)); + $meter->method('batchObserve') + ->willReturnCallback(function (callable $cb) use (&$capturedCallback): ObservableCallbackInterface { if ($capturedCallback === null) { $capturedCallback = $cb; } - return $callbackStub; + return $this->createMock(ObservableCallbackInterface::class); }); - $meter = $this->createMock(MeterInterface::class); - $meter->method('createObservableCounter')->willReturn($counter); - $meter->method('createObservableGauge')->willReturn($this->createMock(ObservableGaugeInterface::class)); - GarbageCollectionMetrics::register($meter); assert($capturedCallback !== null); - $observer = $this->createMock(ObserverInterface::class); - $observer->expects($this->once()) - ->method('observe') - ->with($this->isType('int')); + $runsObs = $this->createMock(ObserverInterface::class); + $runsObs->expects($this->once())->method('observe')->with($this->isType('int')); - $capturedCallback($observer); + $capturedCallback( + $runsObs, + $this->createMock(ObserverInterface::class), + $this->createMock(ObserverInterface::class), + $this->createMock(ObserverInterface::class), + ); } public function test_gc_timing_metrics_registered_on_php83(): void @@ -74,6 +78,9 @@ public function test_gc_timing_metrics_registered_on_php83(): void ->willReturn($this->createMock(ObservableCounterInterface::class)); $meter->method('createObservableGauge') ->willReturn($this->createMock(ObservableGaugeInterface::class)); + $meter->expects($this->exactly(2)) + ->method('batchObserve') + ->willReturn($this->createMock(ObservableCallbackInterface::class)); GarbageCollectionMetrics::register($meter); } @@ -85,27 +92,26 @@ public function test_gc_timing_callback_observes_float(): void } $callbacks = []; - $callbackStub = $this->createMock(ObservableCallbackInterface::class); - $counter = $this->createMock(ObservableCounterInterface::class); - $counter->method('observe') - ->willReturnCallback(function (callable $cb) use (&$callbacks, $callbackStub): ObservableCallbackInterface { + $meter = $this->createMock(MeterInterface::class); + $meter->method('createObservableCounter')->willReturn($this->createMock(ObservableCounterInterface::class)); + $meter->method('createObservableGauge')->willReturn($this->createMock(ObservableGaugeInterface::class)); + $meter->method('batchObserve') + ->willReturnCallback(function (callable $cb) use (&$callbacks): ObservableCallbackInterface { $callbacks[] = $cb; - return $callbackStub; + return $this->createMock(ObservableCallbackInterface::class); }); - $meter = $this->createMock(MeterInterface::class); - $meter->method('createObservableCounter')->willReturn($counter); - $meter->method('createObservableGauge')->willReturn($this->createMock(ObservableGaugeInterface::class)); - GarbageCollectionMetrics::register($meter); - // callbacks[2] is collector_time (index 0=runs, 1=collected, 2=collector_time) - $observer = $this->createMock(ObserverInterface::class); - $observer->expects($this->once()) - ->method('observe') - ->with($this->isType('float')); + // $callbacks[1] is the PHP 8.3 timing batchObserve (collector_time, destructor_time, free_time) + $collectorObs = $this->createMock(ObserverInterface::class); + $collectorObs->expects($this->once())->method('observe')->with($this->isType('float')); - $callbacks[2]($observer); + $callbacks[1]( + $collectorObs, + $this->createMock(ObserverInterface::class), + $this->createMock(ObserverInterface::class), + ); } } diff --git a/src/Metrics/Runtime/tests/Unit/OpcacheMetricsTest.php b/src/Metrics/Runtime/tests/Unit/OpcacheMetricsTest.php index 1aae96072..7e70bb722 100644 --- a/src/Metrics/Runtime/tests/Unit/OpcacheMetricsTest.php +++ b/src/Metrics/Runtime/tests/Unit/OpcacheMetricsTest.php @@ -5,6 +5,7 @@ namespace OpenTelemetry\Contrib\Metrics\Runtime\Tests\Unit; use OpenTelemetry\API\Metrics\MeterInterface; +use OpenTelemetry\API\Metrics\ObservableCallbackInterface; use OpenTelemetry\API\Metrics\ObservableCounterInterface; use OpenTelemetry\API\Metrics\ObservableGaugeInterface; use OpenTelemetry\API\Metrics\ObservableUpDownCounterInterface; @@ -38,6 +39,10 @@ public function test_register_creates_all_instruments(): void ->method('createObservableCounter') ->willReturn($this->createMock(ObservableCounterInterface::class)); + $meter->expects($this->once()) + ->method('batchObserve') + ->willReturn($this->createMock(ObservableCallbackInterface::class)); + OpcacheMetrics::register($meter); } } diff --git a/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php b/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php index c5c6506a1..3809464c2 100644 --- a/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php +++ b/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php @@ -5,6 +5,8 @@ namespace OpenTelemetry\Contrib\Metrics\Runtime\Tests\Unit; use OpenTelemetry\API\Metrics\MeterInterface; +use OpenTelemetry\API\Metrics\MeterProviderInterface; +use OpenTelemetry\API\Metrics\ObservableCallbackInterface; use OpenTelemetry\API\Metrics\ObservableCounterInterface; use OpenTelemetry\API\Metrics\ObservableGaugeInterface; use OpenTelemetry\API\Metrics\ObservableUpDownCounterInterface; @@ -18,7 +20,7 @@ protected function tearDown(): void putenv('OTEL_PHP_DISABLED_METRICS'); } - public function test_register_does_not_throw(): void + private function makeMeter(): MeterInterface { $meter = $this->createMock(MeterInterface::class); $meter->method('createObservableUpDownCounter') @@ -27,9 +29,24 @@ public function test_register_does_not_throw(): void ->willReturn($this->createMock(ObservableGaugeInterface::class)); $meter->method('createObservableCounter') ->willReturn($this->createMock(ObservableCounterInterface::class)); + $meter->method('batchObserve') + ->willReturn($this->createMock(ObservableCallbackInterface::class)); + + return $meter; + } + + private function makeMeterProvider(): MeterProviderInterface + { + $meterProvider = $this->createMock(MeterProviderInterface::class); + $meterProvider->method('getMeter')->willReturn($this->makeMeter()); + + return $meterProvider; + } + public function test_register_does_not_throw(): void + { $this->expectNotToPerformAssertions(); - RuntimeMetrics::register($meter); + RuntimeMetrics::register($this->makeMeterProvider()); } public function test_instrumentation_name_is_non_empty_string(): void @@ -37,55 +54,41 @@ public function test_instrumentation_name_is_non_empty_string(): void $this->assertNotEmpty(RuntimeMetrics::getInstrumentationName()); } - public function test_disabled_memory_group_skips_memory_instruments(): void + public function test_disabled_memory_group_skips_memory_meter(): void { putenv('OTEL_PHP_DISABLED_METRICS=memory'); - $meter = $this->createMock(MeterInterface::class); - $meter->expects($this->never())->method('createObservableUpDownCounter'); - $meter->method('createObservableGauge') - ->willReturn($this->createMock(ObservableGaugeInterface::class)); - $meter->method('createObservableCounter') - ->willReturn($this->createMock(ObservableCounterInterface::class)); - - RuntimeMetrics::register($meter); - } + $requestedNames = []; + $meterProvider = $this->createMock(MeterProviderInterface::class); + $meterProvider->method('getMeter') + ->willReturnCallback(function (string $name) use (&$requestedNames): MeterInterface { + $requestedNames[] = $name; - public function test_disabled_gc_group_skips_gc_instruments(): void - { - putenv('OTEL_PHP_DISABLED_METRICS=gc,cpu'); + return $this->makeMeter(); + }); - $meter = $this->createMock(MeterInterface::class); - $meter->method('createObservableUpDownCounter') - ->willReturn($this->createMock(ObservableUpDownCounterInterface::class)); - $meter->method('createObservableGauge') - ->willReturn($this->createMock(ObservableGaugeInterface::class)); - $meter->expects($this->never())->method('createObservableCounter'); + RuntimeMetrics::register($meterProvider); - RuntimeMetrics::register($meter); + $this->assertNotContains('io.opentelemetry.contrib.php.runtime.memory', $requestedNames); } - public function test_disabled_multiple_groups_via_comma_list(): void + public function test_disabled_multiple_groups_skips_their_meters(): void { putenv('OTEL_PHP_DISABLED_METRICS=memory,gc,opcache,cpu'); - $meter = $this->createMock(MeterInterface::class); - $meter->expects($this->never())->method('createObservableUpDownCounter'); - $meter->expects($this->never())->method('createObservableCounter'); - $meter->expects($this->never())->method('createObservableGauge'); + $meterProvider = $this->createMock(MeterProviderInterface::class); + $meterProvider->expects($this->never())->method('getMeter'); - RuntimeMetrics::register($meter); + RuntimeMetrics::register($meterProvider); } public function test_disabled_env_is_case_insensitive(): void { putenv('OTEL_PHP_DISABLED_METRICS=Memory,GC,Opcache,CPU'); - $meter = $this->createMock(MeterInterface::class); - $meter->expects($this->never())->method('createObservableUpDownCounter'); - $meter->expects($this->never())->method('createObservableCounter'); - $meter->expects($this->never())->method('createObservableGauge'); + $meterProvider = $this->createMock(MeterProviderInterface::class); + $meterProvider->expects($this->never())->method('getMeter'); - RuntimeMetrics::register($meter); + RuntimeMetrics::register($meterProvider); } } From 4adc29b18aa95dcc6a7655c26e01092fc184247a Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Mon, 6 Jul 2026 14:55:57 +0200 Subject: [PATCH 05/11] feat(metrics/runtime): address PR review feedback - Only observe ru_nvcsw/ru_nivcsw when getrusage() actually reports them (absent on some OSes) instead of defaulting to 0 - Merge voluntary/involuntary context switches into the standard process.context_switches metric with a process.context_switch.type attribute, so it stays meaningful when that attribute is dropped - Split memory usage into additive emalloc/overhead components instead of overlapping real/emalloc values, for the same reason - Mark per-group metric classes as @internal - Drop the process.runtime. prefix from PHP-specific metrics per the runtime metrics semantic conventions - Pass an instrumentation version to each meter via InstalledVersions - Add a _register.php fallback (via ServiceLoader::register()) so the instrumentation still registers when the tbachert/spi composer plugin is not allowed to run --- src/Metrics/Runtime/README.md | 43 +++++++++--------- src/Metrics/Runtime/_register.php | 10 +++++ src/Metrics/Runtime/composer.json | 14 +++--- src/Metrics/Runtime/src/CpuMetrics.php | 32 +++++++------- .../Runtime/src/GarbageCollectionMetrics.php | 17 ++++--- src/Metrics/Runtime/src/MemoryMetrics.php | 19 +++++--- src/Metrics/Runtime/src/OpcacheMetrics.php | 23 +++++----- src/Metrics/Runtime/src/RuntimeMetrics.php | 10 +++-- .../Runtime/tests/Unit/CpuMetricsTest.php | 44 +++++++++++++++++-- .../Runtime/tests/Unit/MemoryMetricsTest.php | 12 ++++- 10 files changed, 146 insertions(+), 78 deletions(-) create mode 100644 src/Metrics/Runtime/_register.php diff --git a/src/Metrics/Runtime/README.md b/src/Metrics/Runtime/README.md index 7944f4e59..221188499 100644 --- a/src/Metrics/Runtime/README.md +++ b/src/Metrics/Runtime/README.md @@ -49,21 +49,21 @@ RuntimeMetrics::register($meterProvider); | Metric | Type | Unit | Description | |--------|------|------|-------------| -| `process.runtime.php.memory.usage` | UpDownCounter | `By` | Current memory usage. Reported for both `real` (OS allocation) and `emalloc` (PHP internal) via the `memory.type` attribute. | -| `process.runtime.php.memory.peak_usage` | UpDownCounter | `By` | Peak memory usage since script start. Same `memory.type` attribute breakdown. | -| `process.runtime.php.memory.limit` | Gauge | `By` | Memory limit from `php.ini`. `-1` means unlimited. | +| `php.memory.usage` | UpDownCounter | `By` | Current memory usage. Reported for `emalloc` (PHP internal allocation) and `overhead` (additional OS allocation beyond `emalloc`) via the `memory.type` attribute; the two sum to the real OS allocation. | +| `php.memory.peak_usage` | UpDownCounter | `By` | Peak memory usage since script start. Same `memory.type` attribute breakdown. | +| `php.memory.limit` | Gauge | `By` | Memory limit from `php.ini`. `-1` means unlimited. | ### Garbage Collection (`gc`) | Metric | Type | Unit | Description | |--------|------|------|-------------| -| `process.runtime.php.gc.runs` | Counter | `{run}` | Total number of GC cycles run. | -| `process.runtime.php.gc.collected` | Counter | `{object}` | Total number of objects collected. | -| `process.runtime.php.gc.roots` | Gauge | `{object}` | Current number of objects in the root buffer. | -| `process.runtime.php.gc.threshold` | Gauge | `{object}` | Number of roots required to trigger a GC cycle. | -| `process.runtime.php.gc.collector_time` | Counter | `s` | Cumulative time spent in the GC collector. **PHP 8.3+** | -| `process.runtime.php.gc.destructor_time` | Counter | `s` | Cumulative time spent running destructors during GC. **PHP 8.3+** | -| `process.runtime.php.gc.free_time` | Counter | `s` | Cumulative time spent freeing memory during GC. **PHP 8.3+** | +| `php.gc.runs` | Counter | `{run}` | Total number of GC cycles run. | +| `php.gc.collected` | Counter | `{object}` | Total number of objects collected. | +| `php.gc.roots` | Gauge | `{object}` | Current number of objects in the root buffer. | +| `php.gc.threshold` | Gauge | `{object}` | Number of roots required to trigger a GC cycle. | +| `php.gc.collector_time` | Counter | `s` | Cumulative time spent in the GC collector. **PHP 8.3+** | +| `php.gc.destructor_time` | Counter | `s` | Cumulative time spent running destructors during GC. **PHP 8.3+** | +| `php.gc.free_time` | Counter | `s` | Cumulative time spent freeing memory during GC. **PHP 8.3+** | ### OPcache (`opcache`) @@ -71,16 +71,16 @@ Registered only when OPcache is enabled (`opcache.enable=1`). In CLI context, al | Metric | Type | Unit | Description | |--------|------|------|-------------| -| `process.runtime.php.opcache.memory_used` | UpDownCounter | `By` | Memory used by cached scripts. | -| `process.runtime.php.opcache.memory_free` | UpDownCounter | `By` | Free memory in the OPcache buffer. | -| `process.runtime.php.opcache.memory_wasted` | UpDownCounter | `By` | Wasted (fragmented) memory — requires restart to reclaim. | -| `process.runtime.php.opcache.hits` | Counter | `{hit}` | Total cache hits. | -| `process.runtime.php.opcache.misses` | Counter | `{miss}` | Total cache misses. | -| `process.runtime.php.opcache.hit_rate` | Gauge | `%` | Cache hit rate percentage. | -| `process.runtime.php.opcache.cached_scripts` | Gauge | `{script}` | Number of scripts currently in cache. | -| `process.runtime.php.opcache.interned_strings.memory_used` | UpDownCounter | `By` | Memory used by interned strings. | -| `process.runtime.php.opcache.interned_strings.memory_free` | UpDownCounter | `By` | Free memory in the interned strings buffer. | -| `process.runtime.php.opcache.interned_strings.count` | Gauge | `{string}` | Number of interned strings currently stored. | +| `php.opcache.memory_used` | UpDownCounter | `By` | Memory used by cached scripts. | +| `php.opcache.memory_free` | UpDownCounter | `By` | Free memory in the OPcache buffer. | +| `php.opcache.memory_wasted` | UpDownCounter | `By` | Wasted (fragmented) memory — requires restart to reclaim. | +| `php.opcache.hits` | Counter | `{hit}` | Total cache hits. | +| `php.opcache.misses` | Counter | `{miss}` | Total cache misses. | +| `php.opcache.hit_rate` | Gauge | `%` | Cache hit rate percentage. | +| `php.opcache.cached_scripts` | Gauge | `{script}` | Number of scripts currently in cache. | +| `php.opcache.interned_strings.memory_used` | UpDownCounter | `By` | Memory used by interned strings. | +| `php.opcache.interned_strings.memory_free` | UpDownCounter | `By` | Free memory in the interned strings buffer. | +| `php.opcache.interned_strings.count` | Gauge | `{string}` | Number of interned strings currently stored. | ### CPU (`cpu`) @@ -89,8 +89,7 @@ Registered only on platforms where `getrusage()` is available (Linux, macOS, Win | Metric | Type | Unit | Description | |--------|------|------|-------------| | `process.cpu.time` | Counter | `s` | CPU time consumed. Reported for `user` and `system` modes via the `cpu.mode` attribute. | -| `process.runtime.php.cpu.voluntary_context_switches` | Counter | `{switch}` | Number of times the process voluntarily yielded the CPU. | -| `process.runtime.php.cpu.involuntary_context_switches` | Counter | `{switch}` | Number of times the process was preempted involuntarily. | +| `process.context_switches` | Counter | `{context_switch}` | Number of times the process has been context switched. Reported for `voluntary` and `involuntary` switches via the `process.context_switch.type` attribute. | ## Configuration diff --git a/src/Metrics/Runtime/_register.php b/src/Metrics/Runtime/_register.php new file mode 100644 index 000000000..9434e5cb0 --- /dev/null +++ b/src/Metrics/Runtime/_register.php @@ -0,0 +1,10 @@ +createObservableCounter( - 'process.runtime.php.cpu.voluntary_context_switches', - '{switch}', - 'Number of times the process voluntarily yielded the CPU', - ); - - $involuntaryCS = $meter->createObservableCounter( - 'process.runtime.php.cpu.involuntary_context_switches', - '{switch}', - 'Number of times the process was preempted involuntarily', + $contextSwitches = $meter->createObservableCounter( + 'process.context_switches', + '{context_switch}', + 'Number of times the process has been context switched', ); $meter->batchObserve( static function ( ObserverInterface $cpuObserver, - ObserverInterface $vcsObserver, - ObserverInterface $ivcsObserver, + ObserverInterface $contextSwitchesObserver, ): void { /** @var array $usage */ $usage = getrusage(); @@ -52,13 +48,17 @@ static function ( $cpuObserver->observe($userSeconds, ['cpu.mode' => 'user']); $cpuObserver->observe($systemSeconds, ['cpu.mode' => 'system']); + // ru_nvcsw and ru_nivcsw may be absent on some operating systems (e.g. macOS) - $vcsObserver->observe($usage['ru_nvcsw'] ?? 0); - $ivcsObserver->observe($usage['ru_nivcsw'] ?? 0); + if (isset($usage['ru_nvcsw'])) { + $contextSwitchesObserver->observe($usage['ru_nvcsw'], ['process.context_switch.type' => 'voluntary']); + } + if (isset($usage['ru_nivcsw'])) { + $contextSwitchesObserver->observe($usage['ru_nivcsw'], ['process.context_switch.type' => 'involuntary']); + } }, $cpuTime, - $voluntaryCS, - $involuntaryCS, + $contextSwitches, ); } } diff --git a/src/Metrics/Runtime/src/GarbageCollectionMetrics.php b/src/Metrics/Runtime/src/GarbageCollectionMetrics.php index 5e65d5d9f..174192ae6 100644 --- a/src/Metrics/Runtime/src/GarbageCollectionMetrics.php +++ b/src/Metrics/Runtime/src/GarbageCollectionMetrics.php @@ -7,6 +7,9 @@ use OpenTelemetry\API\Metrics\MeterInterface; use OpenTelemetry\API\Metrics\ObserverInterface; +/** + * @internal + */ class GarbageCollectionMetrics { public const GROUP = 'gc'; @@ -14,22 +17,22 @@ class GarbageCollectionMetrics public static function register(MeterInterface $meter): void { $runs = $meter->createObservableCounter( - 'process.runtime.php.gc.runs', + 'php.gc.runs', '{run}', 'Total number of garbage collection cycles run', ); $collected = $meter->createObservableCounter( - 'process.runtime.php.gc.collected', + 'php.gc.collected', '{object}', 'Total number of objects collected by the garbage collector', ); $threshold = $meter->createObservableGauge( - 'process.runtime.php.gc.threshold', + 'php.gc.threshold', '{object}', 'Number of roots needed to trigger a garbage collection cycle', ); $roots = $meter->createObservableGauge( - 'process.runtime.php.gc.roots', + 'php.gc.roots', '{object}', 'Current number of objects in the root buffer', ); @@ -56,17 +59,17 @@ static function ( // Timing metrics available since PHP 8.3 if (PHP_VERSION_ID >= 80300) { $collectorTime = $meter->createObservableCounter( - 'process.runtime.php.gc.collector_time', + 'php.gc.collector_time', 's', 'Cumulative time spent in the garbage collector', ); $destructorTime = $meter->createObservableCounter( - 'process.runtime.php.gc.destructor_time', + 'php.gc.destructor_time', 's', 'Cumulative time spent running destructors during GC', ); $freeTime = $meter->createObservableCounter( - 'process.runtime.php.gc.free_time', + 'php.gc.free_time', 's', 'Cumulative time spent freeing memory during GC', ); diff --git a/src/Metrics/Runtime/src/MemoryMetrics.php b/src/Metrics/Runtime/src/MemoryMetrics.php index 53187ebf5..a1a132037 100644 --- a/src/Metrics/Runtime/src/MemoryMetrics.php +++ b/src/Metrics/Runtime/src/MemoryMetrics.php @@ -7,6 +7,9 @@ use OpenTelemetry\API\Metrics\MeterInterface; use OpenTelemetry\API\Metrics\ObserverInterface; +/** + * @internal + */ class MemoryMetrics { public const GROUP = 'memory'; @@ -15,29 +18,31 @@ public static function register(MeterInterface $meter): void { $meter ->createObservableUpDownCounter( - 'process.runtime.php.memory.usage', + 'php.memory.usage', 'By', 'Current memory usage (real allocation from OS)', ) ->observe(static function (ObserverInterface $observer): void { - $observer->observe(memory_get_usage(true), ['memory.type' => 'real']); - $observer->observe(memory_get_usage(false), ['memory.type' => 'emalloc']); + $emalloc = memory_get_usage(false); + $observer->observe($emalloc, ['memory.type' => 'emalloc']); + $observer->observe(memory_get_usage(true) - $emalloc, ['memory.type' => 'overhead']); }); $meter ->createObservableUpDownCounter( - 'process.runtime.php.memory.peak_usage', + 'php.memory.peak_usage', 'By', 'Peak memory usage since script start', ) ->observe(static function (ObserverInterface $observer): void { - $observer->observe(memory_get_peak_usage(true), ['memory.type' => 'real']); - $observer->observe(memory_get_peak_usage(false), ['memory.type' => 'emalloc']); + $emalloc = memory_get_peak_usage(false); + $observer->observe($emalloc, ['memory.type' => 'emalloc']); + $observer->observe(memory_get_peak_usage(true) - $emalloc, ['memory.type' => 'overhead']); }); $meter ->createObservableGauge( - 'process.runtime.php.memory.limit', + 'php.memory.limit', 'By', 'Memory limit configured in php.ini (-1 means unlimited)', ) diff --git a/src/Metrics/Runtime/src/OpcacheMetrics.php b/src/Metrics/Runtime/src/OpcacheMetrics.php index c6c370f6e..8823d819c 100644 --- a/src/Metrics/Runtime/src/OpcacheMetrics.php +++ b/src/Metrics/Runtime/src/OpcacheMetrics.php @@ -7,6 +7,9 @@ use OpenTelemetry\API\Metrics\MeterInterface; use OpenTelemetry\API\Metrics\ObserverInterface; +/** + * @internal + */ class OpcacheMetrics { public const GROUP = 'opcache'; @@ -25,52 +28,52 @@ public static function isAvailable(): bool public static function register(MeterInterface $meter): void { $memoryUsed = $meter->createObservableUpDownCounter( - 'process.runtime.php.opcache.memory_used', + 'php.opcache.memory_used', 'By', 'OPcache memory used by cached scripts', ); $memoryFree = $meter->createObservableUpDownCounter( - 'process.runtime.php.opcache.memory_free', + 'php.opcache.memory_free', 'By', 'OPcache memory free', ); $memoryWasted = $meter->createObservableUpDownCounter( - 'process.runtime.php.opcache.memory_wasted', + 'php.opcache.memory_wasted', 'By', 'OPcache memory wasted (fragmented, not usable without restart)', ); $hitRate = $meter->createObservableGauge( - 'process.runtime.php.opcache.hit_rate', + 'php.opcache.hit_rate', '%', 'OPcache hit rate percentage', ); $hits = $meter->createObservableCounter( - 'process.runtime.php.opcache.hits', + 'php.opcache.hits', '{hit}', 'Total OPcache hits', ); $misses = $meter->createObservableCounter( - 'process.runtime.php.opcache.misses', + 'php.opcache.misses', '{miss}', 'Total OPcache misses', ); $cachedScripts = $meter->createObservableGauge( - 'process.runtime.php.opcache.cached_scripts', + 'php.opcache.cached_scripts', '{script}', 'Number of scripts currently cached in OPcache', ); $internedStringsMemoryUsed = $meter->createObservableUpDownCounter( - 'process.runtime.php.opcache.interned_strings.memory_used', + 'php.opcache.interned_strings.memory_used', 'By', 'Memory used by OPcache interned strings', ); $internedStringsMemoryFree = $meter->createObservableUpDownCounter( - 'process.runtime.php.opcache.interned_strings.memory_free', + 'php.opcache.interned_strings.memory_free', 'By', 'Memory free in OPcache interned strings buffer', ); $internedStringsCount = $meter->createObservableGauge( - 'process.runtime.php.opcache.interned_strings.count', + 'php.opcache.interned_strings.count', '{string}', 'Number of interned strings currently stored in OPcache', ); diff --git a/src/Metrics/Runtime/src/RuntimeMetrics.php b/src/Metrics/Runtime/src/RuntimeMetrics.php index 75218d46b..0de6b58dd 100644 --- a/src/Metrics/Runtime/src/RuntimeMetrics.php +++ b/src/Metrics/Runtime/src/RuntimeMetrics.php @@ -4,6 +4,7 @@ namespace OpenTelemetry\Contrib\Metrics\Runtime; +use Composer\InstalledVersions; use OpenTelemetry\API\Metrics\MeterProviderInterface; use OpenTelemetry\SemConv\Version; @@ -31,27 +32,28 @@ class RuntimeMetrics */ public static function register(MeterProviderInterface $meterProvider): void { + $version = InstalledVersions::getPrettyVersion('open-telemetry/opentelemetry-metrics-runtime'); $schemaUrl = Version::VERSION_1_38_0->url(); $disabled = self::disabledGroups(); if (!\in_array(MemoryMetrics::GROUP, $disabled, true)) { MemoryMetrics::register( - $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.memory', null, $schemaUrl), + $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.memory', $version, $schemaUrl), ); } if (!\in_array(GarbageCollectionMetrics::GROUP, $disabled, true)) { GarbageCollectionMetrics::register( - $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.gc', null, $schemaUrl), + $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.gc', $version, $schemaUrl), ); } if (!\in_array(OpcacheMetrics::GROUP, $disabled, true) && OpcacheMetrics::isAvailable()) { OpcacheMetrics::register( - $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.opcache', null, $schemaUrl), + $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.opcache', $version, $schemaUrl), ); } if (!\in_array(CpuMetrics::GROUP, $disabled, true) && CpuMetrics::isAvailable()) { CpuMetrics::register( - $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.cpu', null, $schemaUrl), + $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.cpu', $version, $schemaUrl), ); } } diff --git a/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php b/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php index c884cfa80..bd6569a33 100644 --- a/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php +++ b/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php @@ -26,7 +26,7 @@ public function test_register_creates_all_instruments(): void $meter = $this->createMock(MeterInterface::class); - $meter->expects($this->exactly(3)) + $meter->expects($this->exactly(2)) ->method('createObservableCounter') ->willReturn($this->createMock(ObservableCounterInterface::class)); @@ -69,9 +69,45 @@ public function test_cpu_time_callback_observes_user_and_system_modes(): void ), ); - $vcsObserver = $this->createMock(ObserverInterface::class); - $ivcsObserver = $this->createMock(ObserverInterface::class); + $contextSwitchesObserver = $this->createMock(ObserverInterface::class); - $capturedCallback($cpuObserver, $vcsObserver, $ivcsObserver); + $capturedCallback($cpuObserver, $contextSwitchesObserver); + } + + public function test_context_switches_callback_observes_voluntary_and_involuntary_types(): void + { + if (!CpuMetrics::isAvailable()) { + $this->markTestSkipped('getrusage() not available on this platform'); + } + + $capturedCallback = null; + + $meter = $this->createMock(MeterInterface::class); + $meter->method('createObservableCounter')->willReturn($this->createMock(ObservableCounterInterface::class)); + $meter->method('batchObserve') + ->willReturnCallback(function (callable $cb) use (&$capturedCallback): ObservableCallbackInterface { + $capturedCallback = $cb; + + return $this->createMock(ObservableCallbackInterface::class); + }); + + CpuMetrics::register($meter); + + assert($capturedCallback !== null); + + $cpuObserver = $this->createMock(ObserverInterface::class); + + $contextSwitchesObserver = $this->createMock(ObserverInterface::class); + $contextSwitchesObserver->expects($this->atMost(2)) + ->method('observe') + ->with( + $this->isType('int'), + $this->logicalOr( + $this->equalTo(['process.context_switch.type' => 'voluntary']), + $this->equalTo(['process.context_switch.type' => 'involuntary']), + ), + ); + + $capturedCallback($cpuObserver, $contextSwitchesObserver); } } diff --git a/src/Metrics/Runtime/tests/Unit/MemoryMetricsTest.php b/src/Metrics/Runtime/tests/Unit/MemoryMetricsTest.php index 61ac79ae2..fe4402a8b 100644 --- a/src/Metrics/Runtime/tests/Unit/MemoryMetricsTest.php +++ b/src/Metrics/Runtime/tests/Unit/MemoryMetricsTest.php @@ -29,7 +29,7 @@ public function test_register_creates_all_instruments(): void MemoryMetrics::register($meter); } - public function test_memory_usage_callback_observes_real_and_emalloc(): void + public function test_memory_usage_callback_observes_emalloc_and_overhead(): void { $capturedCallbacks = []; @@ -49,7 +49,15 @@ public function test_memory_usage_callback_observes_real_and_emalloc(): void MemoryMetrics::register($meter); $observer = $this->createMock(ObserverInterface::class); - $observer->expects($this->exactly(2))->method('observe'); + $observer->expects($this->exactly(2)) + ->method('observe') + ->with( + $this->isType('int'), + $this->logicalOr( + $this->equalTo(['memory.type' => 'emalloc']), + $this->equalTo(['memory.type' => 'overhead']), + ), + ); $capturedCallbacks[0]($observer); } From 0fc530ce709f6e929fe22ff6473cf358754666ca Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Mon, 6 Jul 2026 16:31:45 +0200 Subject: [PATCH 06/11] feat(metrics/runtime): disable groups via OTEL_PHP_DISABLED_INSTRUMENTATIONS Reuse the standard OTEL_PHP_DISABLED_INSTRUMENTATIONS env var (metrics-runtime-memory/-gc/-opcache/-cpu, or metrics-runtime/all for everything) instead of the ad-hoc OTEL_PHP_DISABLED_METRICS variable. --- src/Metrics/Runtime/README.md | 16 +++- src/Metrics/Runtime/_register.php | 4 + src/Metrics/Runtime/psalm.xml.dist | 6 -- ...ationConfigurationRuntimeMetricsConfig.php | 54 +++++++++++++ src/Metrics/Runtime/src/RuntimeMetrics.php | 24 ++---- .../Runtime/src/RuntimeMetricsConfig.php | 19 +++++ .../src/RuntimeMetricsInstrumentation.php | 5 +- ...nConfigurationRuntimeMetricsConfigTest.php | 77 ++++++++++++++++++ .../RuntimeMetricsInstrumentationTest.php | 79 +++++++++++++++++++ .../Runtime/tests/Unit/RuntimeMetricsTest.php | 23 +----- 10 files changed, 256 insertions(+), 51 deletions(-) create mode 100644 src/Metrics/Runtime/src/InstrumentationConfigurationRuntimeMetricsConfig.php create mode 100644 src/Metrics/Runtime/src/RuntimeMetricsConfig.php create mode 100644 src/Metrics/Runtime/tests/Unit/InstrumentationConfigurationRuntimeMetricsConfigTest.php create mode 100644 src/Metrics/Runtime/tests/Unit/RuntimeMetricsInstrumentationTest.php diff --git a/src/Metrics/Runtime/README.md b/src/Metrics/Runtime/README.md index 221188499..780c820c3 100644 --- a/src/Metrics/Runtime/README.md +++ b/src/Metrics/Runtime/README.md @@ -41,6 +41,9 @@ If you need to control when metrics are registered: use OpenTelemetry\Contrib\Metrics\Runtime\RuntimeMetrics; RuntimeMetrics::register($meterProvider); + +// Optionally, disable individual groups (memory, gc, opcache, cpu): +RuntimeMetrics::register($meterProvider, disabled: ['opcache', 'cpu']); ``` ## Metrics @@ -101,17 +104,22 @@ OTEL_PHP_DISABLED_INSTRUMENTATIONS=metrics-runtime ### Disable individual metric groups -**Option 1: via `OTEL_PHP_DISABLED_METRICS`** — lightweight, no config file needed: +**Option 1: via `OTEL_PHP_DISABLED_INSTRUMENTATIONS`** — lightweight, no config file needed. Each group is addressed as `metrics-runtime-{group}`: ```shell # Disable OPcache and CPU metrics only -OTEL_PHP_DISABLED_METRICS=opcache,cpu +OTEL_PHP_DISABLED_INSTRUMENTATIONS=metrics-runtime-opcache,metrics-runtime-cpu # Disable GC metrics only -OTEL_PHP_DISABLED_METRICS=gc +OTEL_PHP_DISABLED_INSTRUMENTATIONS=metrics-runtime-gc ``` -Available group names: `memory`, `gc`, `opcache`, `cpu`. Values are case-insensitive. +| Group | Instrumentation name | +|-------|----------------------| +| `memory` | `metrics-runtime-memory` | +| `gc` | `metrics-runtime-gc` | +| `opcache` | `metrics-runtime-opcache` | +| `cpu` | `metrics-runtime-cpu` | **Option 2: via `OTEL_CONFIG_FILE`** — standard OTel SDK configuration, supports fine-grained control (e.g. filtering by attributes). Each group uses its own meter named `io.opentelemetry.contrib.php.runtime.{group}`, which can be targeted with meter configurators or views: diff --git a/src/Metrics/Runtime/_register.php b/src/Metrics/Runtime/_register.php index 9434e5cb0..a3c36d22d 100644 --- a/src/Metrics/Runtime/_register.php +++ b/src/Metrics/Runtime/_register.php @@ -3,8 +3,12 @@ declare(strict_types=1); use Nevay\SPI\ServiceLoader; +use OpenTelemetry\API\Configuration\ConfigEnv\EnvComponentLoader; use OpenTelemetry\API\Instrumentation\AutoInstrumentation\Instrumentation; +use OpenTelemetry\Contrib\Metrics\Runtime\InstrumentationConfigurationRuntimeMetricsConfig; use OpenTelemetry\Contrib\Metrics\Runtime\RuntimeMetricsInstrumentation; // @phan-suppress-next-line PhanTemplateTypeConstraintViolation ServiceLoader::register(Instrumentation::class, RuntimeMetricsInstrumentation::class); +// @phan-suppress-next-line PhanTemplateTypeConstraintViolation +ServiceLoader::register(EnvComponentLoader::class, InstrumentationConfigurationRuntimeMetricsConfig::class); diff --git a/src/Metrics/Runtime/psalm.xml.dist b/src/Metrics/Runtime/psalm.xml.dist index be4e36bac..f79b91604 100644 --- a/src/Metrics/Runtime/psalm.xml.dist +++ b/src/Metrics/Runtime/psalm.xml.dist @@ -15,11 +15,5 @@ - - - - - - diff --git a/src/Metrics/Runtime/src/InstrumentationConfigurationRuntimeMetricsConfig.php b/src/Metrics/Runtime/src/InstrumentationConfigurationRuntimeMetricsConfig.php new file mode 100644 index 000000000..cbf5bb44e --- /dev/null +++ b/src/Metrics/Runtime/src/InstrumentationConfigurationRuntimeMetricsConfig.php @@ -0,0 +1,54 @@ + + * @internal + */ +final class InstrumentationConfigurationRuntimeMetricsConfig implements EnvComponentLoader +{ + private const GROUPS = [ + MemoryMetrics::GROUP, + GarbageCollectionMetrics::GROUP, + OpcacheMetrics::GROUP, + CpuMetrics::GROUP, + ]; + + public function load(EnvResolver $env, EnvComponentLoaderRegistry $registry, Context $context): InstrumentationConfiguration + { + $disabledInstrumentations = $env->list('OTEL_PHP_DISABLED_INSTRUMENTATIONS') ?? []; + + if (in_array('all', $disabledInstrumentations, true) || in_array(RuntimeMetrics::NAME, $disabledInstrumentations, true)) { + return new RuntimeMetricsConfig(self::GROUPS); + } + + $disabled = []; + foreach (self::GROUPS as $group) { + if (in_array(RuntimeMetrics::NAME . '-' . $group, $disabledInstrumentations, true)) { + $disabled[] = $group; + } + } + + return new RuntimeMetricsConfig($disabled); + } + + public function name(): string + { + return RuntimeMetricsConfig::class; + } +} diff --git a/src/Metrics/Runtime/src/RuntimeMetrics.php b/src/Metrics/Runtime/src/RuntimeMetrics.php index 0de6b58dd..2df88a774 100644 --- a/src/Metrics/Runtime/src/RuntimeMetrics.php +++ b/src/Metrics/Runtime/src/RuntimeMetrics.php @@ -13,7 +13,6 @@ class RuntimeMetrics public const NAME = 'metrics-runtime'; private const INSTRUMENTATION_NAME = 'io.opentelemetry.contrib.php.runtime'; - private const ENV_DISABLED_METRICS = 'OTEL_PHP_DISABLED_METRICS'; /** * Register available runtime metric observers on the given meter provider. @@ -21,20 +20,18 @@ class RuntimeMetrics * Each group gets its own meter (io.opentelemetry.contrib.php.runtime.{group}), * which allows disabling them via SDK meter configurators or views in OTEL_CONFIG_FILE. * - * Individual groups can also be disabled via the OTEL_PHP_DISABLED_METRICS - * environment variable as a comma-separated list of group names: - * OTEL_PHP_DISABLED_METRICS=opcache,cpu - * + * Individual groups can also be disabled by passing their names in $disabled. * Valid group names: memory, gc, opcache, cpu * * OPcache and CPU metrics are also skipped when the underlying PHP - * functions are unavailable, regardless of this setting. + * functions are unavailable, regardless of $disabled. + * + * @param list $disabled */ - public static function register(MeterProviderInterface $meterProvider): void + public static function register(MeterProviderInterface $meterProvider, array $disabled = []): void { $version = InstalledVersions::getPrettyVersion('open-telemetry/opentelemetry-metrics-runtime'); $schemaUrl = Version::VERSION_1_38_0->url(); - $disabled = self::disabledGroups(); if (!\in_array(MemoryMetrics::GROUP, $disabled, true)) { MemoryMetrics::register( @@ -62,15 +59,4 @@ public static function getInstrumentationName(): string { return self::INSTRUMENTATION_NAME; } - - /** @return list */ - private static function disabledGroups(): array - { - $value = getenv(self::ENV_DISABLED_METRICS); - if ($value === false || $value === '') { - return []; - } - - return array_map('trim', explode(',', strtolower($value))); - } } diff --git a/src/Metrics/Runtime/src/RuntimeMetricsConfig.php b/src/Metrics/Runtime/src/RuntimeMetricsConfig.php new file mode 100644 index 000000000..f37d3716b --- /dev/null +++ b/src/Metrics/Runtime/src/RuntimeMetricsConfig.php @@ -0,0 +1,19 @@ + $disabled */ + public function __construct( + public readonly array $disabled = [], + ) { + } +} diff --git a/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php b/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php index e8b873b60..a9b73250f 100644 --- a/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php +++ b/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php @@ -13,6 +13,9 @@ class RuntimeMetricsInstrumentation implements Instrumentation { public function register(HookManagerInterface $hookManager, ConfigProperties $configuration, Context $context): void { - RuntimeMetrics::register($context->meterProvider); + $config = $configuration->get(RuntimeMetricsConfig::class); + $disabled = $config instanceof RuntimeMetricsConfig ? $config->disabled : []; + + RuntimeMetrics::register($context->meterProvider, $disabled); } } diff --git a/src/Metrics/Runtime/tests/Unit/InstrumentationConfigurationRuntimeMetricsConfigTest.php b/src/Metrics/Runtime/tests/Unit/InstrumentationConfigurationRuntimeMetricsConfigTest.php new file mode 100644 index 000000000..a28aed818 --- /dev/null +++ b/src/Metrics/Runtime/tests/Unit/InstrumentationConfigurationRuntimeMetricsConfigTest.php @@ -0,0 +1,77 @@ +createMock(EnvResolver::class); + $env->method('list') + ->with('OTEL_PHP_DISABLED_INSTRUMENTATIONS') + ->willReturn($disabledInstrumentations); + + $config = (new InstrumentationConfigurationRuntimeMetricsConfig())->load( + $env, + $this->createMock(EnvComponentLoaderRegistry::class), + new Context(), + ); + + $this->assertInstanceOf(RuntimeMetricsConfig::class, $config); + + return $config; + } + + public function test_name_returns_config_class(): void + { + $this->assertSame(RuntimeMetricsConfig::class, (new InstrumentationConfigurationRuntimeMetricsConfig())->name()); + } + + public function test_nothing_disabled_by_default(): void + { + $this->assertSame([], $this->load(null)->disabled); + } + + public function test_single_group_disabled(): void + { + $this->assertSame(['opcache'], $this->load(['metrics-runtime-opcache'])->disabled); + } + + public function test_multiple_groups_disabled(): void + { + $this->assertEqualsCanonicalizing( + ['memory', 'cpu'], + $this->load(['metrics-runtime-memory', 'metrics-runtime-cpu'])->disabled, + ); + } + + public function test_whole_package_name_disables_all_groups(): void + { + $this->assertEqualsCanonicalizing( + ['memory', 'gc', 'opcache', 'cpu'], + $this->load(['metrics-runtime'])->disabled, + ); + } + + public function test_all_disables_all_groups(): void + { + $this->assertEqualsCanonicalizing( + ['memory', 'gc', 'opcache', 'cpu'], + $this->load(['all'])->disabled, + ); + } + + public function test_unrelated_instrumentation_names_are_ignored(): void + { + $this->assertSame([], $this->load(['guzzle', 'pdo'])->disabled); + } +} diff --git a/src/Metrics/Runtime/tests/Unit/RuntimeMetricsInstrumentationTest.php b/src/Metrics/Runtime/tests/Unit/RuntimeMetricsInstrumentationTest.php new file mode 100644 index 000000000..a1f0cfeb7 --- /dev/null +++ b/src/Metrics/Runtime/tests/Unit/RuntimeMetricsInstrumentationTest.php @@ -0,0 +1,79 @@ + $requestedNames */ + private function makeMeterProvider(array &$requestedNames): MeterProviderInterface + { + $meter = $this->createMock(MeterInterface::class); + $meter->method('createObservableUpDownCounter') + ->willReturn($this->createMock(ObservableUpDownCounterInterface::class)); + $meter->method('createObservableGauge') + ->willReturn($this->createMock(ObservableGaugeInterface::class)); + $meter->method('createObservableCounter') + ->willReturn($this->createMock(ObservableCounterInterface::class)); + $meter->method('batchObserve') + ->willReturn($this->createMock(ObservableCallbackInterface::class)); + + $meterProvider = $this->createMock(MeterProviderInterface::class); + $meterProvider->method('getMeter') + ->willReturnCallback(function (string $name) use ($meter, &$requestedNames): MeterInterface { + $requestedNames[] = $name; + + return $meter; + }); + + return $meterProvider; + } + + public function test_register_without_config_registers_all_groups(): void + { + $requestedNames = []; + $meterProvider = $this->makeMeterProvider($requestedNames); + + $configuration = $this->createMock(ConfigProperties::class); + $configuration->method('get')->willReturn(null); + + (new RuntimeMetricsInstrumentation())->register( + $this->createMock(HookManagerInterface::class), + $configuration, + new Context(meterProvider: $meterProvider), + ); + + $this->assertContains('io.opentelemetry.contrib.php.runtime.memory', $requestedNames); + } + + public function test_register_with_config_skips_disabled_groups(): void + { + $requestedNames = []; + $meterProvider = $this->makeMeterProvider($requestedNames); + + $configuration = $this->createMock(ConfigProperties::class); + $configuration->method('get')->willReturn(new RuntimeMetricsConfig(['memory', 'gc', 'opcache', 'cpu'])); + + (new RuntimeMetricsInstrumentation())->register( + $this->createMock(HookManagerInterface::class), + $configuration, + new Context(meterProvider: $meterProvider), + ); + + $this->assertSame([], $requestedNames); + } +} diff --git a/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php b/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php index 3809464c2..95bb57335 100644 --- a/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php +++ b/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php @@ -15,11 +15,6 @@ class RuntimeMetricsTest extends TestCase { - protected function tearDown(): void - { - putenv('OTEL_PHP_DISABLED_METRICS'); - } - private function makeMeter(): MeterInterface { $meter = $this->createMock(MeterInterface::class); @@ -56,8 +51,6 @@ public function test_instrumentation_name_is_non_empty_string(): void public function test_disabled_memory_group_skips_memory_meter(): void { - putenv('OTEL_PHP_DISABLED_METRICS=memory'); - $requestedNames = []; $meterProvider = $this->createMock(MeterProviderInterface::class); $meterProvider->method('getMeter') @@ -67,28 +60,16 @@ public function test_disabled_memory_group_skips_memory_meter(): void return $this->makeMeter(); }); - RuntimeMetrics::register($meterProvider); + RuntimeMetrics::register($meterProvider, ['memory']); $this->assertNotContains('io.opentelemetry.contrib.php.runtime.memory', $requestedNames); } public function test_disabled_multiple_groups_skips_their_meters(): void { - putenv('OTEL_PHP_DISABLED_METRICS=memory,gc,opcache,cpu'); - - $meterProvider = $this->createMock(MeterProviderInterface::class); - $meterProvider->expects($this->never())->method('getMeter'); - - RuntimeMetrics::register($meterProvider); - } - - public function test_disabled_env_is_case_insensitive(): void - { - putenv('OTEL_PHP_DISABLED_METRICS=Memory,GC,Opcache,CPU'); - $meterProvider = $this->createMock(MeterProviderInterface::class); $meterProvider->expects($this->never())->method('getMeter'); - RuntimeMetrics::register($meterProvider); + RuntimeMetrics::register($meterProvider, ['memory', 'gc', 'opcache', 'cpu']); } } From 7af0b6e8a5374e63d342d63085d15aafdb0ce3d1 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Mon, 6 Jul 2026 16:35:14 +0200 Subject: [PATCH 07/11] feat(metrics/runtime): add .gitattributes --- src/Metrics/Runtime/.gitattributes | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/Metrics/Runtime/.gitattributes diff --git a/src/Metrics/Runtime/.gitattributes b/src/Metrics/Runtime/.gitattributes new file mode 100644 index 000000000..69b2a0d6b --- /dev/null +++ b/src/Metrics/Runtime/.gitattributes @@ -0,0 +1,14 @@ +* text=auto + +*.md diff=markdown +*.php diff=php + +/.gitattributes export-ignore +/.gitignore export-ignore +/.phan export-ignore +/.php-cs-fixer.php export-ignore +/coverage.clover export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/psalm.xml.dist export-ignore +/tests export-ignore From 8d57f2e60c1102d6d58e6680c5276901b058a5c2 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Wed, 8 Jul 2026 10:45:39 +0200 Subject: [PATCH 08/11] feat(metrics/runtime): address PR review feedback Avoid measuring emalloc/real memory usage across observer.observe() calls, which could allocate memory and skew the overhead calculation. Skip reporting php.memory.limit when unlimited to keep avg/min aggregations meaningful. --- src/Metrics/Runtime/src/MemoryMetrics.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Metrics/Runtime/src/MemoryMetrics.php b/src/Metrics/Runtime/src/MemoryMetrics.php index a1a132037..a06f6b5b4 100644 --- a/src/Metrics/Runtime/src/MemoryMetrics.php +++ b/src/Metrics/Runtime/src/MemoryMetrics.php @@ -24,8 +24,9 @@ public static function register(MeterInterface $meter): void ) ->observe(static function (ObserverInterface $observer): void { $emalloc = memory_get_usage(false); + $real = memory_get_usage(true); $observer->observe($emalloc, ['memory.type' => 'emalloc']); - $observer->observe(memory_get_usage(true) - $emalloc, ['memory.type' => 'overhead']); + $observer->observe($real - $emalloc, ['memory.type' => 'overhead']); }); $meter @@ -36,19 +37,22 @@ public static function register(MeterInterface $meter): void ) ->observe(static function (ObserverInterface $observer): void { $emalloc = memory_get_peak_usage(false); + $real = memory_get_peak_usage(true); $observer->observe($emalloc, ['memory.type' => 'emalloc']); - $observer->observe(memory_get_peak_usage(true) - $emalloc, ['memory.type' => 'overhead']); + $observer->observe($real - $emalloc, ['memory.type' => 'overhead']); }); $meter ->createObservableGauge( 'php.memory.limit', 'By', - 'Memory limit configured in php.ini (-1 means unlimited)', + 'Memory limit configured in php.ini', ) ->observe(static function (ObserverInterface $observer): void { - $limit = ini_get('memory_limit') ?: '-1'; - $observer->observe(ini_parse_quantity($limit)); + $limit = self::parseMemoryLimit(ini_get('memory_limit') ?: '-1'); + if ($limit >= 0) { + $observer->observe($limit); + } }); } From 745c24847e253b407acd7fae055b10d9f67ef298 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Wed, 8 Jul 2026 14:45:33 +0200 Subject: [PATCH 09/11] Update src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php Co-authored-by: Chris Lightfoot-Wild --- src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php b/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php index a9b73250f..4170494ff 100644 --- a/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php +++ b/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php @@ -16,6 +16,8 @@ public function register(HookManagerInterface $hookManager, ConfigProperties $co $config = $configuration->get(RuntimeMetricsConfig::class); $disabled = $config instanceof RuntimeMetricsConfig ? $config->disabled : []; - RuntimeMetrics::register($context->meterProvider, $disabled); +$config = $configuration->get(RuntimeMetricsConfig::class) ?? new RuntimeMetricsConfig(); + +RuntimeMetrics::register($context->meterProvider, $config); } } From 9a0ab7fe6f71575aa9fbd4244c16117acdb6eca2 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Wed, 8 Jul 2026 15:45:03 +0200 Subject: [PATCH 10/11] feat(metrics/runtime): finish RuntimeMetricsConfig migration in RuntimeMetrics::register() --- src/Metrics/Runtime/README.md | 3 ++- src/Metrics/Runtime/src/RuntimeMetrics.php | 9 ++++----- .../Runtime/src/RuntimeMetricsInstrumentation.php | 6 ++---- src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php | 5 +++-- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Metrics/Runtime/README.md b/src/Metrics/Runtime/README.md index 780c820c3..7e381f56b 100644 --- a/src/Metrics/Runtime/README.md +++ b/src/Metrics/Runtime/README.md @@ -39,11 +39,12 @@ If you need to control when metrics are registered: ```php use OpenTelemetry\Contrib\Metrics\Runtime\RuntimeMetrics; +use OpenTelemetry\Contrib\Metrics\Runtime\RuntimeMetricsConfig; RuntimeMetrics::register($meterProvider); // Optionally, disable individual groups (memory, gc, opcache, cpu): -RuntimeMetrics::register($meterProvider, disabled: ['opcache', 'cpu']); +RuntimeMetrics::register($meterProvider, new RuntimeMetricsConfig(disabled: ['opcache', 'cpu'])); ``` ## Metrics diff --git a/src/Metrics/Runtime/src/RuntimeMetrics.php b/src/Metrics/Runtime/src/RuntimeMetrics.php index 2df88a774..6d7a32afb 100644 --- a/src/Metrics/Runtime/src/RuntimeMetrics.php +++ b/src/Metrics/Runtime/src/RuntimeMetrics.php @@ -20,16 +20,15 @@ class RuntimeMetrics * Each group gets its own meter (io.opentelemetry.contrib.php.runtime.{group}), * which allows disabling them via SDK meter configurators or views in OTEL_CONFIG_FILE. * - * Individual groups can also be disabled by passing their names in $disabled. + * Individual groups can also be disabled via $config->disabled. * Valid group names: memory, gc, opcache, cpu * * OPcache and CPU metrics are also skipped when the underlying PHP - * functions are unavailable, regardless of $disabled. - * - * @param list $disabled + * functions are unavailable, regardless of $config->disabled. */ - public static function register(MeterProviderInterface $meterProvider, array $disabled = []): void + public static function register(MeterProviderInterface $meterProvider, RuntimeMetricsConfig $config = new RuntimeMetricsConfig()): void { + $disabled = $config->disabled; $version = InstalledVersions::getPrettyVersion('open-telemetry/opentelemetry-metrics-runtime'); $schemaUrl = Version::VERSION_1_38_0->url(); diff --git a/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php b/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php index 4170494ff..fb785fc18 100644 --- a/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php +++ b/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php @@ -14,10 +14,8 @@ class RuntimeMetricsInstrumentation implements Instrumentation public function register(HookManagerInterface $hookManager, ConfigProperties $configuration, Context $context): void { $config = $configuration->get(RuntimeMetricsConfig::class); - $disabled = $config instanceof RuntimeMetricsConfig ? $config->disabled : []; + $config = $config instanceof RuntimeMetricsConfig ? $config : new RuntimeMetricsConfig(); -$config = $configuration->get(RuntimeMetricsConfig::class) ?? new RuntimeMetricsConfig(); - -RuntimeMetrics::register($context->meterProvider, $config); + RuntimeMetrics::register($context->meterProvider, $config); } } diff --git a/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php b/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php index 95bb57335..893bf0b8a 100644 --- a/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php +++ b/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php @@ -11,6 +11,7 @@ use OpenTelemetry\API\Metrics\ObservableGaugeInterface; use OpenTelemetry\API\Metrics\ObservableUpDownCounterInterface; use OpenTelemetry\Contrib\Metrics\Runtime\RuntimeMetrics; +use OpenTelemetry\Contrib\Metrics\Runtime\RuntimeMetricsConfig; use PHPUnit\Framework\TestCase; class RuntimeMetricsTest extends TestCase @@ -60,7 +61,7 @@ public function test_disabled_memory_group_skips_memory_meter(): void return $this->makeMeter(); }); - RuntimeMetrics::register($meterProvider, ['memory']); + RuntimeMetrics::register($meterProvider, new RuntimeMetricsConfig(['memory'])); $this->assertNotContains('io.opentelemetry.contrib.php.runtime.memory', $requestedNames); } @@ -70,6 +71,6 @@ public function test_disabled_multiple_groups_skips_their_meters(): void $meterProvider = $this->createMock(MeterProviderInterface::class); $meterProvider->expects($this->never())->method('getMeter'); - RuntimeMetrics::register($meterProvider, ['memory', 'gc', 'opcache', 'cpu']); + RuntimeMetrics::register($meterProvider, new RuntimeMetricsConfig(['memory', 'gc', 'opcache', 'cpu'])); } } From 08b33cb8f8c5d8b5e79adcdea8cecb667d99afb9 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Wed, 8 Jul 2026 16:22:18 +0200 Subject: [PATCH 11/11] feat(metrics/runtime): move CI to per-package reusable workflow Follows the experimental split from PR #634: give Metrics/Runtime its own path-filtered workflow calling php-contrib.yml and drop it from the monolithic php.yml matrix. --- .github/workflows/metrics-runtime.yml | 36 +++++++++++++++++++ .github/workflows/php.yml | 2 +- src/Metrics/Runtime/.gitattributes | 1 + .../Runtime/.github/workflows/contrib-ci.yml | 9 +++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/metrics-runtime.yml create mode 100644 src/Metrics/Runtime/.github/workflows/contrib-ci.yml diff --git a/.github/workflows/metrics-runtime.yml b/.github/workflows/metrics-runtime.yml new file mode 100644 index 000000000..134d6e457 --- /dev/null +++ b/.github/workflows/metrics-runtime.yml @@ -0,0 +1,36 @@ +name: "[CI] Metrics Runtime" + +on: + push: + branches: [main] + paths: + - 'src/Metrics/Runtime/**' + + pull_request: + paths: + - 'src/Metrics/Runtime/**' + + # To call from https://github.com/opentelemetry-php/contrib-auto-metrics-runtime repo/forks. + workflow_call: + inputs: + package-root: + type: string + required: true + +jobs: + CI: + strategy: + fail-fast: false + matrix: + php-version: + - 8.1 + - 8.2 + - 8.3 + - 8.4 + + uses: ./.github/workflows/php-contrib.yml + with: + job-name: "PHP ${{ matrix.php-version }}" + codecov-flags: 'Metrics/Runtime' + package-root: ${{ inputs.package-root || 'src/Metrics/Runtime' }} + php-version: ${{ matrix.php-version }} diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index cfd6c7240..e5c7d53c4 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -57,7 +57,7 @@ jobs: 'Instrumentation/Symfony', 'Instrumentation/Yii', 'Logs/Monolog', - 'Metrics/Runtime', + # 'Metrics/Runtime', # Experiment: moved to metrics-runtime.yml 'Propagation/CloudTrace', 'Propagation/Instana', 'Propagation/ServerTiming', diff --git a/src/Metrics/Runtime/.gitattributes b/src/Metrics/Runtime/.gitattributes index 69b2a0d6b..ab32a6bf0 100644 --- a/src/Metrics/Runtime/.gitattributes +++ b/src/Metrics/Runtime/.gitattributes @@ -4,6 +4,7 @@ *.php diff=php /.gitattributes export-ignore +/.github export-ignore /.gitignore export-ignore /.phan export-ignore /.php-cs-fixer.php export-ignore diff --git a/src/Metrics/Runtime/.github/workflows/contrib-ci.yml b/src/Metrics/Runtime/.github/workflows/contrib-ci.yml new file mode 100644 index 000000000..114963b70 --- /dev/null +++ b/src/Metrics/Runtime/.github/workflows/contrib-ci.yml @@ -0,0 +1,9 @@ +name: Contrib CI Pipeline +on: + workflow_dispatch: + +jobs: + CI: + uses: open-telemetry/opentelemetry-php-contrib/.github/workflows/metrics-runtime.yml@main + with: + package-root: '.'