From ce46a9145a9059c7faf7e1901d95004f648e289d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 20 Jul 2026 15:30:41 -0400 Subject: [PATCH] fix(telemetry): preserve built-in meter discovery Signed-off-by: Yordis Prieto --- docs/diagnostics/metrics.md | 2 + src/EventStore.ClusterNode/metricsconfig.json | 5 +- .../TelemetryMeterFactoryTests.cs | 8 +-- .../TelemetryMeterInstrumentationTests.cs | 60 +++++++++++++++++++ src/EventStore.Core/ClusterVNodeStartup.cs | 2 +- .../Diagnostics/TelemetryMeterFactory.cs | 18 +----- .../TelemetryMeterInstrumentation.cs | 45 ++++++++++++++ src/EventStore.Core/MetricsBootstrapper.cs | 2 +- .../ProjectionsSubsystem.cs | 2 +- 9 files changed, 114 insertions(+), 30 deletions(-) create mode 100644 src/EventStore.Core.XUnit.Tests/OpenTelemetry/TelemetryMeterInstrumentationTests.cs create mode 100644 src/EventStore.Core/Diagnostics/TelemetryMeterInstrumentation.cs diff --git a/docs/diagnostics/metrics.md b/docs/diagnostics/metrics.md index 487166a5e..63f8c5055 100644 --- a/docs/diagnostics/metrics.md +++ b/docs/diagnostics/metrics.md @@ -4,6 +4,8 @@ TrogonEventStore collects metrics in [Prometheus format](https://prometheus.io/d In addition, TrogonEventStore can actively export metrics to a specified endpoint using the [OpenTelemetry Protocol](https://opentelemetry.io/docs/specs/otel/protocol/) (OTLP). +The built-in Core and Projections meter sources are always registered. Use the `Meters` array in `metricsconfig.json` only when additional components expose their own `System.Diagnostics.Metrics` meter sources. + ## Metrics reference ### Caches diff --git a/src/EventStore.ClusterNode/metricsconfig.json b/src/EventStore.ClusterNode/metricsconfig.json index b1bc02497..ac6aaae24 100644 --- a/src/EventStore.ClusterNode/metricsconfig.json +++ b/src/EventStore.ClusterNode/metricsconfig.json @@ -8,10 +8,7 @@ "Enabled": false }, - "Meters": [ - "EventStore.Core", - "EventStore.Projections.Core" - ], + "Meters": [], "Statuses": { "Index": true, diff --git a/src/EventStore.Core.XUnit.Tests/OpenTelemetry/TelemetryMeterFactoryTests.cs b/src/EventStore.Core.XUnit.Tests/OpenTelemetry/TelemetryMeterFactoryTests.cs index 7ad0b0a5f..92f0ff2c2 100644 --- a/src/EventStore.Core.XUnit.Tests/OpenTelemetry/TelemetryMeterFactoryTests.cs +++ b/src/EventStore.Core.XUnit.Tests/OpenTelemetry/TelemetryMeterFactoryTests.cs @@ -1,5 +1,4 @@ using System; -using System.Reflection; using EventStore.Core.Diagnostics; using FluentAssertions; using Xunit; @@ -9,15 +8,12 @@ namespace EventStore.Core.XUnit.Tests.OpenTelemetry; public class TelemetryMeterFactoryTests { [Fact] - public void UsesTheCurrentServerVersionForTheInstrumentationScope() + public void UsesTheCurrentInstrumentationVersionForTheScope() { using var meter = TelemetryMeterFactory.Create("test-scope"); - var informationalVersion = typeof(TelemetryMeterFactory).Assembly - .GetCustomAttribute()! - .InformationalVersion; meter.Name.Should().Be("test-scope"); - meter.Version.Should().Be(informationalVersion.Split('+', 2)[0]); + meter.Version.Should().Be(TelemetryMeterInstrumentation.ScopeVersion); } [Theory] diff --git a/src/EventStore.Core.XUnit.Tests/OpenTelemetry/TelemetryMeterInstrumentationTests.cs b/src/EventStore.Core.XUnit.Tests/OpenTelemetry/TelemetryMeterInstrumentationTests.cs new file mode 100644 index 000000000..1ea0861a7 --- /dev/null +++ b/src/EventStore.Core.XUnit.Tests/OpenTelemetry/TelemetryMeterInstrumentationTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Reflection; +using EventStore.Core.Diagnostics; +using FluentAssertions; +using Xunit; + +namespace EventStore.Core.XUnit.Tests.OpenTelemetry; + +public class TelemetryMeterInstrumentationTests +{ + [Fact] + public void PreservesBuiltInMeterNames() + { + TelemetryMeterInstrumentation.CoreName.Should().Be("EventStore.Core"); + TelemetryMeterInstrumentation.ProjectionsName.Should().Be("EventStore.Projections.Core"); + } + + [Fact] + public void UsesTheEmbeddedAssemblyVersionForTheScope() + { + var informationalVersion = typeof(TelemetryMeterInstrumentation).Assembly + .GetCustomAttribute()! + .InformationalVersion; + + TelemetryMeterInstrumentation.ScopeVersion.Should().Be(informationalVersion.Split('+', 2)[0]); + } + + [Fact] + public void IncludesBuiltInMetersOnceBeforeAdditionalMeters() + { + var names = TelemetryMeterInstrumentation.GetNames([ + TelemetryMeterInstrumentation.CoreName, + "Custom.Component", + ]); + + names.Should().Equal( + TelemetryMeterInstrumentation.CoreName, + TelemetryMeterInstrumentation.ProjectionsName, + "Custom.Component"); + } + + [Fact] + public void RejectsMissingAdditionalMeterCollection() + { + var action = () => TelemetryMeterInstrumentation.GetNames(null!); + + action.Should().Throw(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void RejectsInvalidAdditionalMeterNames(string meterName) + { + var action = () => TelemetryMeterInstrumentation.GetNames([meterName]); + + action.Should().Throw(); + } +} diff --git a/src/EventStore.Core/ClusterVNodeStartup.cs b/src/EventStore.Core/ClusterVNodeStartup.cs index ee116e925..a6e1dbdc4 100644 --- a/src/EventStore.Core/ClusterVNodeStartup.cs +++ b/src/EventStore.Core/ClusterVNodeStartup.cs @@ -301,7 +301,7 @@ private static void ConfigureMetrics( { meterOptions .SetResourceBuilder(serviceIdentity.CreateResourceBuilder()) - .AddMeter(metricsConfiguration.Meters) + .AddMeter(TelemetryMeterInstrumentation.GetNames(metricsConfiguration.Meters)) .AddView(i => { if (i.Name == MetricsBootstrapper.LogicalChunkReadDistributionName) diff --git a/src/EventStore.Core/Diagnostics/TelemetryMeterFactory.cs b/src/EventStore.Core/Diagnostics/TelemetryMeterFactory.cs index bb6028383..6e97ed1ec 100644 --- a/src/EventStore.Core/Diagnostics/TelemetryMeterFactory.cs +++ b/src/EventStore.Core/Diagnostics/TelemetryMeterFactory.cs @@ -1,29 +1,13 @@ using System; using System.Diagnostics.Metrics; -using System.Reflection; namespace EventStore.Core.Diagnostics; public static class TelemetryMeterFactory { - private static readonly string InstrumentationScopeVersion = GetInstrumentationScopeVersion(); - public static Meter Create(string instrumentationScopeName) { ArgumentException.ThrowIfNullOrWhiteSpace(instrumentationScopeName); - return new Meter(instrumentationScopeName, InstrumentationScopeVersion); - } - - private static string GetInstrumentationScopeVersion() - { - var informationalVersion = typeof(TelemetryMeterFactory).Assembly - .GetCustomAttribute()? - .InformationalVersion; - if (string.IsNullOrWhiteSpace(informationalVersion)) - { - throw new InvalidOperationException("The telemetry assembly has no informational version."); - } - - return informationalVersion.Split('+', 2)[0]; + return new Meter(instrumentationScopeName, TelemetryMeterInstrumentation.ScopeVersion); } } diff --git a/src/EventStore.Core/Diagnostics/TelemetryMeterInstrumentation.cs b/src/EventStore.Core/Diagnostics/TelemetryMeterInstrumentation.cs new file mode 100644 index 000000000..17d830b9c --- /dev/null +++ b/src/EventStore.Core/Diagnostics/TelemetryMeterInstrumentation.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace EventStore.Core.Diagnostics; + +public static class TelemetryMeterInstrumentation +{ + public const string CoreName = "EventStore.Core"; + public const string ProjectionsName = "EventStore.Projections.Core"; + + public static string ScopeVersion { get; } = GetScopeVersion(); + + public static string[] GetNames(IEnumerable additionalMeterNames) + { + ArgumentNullException.ThrowIfNull(additionalMeterNames); + + var names = new List { CoreName, ProjectionsName }; + var seen = new HashSet(names, StringComparer.Ordinal); + + foreach (var name in additionalMeterNames) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + if (seen.Add(name)) + { + names.Add(name); + } + } + + return names.ToArray(); + } + + private static string GetScopeVersion() + { + var informationalVersion = typeof(TelemetryMeterInstrumentation).Assembly + .GetCustomAttribute()? + .InformationalVersion; + if (string.IsNullOrWhiteSpace(informationalVersion)) + { + throw new InvalidOperationException("The telemetry assembly has no informational version."); + } + + return informationalVersion.Split('+', 2)[0]; + } +} diff --git a/src/EventStore.Core/MetricsBootstrapper.cs b/src/EventStore.Core/MetricsBootstrapper.cs index e1c18e495..50527f6f0 100644 --- a/src/EventStore.Core/MetricsBootstrapper.cs +++ b/src/EventStore.Core/MetricsBootstrapper.cs @@ -84,7 +84,7 @@ public static void Bootstrap( return; } - var coreMeter = TelemetryMeterFactory.Create("EventStore.Core"); + var coreMeter = TelemetryMeterFactory.Create(TelemetryMeterInstrumentation.CoreName); var statusMetric = new StatusMetric(coreMeter, "eventstore-statuses"); var grpcMethodMetric = new DurationMetric(coreMeter, "eventstore-grpc-method-duration"); var gossipLatencyMetric = new DurationMetric(coreMeter, "eventstore-gossip-latency"); diff --git a/src/EventStore.Projections.Core/ProjectionsSubsystem.cs b/src/EventStore.Projections.Core/ProjectionsSubsystem.cs index ced9a5f2e..322305152 100644 --- a/src/EventStore.Projections.Core/ProjectionsSubsystem.cs +++ b/src/EventStore.Projections.Core/ProjectionsSubsystem.cs @@ -205,7 +205,7 @@ private void ConfigureProjectionMetrics(bool isEnabled) return; } - var projectionMeter = TelemetryMeterFactory.Create("EventStore.Projections.Core"); + var projectionMeter = TelemetryMeterFactory.Create(TelemetryMeterInstrumentation.ProjectionsName); var tracker = new ProjectionTracker(); _projectionTracker = tracker;