Skip to content

Commit 448b2e2

Browse files
Automatic Tags and Span Aggregates for Metrics (#3191)
1 parent 0edfe93 commit 448b2e2

17 files changed

+520
-126
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Features
66

77
- Added Crons support via `SentrySdk.CaptureCheckIn` and an integration with Hangfire ([#3128](https://github.com/getsentry/sentry-dotnet/pull/3128))
8+
- Common tags set automatically for metrics and metrics summaries are attached to Spans ([#3191](https://github.com/getsentry/sentry-dotnet/pull/3191))
89

910
### Fixes
1011

src/Sentry/IMetricHub.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,7 @@ internal interface IMetricHub
1818
/// Starts a child span for the current transaction or, if there is no active transaction, starts a new transaction.
1919
/// </summary>
2020
ISpan StartSpan(string operation, string description);
21+
22+
/// <inheritdoc cref="IHub.GetSpan"/>
23+
ISpan? GetSpan();
2124
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Sentry.Internal.Extensions;
2+
3+
internal static class DictionaryExtensions
4+
{
5+
public static void AddIfNotNullOrEmpty<TKey>(this IDictionary<TKey, string> dictionary, TKey key, string? value)
6+
where TKey : notnull
7+
{
8+
if (!string.IsNullOrEmpty(value))
9+
{
10+
dictionary.Add(key, value);
11+
}
12+
}
13+
}

src/Sentry/MetricAggregator.cs

Lines changed: 24 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -58,61 +58,6 @@ internal MetricAggregator(SentryOptions options, IMetricHub metricHub,
5858
}
5959
}
6060

61-
internal static string GetMetricBucketKey(MetricType type, string metricKey, MeasurementUnit unit,
62-
IDictionary<string, string>? tags)
63-
{
64-
var typePrefix = type.ToStatsdType();
65-
var serializedTags = GetTagsKey(tags);
66-
67-
return $"{typePrefix}_{metricKey}_{unit}_{serializedTags}";
68-
}
69-
70-
internal static string GetTagsKey(IDictionary<string, string>? tags)
71-
{
72-
if (tags == null || tags.Count == 0)
73-
{
74-
return string.Empty;
75-
}
76-
77-
const char pairDelimiter = ','; // Delimiter between key-value pairs
78-
const char keyValueDelimiter = '='; // Delimiter between key and value
79-
const char escapeChar = '\\';
80-
81-
var builder = new StringBuilder();
82-
83-
foreach (var tag in tags)
84-
{
85-
// Escape delimiters in key and value
86-
var key = EscapeString(tag.Key, pairDelimiter, keyValueDelimiter, escapeChar);
87-
var value = EscapeString(tag.Value, pairDelimiter, keyValueDelimiter, escapeChar);
88-
89-
if (builder.Length > 0)
90-
{
91-
builder.Append(pairDelimiter);
92-
}
93-
94-
builder.Append(key).Append(keyValueDelimiter).Append(value);
95-
}
96-
97-
return builder.ToString();
98-
99-
static string EscapeString(string input, params char[] charsToEscape)
100-
{
101-
var escapedString = new StringBuilder(input.Length);
102-
103-
foreach (var ch in input)
104-
{
105-
if (charsToEscape.Contains(ch))
106-
{
107-
escapedString.Append(escapeChar); // Prefix with escape character
108-
}
109-
escapedString.Append(ch);
110-
}
111-
112-
return escapedString.ToString();
113-
}
114-
}
115-
11661
/// <inheritdoc cref="IMetricAggregator.Increment"/>
11762
public void Increment(string key,
11863
double value = 1.0,
@@ -186,19 +131,28 @@ private void Emit(
186131
timestamp ??= DateTimeOffset.UtcNow;
187132
unit ??= MeasurementUnit.None;
188133

134+
var updatedTags = tags != null ? new Dictionary<string, string>(tags) : new Dictionary<string, string>();
135+
updatedTags.AddIfNotNullOrEmpty("release", _options.Release);
136+
updatedTags.AddIfNotNullOrEmpty("environment", _options.Environment);
137+
var span = _metricHub.GetSpan();
138+
if (span?.GetTransaction() is { } transaction)
139+
{
140+
updatedTags.AddIfNotNullOrEmpty("transaction", transaction.TransactionName);
141+
}
142+
189143
Func<string, Metric> addValuesFactory = type switch
190144
{
191-
MetricType.Counter => _ => new CounterMetric(key, value, unit.Value, tags, timestamp),
192-
MetricType.Gauge => _ => new GaugeMetric(key, value, unit.Value, tags, timestamp),
193-
MetricType.Distribution => _ => new DistributionMetric(key, value, unit.Value, tags, timestamp),
194-
MetricType.Set => _ => new SetMetric(key, (int)value, unit.Value, tags, timestamp),
145+
MetricType.Counter => _ => new CounterMetric(key, value, unit.Value, updatedTags, timestamp),
146+
MetricType.Gauge => _ => new GaugeMetric(key, value, unit.Value, updatedTags, timestamp),
147+
MetricType.Distribution => _ => new DistributionMetric(key, value, unit.Value, updatedTags, timestamp),
148+
MetricType.Set => _ => new SetMetric(key, (int)value, unit.Value, updatedTags, timestamp),
195149
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown MetricType")
196150
};
197151

198152
var timeBucket = GetOrAddTimeBucket(timestamp.Value.GetTimeBucketKey());
199153

200154
timeBucket.AddOrUpdate(
201-
GetMetricBucketKey(type, key, unit.Value, tags),
155+
MetricHelper.GetMetricBucketKey(type, key, unit.Value, updatedTags),
202156
addValuesFactory,
203157
(_, metric) =>
204158
{
@@ -223,6 +177,16 @@ private void Emit(
223177
{
224178
RecordCodeLocation(type, key, unit.Value, stackLevel + 1, timestamp.Value);
225179
}
180+
181+
switch (span)
182+
{
183+
case TransactionTracer transactionTracer:
184+
transactionTracer.MetricsSummary.Add(type, key, value, unit, tags);
185+
break;
186+
case SpanTracer spanTracer:
187+
spanTracer.MetricsSummary.Add(type, key, value, unit, tags);
188+
break;
189+
}
226190
}
227191

228192
private ConcurrentDictionary<string, Metric> GetOrAddTimeBucket(long bucketKey)

src/Sentry/MetricHelper.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Sentry.Internal;
2+
using Sentry.Protocol.Metrics;
23

34
namespace Sentry;
45

@@ -64,4 +65,59 @@ internal static DateTimeOffset GetCutoff() => DateTimeOffset.UtcNow
6465
private static readonly Regex InvalidMetricUnitCharacters = new(InvalidMetricUnitCharactersPattern, RegexOptions.Compiled);
6566
internal static string SanitizeMetricUnit(string input) => InvalidMetricUnitCharacters.Replace(input, "_");
6667
#endif
68+
69+
public static string GetMetricBucketKey(MetricType type, string metricKey, MeasurementUnit unit,
70+
IDictionary<string, string>? tags)
71+
{
72+
var typePrefix = type.ToStatsdType();
73+
var serializedTags = GetTagsKey(tags);
74+
75+
return $"{typePrefix}_{metricKey}_{unit}_{serializedTags}";
76+
}
77+
78+
internal static string GetTagsKey(IDictionary<string, string>? tags)
79+
{
80+
if (tags == null || tags.Count == 0)
81+
{
82+
return string.Empty;
83+
}
84+
85+
const char pairDelimiter = ','; // Delimiter between key-value pairs
86+
const char keyValueDelimiter = '='; // Delimiter between key and value
87+
const char escapeChar = '\\';
88+
89+
var builder = new StringBuilder();
90+
91+
foreach (var tag in tags)
92+
{
93+
// Escape delimiters in key and value
94+
var key = EscapeString(tag.Key, pairDelimiter, keyValueDelimiter, escapeChar);
95+
var value = EscapeString(tag.Value, pairDelimiter, keyValueDelimiter, escapeChar);
96+
97+
if (builder.Length > 0)
98+
{
99+
builder.Append(pairDelimiter);
100+
}
101+
102+
builder.Append(key).Append(keyValueDelimiter).Append(value);
103+
}
104+
105+
return builder.ToString();
106+
107+
static string EscapeString(string input, params char[] charsToEscape)
108+
{
109+
var escapedString = new StringBuilder(input.Length);
110+
111+
foreach (var ch in input)
112+
{
113+
if (charsToEscape.Contains(ch))
114+
{
115+
escapedString.Append(escapeChar); // Prefix with escape character
116+
}
117+
escapedString.Append(ch);
118+
}
119+
120+
return escapedString.ToString();
121+
}
122+
}
67123
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using Sentry.Protocol.Metrics;
2+
3+
namespace Sentry;
4+
5+
internal class MetricsSummaryAggregator
6+
{
7+
private Lazy<ConcurrentDictionary<string, SpanMetric>> LazyMeasurements { get; } = new();
8+
internal ConcurrentDictionary<string, SpanMetric> Measurements => LazyMeasurements.Value;
9+
10+
public void Add(
11+
MetricType ty,
12+
string key,
13+
double value = 1.0,
14+
MeasurementUnit? unit = null,
15+
IDictionary<string, string>? tags = null
16+
)
17+
{
18+
unit ??= MeasurementUnit.None;
19+
20+
var bucketKey = MetricHelper.GetMetricBucketKey(ty, key, unit.Value, tags);
21+
22+
Measurements.AddOrUpdate(
23+
bucketKey,
24+
_ => new SpanMetric(ty, key, value, unit.Value, tags),
25+
(_, metric) =>
26+
{
27+
// This prevents multiple threads from trying to mutate the metric at the same time. The only other
28+
// operation performed against metrics is adding one to the bucket (guaranteed to be atomic due to
29+
// the use of a ConcurrentDictionary for the timeBucket).
30+
lock (metric)
31+
{
32+
metric.Add(value);
33+
}
34+
return metric;
35+
});
36+
}
37+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using Sentry.Extensibility;
2+
3+
namespace Sentry.Protocol.Metrics;
4+
5+
internal class MetricsSummary : ISentryJsonSerializable
6+
{
7+
private readonly IDictionary<string, List<SpanMetric>> _measurements;
8+
9+
public MetricsSummary(MetricsSummaryAggregator aggregator)
10+
{
11+
// For the Metrics Summary we group all the metrics by an export key.
12+
// See https://github.com/getsentry/rfcs/blob/main/text/0123-metrics-correlation.md#basics
13+
var measurements = new Dictionary<string, List<SpanMetric>>();
14+
foreach (var (_, value) in aggregator.Measurements)
15+
{
16+
var exportKey = value.ExportKey;
17+
#if NET6_0_OR_GREATER
18+
measurements.TryAdd(exportKey, new List<SpanMetric>());
19+
#else
20+
if (!measurements.ContainsKey(exportKey))
21+
{
22+
measurements.Add(exportKey, new List<SpanMetric>());
23+
}
24+
#endif
25+
measurements[exportKey].Add(value);
26+
}
27+
_measurements = measurements.ToImmutableSortedDictionary();
28+
}
29+
30+
public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
31+
{
32+
writer.WriteStartObject();
33+
34+
foreach (var (exportKey, value) in _measurements)
35+
{
36+
writer.WritePropertyName(exportKey);
37+
writer.WriteStartArray();
38+
foreach (var metric in value.OrderBy(x => MetricHelper.GetMetricBucketKey(x.MetricType, x.Key, x.Unit, x.Tags)))
39+
{
40+
metric.WriteTo(writer, logger);
41+
}
42+
writer.WriteEndArray();
43+
}
44+
45+
writer.WriteEndObject();
46+
}
47+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using Sentry.Extensibility;
2+
using Sentry.Internal.Extensions;
3+
4+
namespace Sentry.Protocol.Metrics;
5+
6+
internal record SpanMetric
7+
{
8+
public SpanMetric(MetricType MetricType,
9+
string key,
10+
double value,
11+
MeasurementUnit unit,
12+
IDictionary<string, string>? tags = null)
13+
{
14+
this.MetricType = MetricType;
15+
Key = key;
16+
Unit = unit;
17+
Tags = tags;
18+
Min = value;
19+
Max = value;
20+
Sum = value;
21+
}
22+
23+
public MetricType MetricType { get; init; }
24+
public string Key { get; init; }
25+
public MeasurementUnit Unit { get; init; }
26+
public IDictionary<string, string>? Tags { get; init; }
27+
28+
public double Min { get; private set; }
29+
public double Max { get; private set; }
30+
public double Sum { get; private set; }
31+
public double Count { get; private set; } = 1;
32+
33+
public string ExportKey => $"{MetricType.ToStatsdType()}:{Key}@{Unit}";
34+
35+
public void Add(double value)
36+
{
37+
Min = Math.Min(Min, value);
38+
Max = Math.Max(Max, value);
39+
Sum += value;
40+
Count++;
41+
}
42+
43+
/// <inheritdoc cref="ISentryJsonSerializable.WriteTo"/>
44+
public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
45+
{
46+
writer.WriteStartObject();
47+
writer.WriteNumber("min", Min);
48+
writer.WriteNumber("max", Max);
49+
writer.WriteNumber("count", Count);
50+
writer.WriteNumber("sum", Sum);
51+
writer.WriteStringDictionaryIfNotEmpty("tags", (IEnumerable<KeyValuePair<string, string?>>?)Tags);
52+
writer.WriteEndObject();
53+
}
54+
}

src/Sentry/SentrySpan.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Sentry.Internal;
33
using Sentry.Internal.Extensions;
44
using Sentry.Protocol;
5+
using Sentry.Protocol.Metrics;
56

67
namespace Sentry;
78

@@ -66,6 +67,7 @@ public void UnsetTag(string key) =>
6667

6768
// Aka 'data'
6869
private Dictionary<string, object?>? _extra;
70+
private readonly MetricsSummary? _metricsSummary;
6971

7072
/// <inheritdoc />
7173
public IReadOnlyDictionary<string, object?> Extra => _extra ??= new Dictionary<string, object?>();
@@ -104,6 +106,10 @@ public SentrySpan(ISpan tracer)
104106
{
105107
_measurements = spanTracer.InternalMeasurements?.ToDict();
106108
_tags = spanTracer.InternalTags?.ToDict();
109+
if (spanTracer.HasMetrics)
110+
{
111+
_metricsSummary = new MetricsSummary(spanTracer.MetricsSummary);
112+
}
107113
}
108114
else
109115
{
@@ -134,6 +140,7 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
134140
writer.WriteStringDictionaryIfNotEmpty("tags", _tags!);
135141
writer.WriteDictionaryIfNotEmpty("data", _extra!, logger);
136142
writer.WriteDictionaryIfNotEmpty("measurements", _measurements, logger);
143+
writer.WriteSerializableIfNotNull("_metrics_summary", _metricsSummary, logger);
137144

138145
writer.WriteEndObject();
139146
}

0 commit comments

Comments
 (0)