From 86a8ae422318e71f6dc4f625eaaf41bdcab74d72 Mon Sep 17 00:00:00 2001 From: Andrii Havryliuk Date: Fri, 18 Sep 2026 17:31:08 +0200 Subject: [PATCH] feat: add prefix support for scrape time metrics --- README.md | 7 ++++ docs/Specification.md | 2 ++ .../CompilerPass/ScrapeProviderPass.php | 32 +++++++++++++++++ src/DependencyInjection/Configuration.php | 4 +++ .../PrometheusExporterExtension.php | 1 + src/Metrics/MetricsCollector.php | 35 ++++++++++++++++-- src/Resources/config/services.php | 1 + src/Telemetry/MetricNaming.php | 26 ++++++++++++++ src/Telemetry/PrometheusTransport.php | 16 ++------- .../PrometheusExporterExtensionTest.php | 15 ++++++++ .../ScrapeProviderPassTest.php | 36 +++++++++++++++++++ tests/Metrics/MetricsCollectorTest.php | 26 ++++++++++++-- 12 files changed, 184 insertions(+), 17 deletions(-) create mode 100644 src/Telemetry/MetricNaming.php diff --git a/README.md b/README.md index 2dfb8b5..a01c6e3 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ prometheus_exporter: endpoint: allowed_ips: ['127.0.0.1', '::1'] auth_token: null # when set, scrapes need "Authorization: Bearer " + scrape_metrics_namespace: null # null = inherit shopware.telemetry.metrics.namespace, + # '' = no prefix, any other string = custom prefix scrape_providers: # host-local, computed live per scrape, all opt-in instance: false opcache: false @@ -116,6 +118,11 @@ stored metrics): | `php_fpm` | `phpfpm_*` | Prefer the native FPM status page (`pm.status_path` + `?openmetrics`, PHP ≥ 8.1) | | `opensearch` | `opensearch_*` | Prefer a dedicated infrastructure exporter next to the cluster | +Provider metrics carry the `scrape_metrics_namespace` prefix (by default the telemetry namespace, +e.g. `shopware_opcache_memory_used_bytes`). The metric schemas are this bundle's own — they are not +compatible with community exporters for the same subsystems, and the default prefix makes that +explicit and collision-free; set `scrape_metrics_namespace: ''` if you need bare subsystem names. + These providers are not an extension point: plugin metrics belong in the core telemetry abstraction, where every transport (Prometheus, OpenTelemetry, …) receives them. diff --git a/docs/Specification.md b/docs/Specification.md index fae7f97..4a42be4 100644 --- a/docs/Specification.md +++ b/docs/Specification.md @@ -148,6 +148,8 @@ prometheus_exporter: endpoint: allowed_ips: ['127.0.0.1', '::1'] auth_token: null # null = check off; string = required Bearer token + scrape_metrics_namespace: null # null = inherit shopware.telemetry.metrics.namespace, + # '' = no prefix, other string = custom prefix (sanitized) scrape_providers: # per-scraped-host metrics, computed live per scrape instance: false opcache: false diff --git a/src/DependencyInjection/CompilerPass/ScrapeProviderPass.php b/src/DependencyInjection/CompilerPass/ScrapeProviderPass.php index 2869c8d..ad1fdc6 100644 --- a/src/DependencyInjection/CompilerPass/ScrapeProviderPass.php +++ b/src/DependencyInjection/CompilerPass/ScrapeProviderPass.php @@ -4,6 +4,8 @@ use Shopware\PrometheusExporter\Command\ListScrapeProvidersCommand; use Shopware\PrometheusExporter\Metrics\InstanceMetricProvider; +use Shopware\PrometheusExporter\Metrics\MetricsCollector; +use Shopware\PrometheusExporter\Telemetry\MetricNaming; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -32,6 +34,10 @@ class ScrapeProviderPass implements CompilerPassInterface private const PARAMETER = 'prometheus_exporter.scrape_providers'; + private const NAMESPACE_PARAMETER = 'prometheus_exporter.scrape_metrics_namespace'; + + private const CORE_NAMESPACE_PARAMETER = 'shopware.telemetry.metrics.namespace'; + private const BUNDLE_NAMESPACE = 'Shopware\\PrometheusExporter\\'; public function process(ContainerBuilder $container): void @@ -115,5 +121,31 @@ public function process(ContainerBuilder $container): void $container->hasParameter('elasticsearch.enabled') ? new Parameter('elasticsearch.enabled') : false, ); } + + if ($container->hasDefinition(MetricsCollector::class)) { + $container->getDefinition(MetricsCollector::class) + ->replaceArgument(3, $this->resolveNamespace($container)); + } + } + + /** + * scrape_metrics_namespace: null inherits the core telemetry namespace (the stored + * metrics' prefix), '' disables the prefix, any other string is used as-is (sanitized). + */ + private function resolveNamespace(ContainerBuilder $container): string + { + $namespace = $container->hasParameter(self::NAMESPACE_PARAMETER) + ? $container->getParameter(self::NAMESPACE_PARAMETER) + : null; + + if ($namespace === null && $container->hasParameter(self::CORE_NAMESPACE_PARAMETER)) { + $namespace = $container->getParameter(self::CORE_NAMESPACE_PARAMETER); + } + + if (!\is_string($namespace) || $namespace === '') { + return ''; + } + + return MetricNaming::sanitizeMetricName($namespace); } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index d8d0480..674b69a 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -64,6 +64,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->scalarNode('scrape_metrics_namespace') + ->defaultNull() + ->info('Prefix for scrape-time provider metrics: null inherits shopware.telemetry.metrics.namespace (same prefix as the stored metrics), an empty string disables the prefix, any other string is used as-is (sanitized)') + ->end() ->arrayNode('scrape_providers') ->useAttributeAsKey('name') ->normalizeKeys(false) diff --git a/src/DependencyInjection/PrometheusExporterExtension.php b/src/DependencyInjection/PrometheusExporterExtension.php index 84701a7..55e6f37 100644 --- a/src/DependencyInjection/PrometheusExporterExtension.php +++ b/src/DependencyInjection/PrometheusExporterExtension.php @@ -30,6 +30,7 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('prometheus_exporter.endpoint.allowed_ips', $config['endpoint']['allowed_ips']); $container->setParameter('prometheus_exporter.endpoint.auth_token', $config['endpoint']['auth_token']); $container->setParameter('prometheus_exporter.scrape_providers', $config['scrape_providers']); + $container->setParameter('prometheus_exporter.scrape_metrics_namespace', $config['scrape_metrics_namespace']); } public function getAlias(): string diff --git a/src/Metrics/MetricsCollector.php b/src/Metrics/MetricsCollector.php index c988051..221ac0c 100644 --- a/src/Metrics/MetricsCollector.php +++ b/src/Metrics/MetricsCollector.php @@ -3,6 +3,7 @@ namespace Shopware\PrometheusExporter\Metrics; use Prometheus\CollectorRegistry; +use Prometheus\MetricFamilySamples; use Prometheus\RenderTextFormat; use Prometheus\Storage\InMemory; use Psr\Log\LoggerInterface; @@ -11,17 +12,22 @@ * Merges the stored telemetry metrics with the scrape-time provider samples and renders * them in the Prometheus text exposition format. * + * Provider metrics are namespaced centrally here (per prometheus_exporter.scrape_metrics_namespace, + * resolved in ScrapeProviderPass), so providers always register bare subsystem names. + * * @internal */ class MetricsCollector { /** * @param iterable $metricProviders + * @param string $namespace already sanitized; '' renders provider metrics unprefixed */ public function __construct( private readonly CollectorRegistry $registry, private readonly iterable $metricProviders, private readonly LoggerInterface $logger, + private readonly string $namespace = '', ) { } @@ -33,7 +39,7 @@ public function render(): string } /** - * @return list<\Prometheus\MetricFamilySamples> + * @return list */ private function collectScrapeTimeSamples(): array { @@ -50,6 +56,31 @@ private function collectScrapeTimeSamples(): array } } - return \array_values($localRegistry->getMetricFamilySamples()); + return \array_map( + $this->applyNamespace(...), + \array_values($localRegistry->getMetricFamilySamples()), + ); + } + + private function applyNamespace(MetricFamilySamples $family): MetricFamilySamples + { + if ($this->namespace === '') { + return $family; + } + + // sample names carry type suffixes (_bucket/_count/_sum), so prefixing each sample + // name keeps histograms intact + return new MetricFamilySamples([ + 'name' => $this->namespace . '_' . $family->getName(), + 'type' => $family->getType(), + 'help' => $family->getHelp(), + 'labelNames' => $family->getLabelNames(), + 'samples' => \array_map(fn ($sample) => [ + 'name' => $this->namespace . '_' . $sample->getName(), + 'labelNames' => $sample->getLabelNames(), + 'labelValues' => $sample->getLabelValues(), + 'value' => $sample->getValue(), + ], $family->getSamples()), + ]); } } diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 8829e22..7cce0ad 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -49,6 +49,7 @@ service('prometheus_exporter.collector_registry'), tagged_iterator('shopware.prometheus.metrics'), service('logger'), + abstract_arg('effective scrape metrics namespace, resolved by ScrapeProviderPass'), ]); // Controller diff --git a/src/Telemetry/MetricNaming.php b/src/Telemetry/MetricNaming.php new file mode 100644 index 0000000..7bd22f1 --- /dev/null +++ b/src/Telemetry/MetricNaming.php @@ -0,0 +1,26 @@ +namespace === null ? '' : self::sanitizeMetricName($this->namespace); - $name = self::sanitizeMetricName($metric->name); + $namespace = $this->namespace === null ? '' : MetricNaming::sanitizeMetricName($this->namespace); + $name = MetricNaming::sanitizeMetricName($metric->name); $labels = $metric->labels; \ksort($labels); // deterministic series identity, independent of emitter argument order - $labelNames = \array_map(self::sanitizeLabelName(...), \array_keys($labels)); + $labelNames = \array_map(MetricNaming::sanitizeLabelName(...), \array_keys($labels)); $labelValues = \array_map(self::formatLabelValue(...), \array_values($labels)); $value = (float) $metric->value; @@ -94,16 +94,6 @@ private function buckets(string $metricName): ?array return \array_values(\array_map(floatval(...), $buckets)); } - private static function sanitizeMetricName(string $name): string - { - return (string) \preg_replace('/[^a-zA-Z0-9_:]/', '_', $name); - } - - private static function sanitizeLabelName(string $name): string - { - return (string) \preg_replace('/[^a-zA-Z0-9_]/', '_', $name); - } - private static function formatLabelValue(string|bool|float|int $value): string { if (\is_bool($value)) { diff --git a/tests/DependencyInjection/PrometheusExporterExtensionTest.php b/tests/DependencyInjection/PrometheusExporterExtensionTest.php index 38976cc..d7c1697 100644 --- a/tests/DependencyInjection/PrometheusExporterExtensionTest.php +++ b/tests/DependencyInjection/PrometheusExporterExtensionTest.php @@ -43,6 +43,21 @@ public function testMapsConfigurationToParameters(): void static::assertSame(['127.0.0.1', '::1'], $container->getParameter('prometheus_exporter.endpoint.allowed_ips')); static::assertNull($container->getParameter('prometheus_exporter.endpoint.auth_token')); static::assertSame(['php_fpm' => true], $container->getParameter('prometheus_exporter.scrape_providers')); + static::assertNull($container->getParameter('prometheus_exporter.scrape_metrics_namespace'), 'default is null = inherit the core telemetry namespace'); + } + + public function testMapsTheScrapeMetricsNamespace(): void + { + $container = new ContainerBuilder(); + + (new PrometheusExporterExtension())->load([ + [ + 'storage' => ['dsn' => 'redis://localhost'], + 'scrape_metrics_namespace' => '', + ], + ], $container); + + static::assertSame('', $container->getParameter('prometheus_exporter.scrape_metrics_namespace')); } public function testScrapeProvidersDefault(): void diff --git a/tests/DependencyInjection/ScrapeProviderPassTest.php b/tests/DependencyInjection/ScrapeProviderPassTest.php index 4248919..d609cea 100644 --- a/tests/DependencyInjection/ScrapeProviderPassTest.php +++ b/tests/DependencyInjection/ScrapeProviderPassTest.php @@ -7,6 +7,7 @@ use Shopware\PrometheusExporter\Command\ListScrapeProvidersCommand; use Shopware\PrometheusExporter\DependencyInjection\CompilerPass\ScrapeProviderPass; use Shopware\PrometheusExporter\Metrics\InstanceMetricProvider; +use Shopware\PrometheusExporter\Metrics\MetricsCollector; use Shopware\PrometheusExporter\Metrics\OpenSearchMetricProvider; use Shopware\PrometheusExporter\Metrics\PHPFPMMetricProvider; use Shopware\PrometheusExporter\Metrics\PHPInfoMetricProvider; @@ -159,6 +160,41 @@ public function testElasticsearchFlagDefaultsToFalseWithoutTheBundle(): void static::assertFalse($container->getDefinition(InstanceMetricProvider::class)->getArgument(1)); } + public function testNamespaceResolution(): void + { + // [bundle namespace or "unset", core namespace or "unset", expected effective namespace] + $cases = [ + ['acme.metrics', 'shopware', 'acme_metrics', 'custom value wins and is sanitized'], + ['', 'shopware', '', 'empty string disables the prefix'], + [null, 'shopware', 'shopware', 'null inherits the core telemetry namespace'], + [null, 'io.otel.shopware', 'io_otel_shopware', 'inherited value is sanitized'], + [null, null, '', 'core namespace null means no prefix'], + [null, 'unset', '', 'no core parameter means no prefix'], + ['unset', 'shopware', 'shopware', 'missing bundle parameter behaves like null'], + ]; + + foreach ($cases as [$bundleNamespace, $coreNamespace, $expected, $reason]) { + $container = new ContainerBuilder(); + $container->setParameter('prometheus_exporter.scrape_providers', []); + if ($bundleNamespace !== 'unset') { + $container->setParameter('prometheus_exporter.scrape_metrics_namespace', $bundleNamespace); + } + if ($coreNamespace !== 'unset') { + $container->setParameter('shopware.telemetry.metrics.namespace', $coreNamespace); + } + $container->register(MetricsCollector::class) + ->setArguments([null, [], null, new AbstractArgument('injected by the pass')]); + + (new ScrapeProviderPass())->process($container); + + static::assertSame( + $expected, + $container->getDefinition(MetricsCollector::class)->getArgument(3), + $reason, + ); + } + } + /** * @param array $scrapeProviders */ diff --git a/tests/Metrics/MetricsCollectorTest.php b/tests/Metrics/MetricsCollectorTest.php index 847249a..804a914 100644 --- a/tests/Metrics/MetricsCollectorTest.php +++ b/tests/Metrics/MetricsCollectorTest.php @@ -55,6 +55,28 @@ public function collect(CollectorRegistry $registry): void static::assertSame([], $this->registry->getMetricFamilySamples()); } + public function testNamespacePrefixesProviderMetricsButNotStoredOnes(): void + { + $this->registry->getOrRegisterCounter('', 'stored_metric', 'stored')->incBy(3); + + $provider = new class implements MetricProviderInterface { + public function collect(CollectorRegistry $registry): void + { + $registry->getOrRegisterGauge('', 'local_metric', 'scrape-time', ['shard'])->set(1.0, ['a']); + $registry->getOrRegisterHistogram('', 'local_duration', 'scrape-time', [], [1.0, 5.0])->observe(2.5); + } + }; + + $output = $this->createCollector(providers: [$provider], namespace: 'shopware')->render(); + + static::assertStringContainsString('stored_metric 3', $output, 'stored metrics are namespaced by the transport, not here'); + static::assertStringContainsString('shopware_local_metric{shard="a"} 1', $output); + static::assertStringContainsString('shopware_local_duration_bucket{le="5"} 1', $output); + static::assertStringContainsString('shopware_local_duration_count 1', $output); + static::assertStringContainsString('shopware_local_duration_sum 2.5', $output); + static::assertStringNotContainsString("\nlocal_metric", $output); + } + public function testFailingProviderDoesNotBreakTheScrape(): void { $failing = new class implements MetricProviderInterface { @@ -81,8 +103,8 @@ public function collect(CollectorRegistry $registry): void /** * @param list $providers */ - private function createCollector(array $providers = [], ?LoggerInterface $logger = null): MetricsCollector + private function createCollector(array $providers = [], ?LoggerInterface $logger = null, string $namespace = ''): MetricsCollector { - return new MetricsCollector($this->registry, $providers, $logger ?? new NullLogger()); + return new MetricsCollector($this->registry, $providers, $logger ?? new NullLogger(), $namespace); } }