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 74e714249..e5c7d53c4 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -57,6 +57,7 @@ jobs: 'Instrumentation/Symfony', 'Instrumentation/Yii', 'Logs/Monolog', + # 'Metrics/Runtime', # Experiment: moved to metrics-runtime.yml 'Propagation/CloudTrace', 'Propagation/Instana', 'Propagation/ServerTiming', diff --git a/.gitsplit.yml b/.gitsplit.yml index 5d4097a31..6bbc8501e 100644 --- a/.gitsplit.yml +++ b/.gitsplit.yml @@ -78,6 +78,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 1da2a56ff..15afa7770 100644 --- a/composer.json +++ b/composer.json @@ -55,6 +55,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", @@ -93,6 +94,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" @@ -130,6 +132,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" @@ -179,6 +182,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/.gitattributes b/src/Metrics/Runtime/.gitattributes new file mode 100644 index 000000000..ab32a6bf0 --- /dev/null +++ b/src/Metrics/Runtime/.gitattributes @@ -0,0 +1,15 @@ +* text=auto + +*.md diff=markdown +*.php diff=php + +/.gitattributes export-ignore +/.github 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 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: '.' 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..7e381f56b --- /dev/null +++ b/src/Metrics/Runtime/README.md @@ -0,0 +1,144 @@ +[![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 API + +## Installation + +```bash +composer require open-telemetry/opentelemetry-metrics-runtime +``` + +## Usage + +### Automatic (recommended) + +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: + +```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, new RuntimeMetricsConfig(disabled: ['opcache', 'cpu'])); +``` + +## Metrics + +### Memory (`memory`) + +| Metric | Type | Unit | Description | +|--------|------|------|-------------| +| `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 | +|--------|------|------|-------------| +| `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`) + +Registered only when OPcache is enabled (`opcache.enable=1`). In CLI context, also requires `opcache.enable_cli=1`. + +| Metric | Type | Unit | Description | +|--------|------|------|-------------| +| `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`) + +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.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 + +### Disable the entire package + +```shell +OTEL_PHP_DISABLED_INSTRUMENTATIONS=metrics-runtime +``` + +### Disable individual metric groups + +**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_INSTRUMENTATIONS=metrics-runtime-opcache,metrics-runtime-cpu + +# Disable GC metrics only +OTEL_PHP_DISABLED_INSTRUMENTATIONS=metrics-runtime-gc +``` + +| 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: + +```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 + +[@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..a3c36d22d --- /dev/null +++ b/src/Metrics/Runtime/_register.php @@ -0,0 +1,14 @@ + + + + + + + + + + + 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..85a9867ad --- /dev/null +++ b/src/Metrics/Runtime/src/CpuMetrics.php @@ -0,0 +1,64 @@ +createObservableCounter( + 'process.cpu.time', + 's', + 'CPU time consumed by the process', + ); + + $contextSwitches = $meter->createObservableCounter( + 'process.context_switches', + '{context_switch}', + 'Number of times the process has been context switched', + ); + + $meter->batchObserve( + static function ( + ObserverInterface $cpuObserver, + ObserverInterface $contextSwitchesObserver, + ): 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; + + $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) + 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, + $contextSwitches, + ); + } +} diff --git a/src/Metrics/Runtime/src/GarbageCollectionMetrics.php b/src/Metrics/Runtime/src/GarbageCollectionMetrics.php new file mode 100644 index 000000000..174192ae6 --- /dev/null +++ b/src/Metrics/Runtime/src/GarbageCollectionMetrics.php @@ -0,0 +1,98 @@ +createObservableCounter( + 'php.gc.runs', + '{run}', + 'Total number of garbage collection cycles run', + ); + $collected = $meter->createObservableCounter( + 'php.gc.collected', + '{object}', + 'Total number of objects collected by the garbage collector', + ); + $threshold = $meter->createObservableGauge( + 'php.gc.threshold', + '{object}', + 'Number of roots needed to trigger a garbage collection cycle', + ); + $roots = $meter->createObservableGauge( + 'php.gc.roots', + '{object}', + 'Current number of objects in the root buffer', + ); + + $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) { + $collectorTime = $meter->createObservableCounter( + 'php.gc.collector_time', + 's', + 'Cumulative time spent in the garbage collector', + ); + $destructorTime = $meter->createObservableCounter( + 'php.gc.destructor_time', + 's', + 'Cumulative time spent running destructors during GC', + ); + $freeTime = $meter->createObservableCounter( + 'php.gc.free_time', + 's', + 'Cumulative time spent freeing memory during GC', + ); + + $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 + $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 + $freeObs->observe($status['free_time']); + }, + $collectorTime, + $destructorTime, + $freeTime, + ); + } + } +} 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/MemoryMetrics.php b/src/Metrics/Runtime/src/MemoryMetrics.php new file mode 100644 index 000000000..a06f6b5b4 --- /dev/null +++ b/src/Metrics/Runtime/src/MemoryMetrics.php @@ -0,0 +1,63 @@ +createObservableUpDownCounter( + 'php.memory.usage', + 'By', + 'Current memory usage (real allocation from OS)', + ) + ->observe(static function (ObserverInterface $observer): void { + $emalloc = memory_get_usage(false); + $real = memory_get_usage(true); + $observer->observe($emalloc, ['memory.type' => 'emalloc']); + $observer->observe($real - $emalloc, ['memory.type' => 'overhead']); + }); + + $meter + ->createObservableUpDownCounter( + 'php.memory.peak_usage', + 'By', + 'Peak memory usage since script start', + ) + ->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($real - $emalloc, ['memory.type' => 'overhead']); + }); + + $meter + ->createObservableGauge( + 'php.memory.limit', + 'By', + 'Memory limit configured in php.ini', + ) + ->observe(static function (ObserverInterface $observer): void { + $limit = self::parseMemoryLimit(ini_get('memory_limit') ?: '-1'); + if ($limit >= 0) { + $observer->observe($limit); + } + }); + } + + public static function parseMemoryLimit(string $limit): int + { + return ini_parse_quantity($limit !== '' ? $limit : '-1'); + } +} diff --git a/src/Metrics/Runtime/src/OpcacheMetrics.php b/src/Metrics/Runtime/src/OpcacheMetrics.php new file mode 100644 index 000000000..8823d819c --- /dev/null +++ b/src/Metrics/Runtime/src/OpcacheMetrics.php @@ -0,0 +1,121 @@ +createObservableUpDownCounter( + 'php.opcache.memory_used', + 'By', + 'OPcache memory used by cached scripts', + ); + $memoryFree = $meter->createObservableUpDownCounter( + 'php.opcache.memory_free', + 'By', + 'OPcache memory free', + ); + $memoryWasted = $meter->createObservableUpDownCounter( + 'php.opcache.memory_wasted', + 'By', + 'OPcache memory wasted (fragmented, not usable without restart)', + ); + $hitRate = $meter->createObservableGauge( + 'php.opcache.hit_rate', + '%', + 'OPcache hit rate percentage', + ); + $hits = $meter->createObservableCounter( + 'php.opcache.hits', + '{hit}', + 'Total OPcache hits', + ); + $misses = $meter->createObservableCounter( + 'php.opcache.misses', + '{miss}', + 'Total OPcache misses', + ); + $cachedScripts = $meter->createObservableGauge( + 'php.opcache.cached_scripts', + '{script}', + 'Number of scripts currently cached in OPcache', + ); + $internedStringsMemoryUsed = $meter->createObservableUpDownCounter( + 'php.opcache.interned_strings.memory_used', + 'By', + 'Memory used by OPcache interned strings', + ); + $internedStringsMemoryFree = $meter->createObservableUpDownCounter( + 'php.opcache.interned_strings.memory_free', + 'By', + 'Memory free in OPcache interned strings buffer', + ); + $internedStringsCount = $meter->createObservableGauge( + '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; + } + $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 new file mode 100644 index 000000000..6d7a32afb --- /dev/null +++ b/src/Metrics/Runtime/src/RuntimeMetrics.php @@ -0,0 +1,61 @@ +disabled. + * Valid group names: memory, gc, opcache, cpu + * + * OPcache and CPU metrics are also skipped when the underlying PHP + * functions are unavailable, regardless of $config->disabled. + */ + 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(); + + if (!\in_array(MemoryMetrics::GROUP, $disabled, true)) { + MemoryMetrics::register( + $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.memory', $version, $schemaUrl), + ); + } + if (!\in_array(GarbageCollectionMetrics::GROUP, $disabled, true)) { + GarbageCollectionMetrics::register( + $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', $version, $schemaUrl), + ); + } + if (!\in_array(CpuMetrics::GROUP, $disabled, true) && CpuMetrics::isAvailable()) { + CpuMetrics::register( + $meterProvider->getMeter(self::INSTRUMENTATION_NAME . '.cpu', $version, $schemaUrl), + ); + } + } + + public static function getInstrumentationName(): string + { + return self::INSTRUMENTATION_NAME; + } +} 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 new file mode 100644 index 000000000..fb785fc18 --- /dev/null +++ b/src/Metrics/Runtime/src/RuntimeMetricsInstrumentation.php @@ -0,0 +1,21 @@ +get(RuntimeMetricsConfig::class); + $config = $config instanceof RuntimeMetricsConfig ? $config : new RuntimeMetricsConfig(); + + RuntimeMetrics::register($context->meterProvider, $config); + } +} diff --git a/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php b/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php new file mode 100644 index 000000000..bd6569a33 --- /dev/null +++ b/src/Metrics/Runtime/tests/Unit/CpuMetricsTest.php @@ -0,0 +1,113 @@ +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(2)) + ->method('createObservableCounter') + ->willReturn($this->createMock(ObservableCounterInterface::class)); + + $meter->expects($this->once()) + ->method('batchObserve') + ->willReturn($this->createMock(ObservableCallbackInterface::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; + + $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); + $cpuObserver->expects($this->exactly(2)) + ->method('observe') + ->with( + $this->isType('float'), + $this->logicalOr( + $this->equalTo(['cpu.mode' => 'user']), + $this->equalTo(['cpu.mode' => 'system']), + ), + ); + + $contextSwitchesObserver = $this->createMock(ObserverInterface::class); + + $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/GarbageCollectionMetricsTest.php b/src/Metrics/Runtime/tests/Unit/GarbageCollectionMetricsTest.php new file mode 100644 index 000000000..da1852e9a --- /dev/null +++ b/src/Metrics/Runtime/tests/Unit/GarbageCollectionMetricsTest.php @@ -0,0 +1,117 @@ +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)); + + $expectedBatchObserve = PHP_VERSION_ID >= 80300 ? 2 : 1; + $meter->expects($this->exactly($expectedBatchObserve)) + ->method('batchObserve') + ->willReturn($this->createMock(ObservableCallbackInterface::class)); + + GarbageCollectionMetrics::register($meter); + } + + public function test_gc_runs_callback_observes_integer(): void + { + $capturedCallback = null; + + $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 $this->createMock(ObservableCallbackInterface::class); + }); + + GarbageCollectionMetrics::register($meter); + + assert($capturedCallback !== null); + + $runsObs = $this->createMock(ObserverInterface::class); + $runsObs->expects($this->once())->method('observe')->with($this->isType('int')); + + $capturedCallback( + $runsObs, + $this->createMock(ObserverInterface::class), + $this->createMock(ObserverInterface::class), + $this->createMock(ObserverInterface::class), + ); + } + + 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)); + $meter->expects($this->exactly(2)) + ->method('batchObserve') + ->willReturn($this->createMock(ObservableCallbackInterface::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 = []; + $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 $this->createMock(ObservableCallbackInterface::class); + }); + + GarbageCollectionMetrics::register($meter); + + // $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[1]( + $collectorObs, + $this->createMock(ObserverInterface::class), + $this->createMock(ObserverInterface::class), + ); + } +} 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/MemoryMetricsTest.php b/src/Metrics/Runtime/tests/Unit/MemoryMetricsTest.php new file mode 100644 index 000000000..fe4402a8b --- /dev/null +++ b/src/Metrics/Runtime/tests/Unit/MemoryMetricsTest.php @@ -0,0 +1,83 @@ +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_emalloc_and_overhead(): 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') + ->with( + $this->isType('int'), + $this->logicalOr( + $this->equalTo(['memory.type' => 'emalloc']), + $this->equalTo(['memory.type' => 'overhead']), + ), + ); + + $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..7e70bb722 --- /dev/null +++ b/src/Metrics/Runtime/tests/Unit/OpcacheMetricsTest.php @@ -0,0 +1,48 @@ +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)); + + $meter->expects($this->once()) + ->method('batchObserve') + ->willReturn($this->createMock(ObservableCallbackInterface::class)); + + OpcacheMetrics::register($meter); + } +} 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 new file mode 100644 index 000000000..893bf0b8a --- /dev/null +++ b/src/Metrics/Runtime/tests/Unit/RuntimeMetricsTest.php @@ -0,0 +1,76 @@ +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)); + + 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($this->makeMeterProvider()); + } + + public function test_instrumentation_name_is_non_empty_string(): void + { + $this->assertNotEmpty(RuntimeMetrics::getInstrumentationName()); + } + + public function test_disabled_memory_group_skips_memory_meter(): void + { + $requestedNames = []; + $meterProvider = $this->createMock(MeterProviderInterface::class); + $meterProvider->method('getMeter') + ->willReturnCallback(function (string $name) use (&$requestedNames): MeterInterface { + $requestedNames[] = $name; + + return $this->makeMeter(); + }); + + RuntimeMetrics::register($meterProvider, new RuntimeMetricsConfig(['memory'])); + + $this->assertNotContains('io.opentelemetry.contrib.php.runtime.memory', $requestedNames); + } + + public function test_disabled_multiple_groups_skips_their_meters(): void + { + $meterProvider = $this->createMock(MeterProviderInterface::class); + $meterProvider->expects($this->never())->method('getMeter'); + + RuntimeMetrics::register($meterProvider, new RuntimeMetricsConfig(['memory', 'gc', 'opcache', 'cpu'])); + } +}