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 @@
+[](https://github.com/open-telemetry/opentelemetry-php-contrib/releases)
+[](https://github.com/open-telemetry/opentelemetry-php/issues)
+[](https://github.com/open-telemetry/opentelemetry-php-contrib/tree/main/src/Metrics/Runtime)
+[](https://packagist.org/packages/open-telemetry/opentelemetry-metrics-runtime/)
+[](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']));
+ }
+}