diff --git a/Directory.Packages.props b/Directory.Packages.props index 309672642..9d5b24dca 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index 19314c344..860532c00 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -12,6 +12,10 @@ using System.Threading.Tasks; using StackExchange.Redis.Configuration; +#if NET6_0_OR_GREATER +using System.Diagnostics.Metrics; +#endif + namespace StackExchange.Redis { /// @@ -619,6 +623,14 @@ public int ConfigCheckSeconds set => configCheckSeconds = value; } +#if NET6_0_OR_GREATER + /// + /// A factory method able to inject the object to use + /// when emitting metrics. Used by tests. + /// + internal Func? MeterFactory { get; set; } +#endif + /// /// Parse the configuration from a comma-delimited configuration string. /// diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.Metrics.cs b/src/StackExchange.Redis/ConnectionMultiplexer.Metrics.cs new file mode 100644 index 000000000..97f580224 --- /dev/null +++ b/src/StackExchange.Redis/ConnectionMultiplexer.Metrics.cs @@ -0,0 +1,19 @@ +#if NET6_0_OR_GREATER + +namespace StackExchange.Redis; + +public partial class ConnectionMultiplexer +{ + internal RedisMetrics Metrics { get; } + + private static RedisMetrics GetMetrics(ConfigurationOptions configuration) + { + if (configuration.MeterFactory is not null) + { + return new RedisMetrics(configuration.MeterFactory()); + } + + return RedisMetrics.Default; + } +} +#endif diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 68d58253a..988632bef 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -128,6 +128,9 @@ static ConnectionMultiplexer() private ConnectionMultiplexer(ConfigurationOptions configuration, ServerType? serverType = null, EndPointCollection? endpoints = null) { RawConfig = configuration ?? throw new ArgumentNullException(nameof(configuration)); +#if NET6_0_OR_GREATER + Metrics = GetMetrics(configuration); +#endif EndPoints = endpoints ?? RawConfig.EndPoints.Clone(); EndPoints.SetDefaultPorts(serverType, ssl: RawConfig.Ssl); @@ -1939,6 +1942,9 @@ internal void UpdateClusterRange(ClusterConfiguration configuration) private bool PrepareToPushMessageToBridge(Message message, ResultProcessor? processor, IResultBox? resultBox, [NotNullWhen(true)] ref ServerEndPoint? server) { message.SetSource(processor, resultBox); +#if NET6_0_OR_GREATER + message.SetMetrics(Metrics); +#endif if (server == null) { diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index a76001756..d3e57eb5f 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -84,6 +84,10 @@ internal abstract class Message : ICompletable internal DateTime CreatedDateTime; internal long CreatedTimestamp; +#if NET6_0_OR_GREATER + private RedisMetrics? metrics; +#endif + protected Message(int db, CommandFlags flags, RedisCommand command) { bool dbNeeded = RequiresDatabase(command); @@ -134,6 +138,13 @@ internal void SetPrimaryOnly() } } +#if NET6_0_OR_GREATER + internal void SetMetrics(RedisMetrics redisMetrics) + { + metrics = redisMetrics; + } +#endif + internal void SetProfileStorage(ProfiledCommand storage) { performance = storage; @@ -374,6 +385,9 @@ public void Complete() // set the completion/performance data performance?.SetCompleted(); +#if NET6_0_OR_GREATER + metrics?.OnMessageComplete(this, currBox); +#endif currBox?.ActivateContinuations(); } @@ -441,7 +455,7 @@ internal static Message Create(int db, CommandFlags flags, RedisCommand command, 3 => new CommandKeyKeyValueValueValueMessage(db, flags, command, key0, key1, values[0], values[1], values[2]), 4 => new CommandKeyKeyValueValueValueValueMessage(db, flags, command, key0, key1, values[0], values[1], values[2], values[3]), 5 => new CommandKeyKeyValueValueValueValueValueMessage(db, flags, command, key0, key1, values[0], values[1], values[2], values[3], values[4]), - 6 => new CommandKeyKeyValueValueValueValueValueValueMessage(db, flags, command, key0, key1, values[0], values[1], values[2], values[3],values[4],values[5]), + 6 => new CommandKeyKeyValueValueValueValueValueValueMessage(db, flags, command, key0, key1, values[0], values[1], values[2], values[3], values[4], values[5]), 7 => new CommandKeyKeyValueValueValueValueValueValueValueMessage(db, flags, command, key0, key1, values[0], values[1], values[2], values[3], values[4], values[5], values[6]), _ => new CommandKeyKeyValuesMessage(db, flags, command, key0, key1, values), }; diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 68ea70105..209b474c0 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -350,6 +350,9 @@ internal string GetStormLog() internal void IncrementOpCount() { Interlocked.Increment(ref operationCount); +#if NET6_0_OR_GREATER + Multiplexer.Metrics.IncrementOperationCount(Name); +#endif } internal void KeepAlive() @@ -1404,12 +1407,22 @@ private void LogNonPreferred(CommandFlags flags, bool isReplica) if (isReplica) { if (Message.GetPrimaryReplicaFlags(flags) == CommandFlags.PreferMaster) + { Interlocked.Increment(ref nonPreferredEndpointCount); +#if NET6_0_OR_GREATER + Multiplexer.Metrics.IncrementNonPreferredEndpointCount(Name); +#endif + } } else { if (Message.GetPrimaryReplicaFlags(flags) == CommandFlags.PreferReplica) + { Interlocked.Increment(ref nonPreferredEndpointCount); +#if NET6_0_OR_GREATER + Multiplexer.Metrics.IncrementNonPreferredEndpointCount(Name); +#endif + } } } } diff --git a/src/StackExchange.Redis/RedisMetrics.cs b/src/StackExchange.Redis/RedisMetrics.cs new file mode 100644 index 000000000..cb2561804 --- /dev/null +++ b/src/StackExchange.Redis/RedisMetrics.cs @@ -0,0 +1,79 @@ +#if NET6_0_OR_GREATER + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace StackExchange.Redis; + +internal sealed class RedisMetrics +{ + private static readonly double s_tickFrequency = (double)TimeSpan.TicksPerSecond / Stopwatch.Frequency; + // cache these boxed boolean values so we don't allocate on each usage. + private static readonly object s_trueBox = true; + private static readonly object s_falseBox = false; + + private readonly Meter _meter; + private readonly Counter _operationCount; + private readonly Histogram _messageDuration; + private readonly Counter _nonPreferredEndpointCount; + + public static readonly RedisMetrics Default = new RedisMetrics(); + + public RedisMetrics(Meter? meter = null) + { + _meter = meter ?? new Meter("StackExchange.Redis"); + + _operationCount = _meter.CreateCounter( + "db.redis.operation.count", + description: "The number of operations performed."); + + _messageDuration = _meter.CreateHistogram( + "db.redis.duration", + unit: "s", + description: "Measures the duration of outbound message requests."); + + _nonPreferredEndpointCount = _meter.CreateCounter( + "db.redis.non_preferred_endpoint.count", + description: "Indicates the total number of messages dispatched to a non-preferred endpoint, for example sent to a primary when the caller stated a preference of replica."); + } + + public void IncrementOperationCount(string endpoint) + { + _operationCount.Add(1, + new KeyValuePair("endpoint", endpoint)); + } + + public void OnMessageComplete(Message message, IResultBox? result) + { + // The caller ensures we can don't record on the same resultBox from two threads. + // 'result' can be null if this method is called for the same message more than once. + if (result is not null && _messageDuration.Enabled) + { + // Stopwatch.GetElapsedTime is only available in net7.0+ + // https://github.com/dotnet/runtime/blob/ae068fec6ede58d2a5b343c5ac41c9ca8715fa47/src/libraries/System.Private.CoreLib/src/System/Diagnostics/Stopwatch.cs#L129-L137 + var now = Stopwatch.GetTimestamp(); + var duration = new TimeSpan((long)((now - message.CreatedTimestamp) * s_tickFrequency)); + + var tags = new TagList + { + { "db.redis.async", result.IsAsync ? s_trueBox : s_falseBox }, + { "db.redis.faulted", result.IsFaulted ? s_trueBox : s_falseBox } + // TODO: can we pass endpoint here? + // should we log the Db? + // { "db.redis.database_index", message.Db }, + }; + + _messageDuration.Record(duration.TotalSeconds, tags); + } + } + + public void IncrementNonPreferredEndpointCount(string endpoint) + { + _nonPreferredEndpointCount.Add(1, + new KeyValuePair("endpoint", endpoint)); + } +} + +#endif diff --git a/tests/StackExchange.Redis.Tests/MetricsTests.cs b/tests/StackExchange.Redis.Tests/MetricsTests.cs new file mode 100644 index 000000000..6824bc025 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/MetricsTests.cs @@ -0,0 +1,44 @@ +#if NET6_0_OR_GREATER +#pragma warning disable TBD // MetricCollector is for evaluation purposes only and is subject to change or removal in future updates. + +using Microsoft.Extensions.Telemetry.Testing.Metering; +using System.Diagnostics.Metrics; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace StackExchange.Redis.Tests; + +public class MetricsTests : TestBase +{ + public MetricsTests(ITestOutputHelper output) : base(output) { } + + [Fact] + public async Task SimpleCommandDuration() + { + var options = ConfigurationOptions.Parse(GetConfiguration()); + + using var meter = new Meter("StackExchange.Redis.Tests"); + using var collector = new MetricCollector(meter, "db.redis.duration"); + + options.MeterFactory = () => meter; + + using var conn = await ConnectionMultiplexer.ConnectAsync(options, Writer); + var db = conn.GetDatabase(); + + RedisKey key = Me(); + string? g1 = await db.StringGetAsync(key); + Assert.Null(g1); + + await collector.WaitForMeasurementsAsync(1); + + Assert.Collection(collector.GetMeasurementSnapshot(), + measurement => + { + // Built-in + Assert.Equal(true, measurement.Tags["db.redis.async"]); + Assert.Equal(false, measurement.Tags["db.redis.faulted"]); + }); + } +} +#endif diff --git a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj index 112513a85..9be4e94fb 100644 --- a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj +++ b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj @@ -33,4 +33,8 @@ + + + +