Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ prometheus_exporter:
endpoint:
allowed_ips: ['127.0.0.1', '::1']
auth_token: null # when set, scrapes need "Authorization: Bearer <token>"
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
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions docs/Specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions src/DependencyInjection/CompilerPass/ScrapeProviderPass.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
4 changes: 4 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/DependencyInjection/PrometheusExporterExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 33 additions & 2 deletions src/Metrics/MetricsCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<MetricProviderInterface> $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 = '',
) {
}

Expand All @@ -33,7 +39,7 @@ public function render(): string
}

/**
* @return list<\Prometheus\MetricFamilySamples>
* @return list<MetricFamilySamples>
*/
private function collectScrapeTimeSamples(): array
{
Expand All @@ -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()),
]);
}
}
1 change: 1 addition & 0 deletions src/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions src/Telemetry/MetricNaming.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php declare(strict_types=1);

namespace Shopware\PrometheusExporter\Telemetry;

/**
* Prometheus naming rules shared by the transport (stored metrics) and the scrape-time
* metric pipeline.
*
* @internal
*/
final class MetricNaming
{
public static function sanitizeMetricName(string $name): string
{
return (string) \preg_replace('/[^a-zA-Z0-9_:]/', '_', $name);
}

public static function sanitizeLabelName(string $name): string
{
return (string) \preg_replace('/[^a-zA-Z0-9_]/', '_', $name);
}

private function __construct()
{
}
}
16 changes: 3 additions & 13 deletions src/Telemetry/PrometheusTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ public function flush(): void

private function write(Metric $metric): void
{
$namespace = $this->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;
Expand Down Expand Up @@ -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)) {
Expand Down
15 changes: 15 additions & 0 deletions tests/DependencyInjection/PrometheusExporterExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions tests/DependencyInjection/ScrapeProviderPassTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, bool> $scrapeProviders
*/
Expand Down
26 changes: 24 additions & 2 deletions tests/Metrics/MetricsCollectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -81,8 +103,8 @@ public function collect(CollectorRegistry $registry): void
/**
* @param list<MetricProviderInterface> $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);
}
}
Loading