Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Metrics rate limits #3276

Merged
merged 11 commits into from
Apr 12, 2024
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features

- Metrics now honor any Rate Limits set in HTTP headers returned by Sentry ([#3276](https://github.com/getsentry/sentry-dotnet/pull/3276))

### Fixes

- Fixed normalization for metric tag values for carriage return, line feed and tab characters ([#3281](https://github.com/getsentry/sentry-dotnet/pull/3281))
Expand Down
6 changes: 6 additions & 0 deletions src/Sentry/Http/HttpTransportBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,12 @@ private void ExtractRateLimits(HttpHeaders responseHeaders)
{
foreach (var rateLimitCategory in rateLimit.Categories)
{
if (string.Equals(rateLimitCategory.Name, "metric_bucket", StringComparison.OrdinalIgnoreCase)
&& !rateLimit.IsDefaultNamespace)
{
// Currently we only back off for default/empty namespaces
continue;
}
CategoryLimitResets[rateLimitCategory] = now + rateLimit.RetryAfter;
}
}
Expand Down
29 changes: 22 additions & 7 deletions src/Sentry/Internal/Http/RateLimit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ internal class RateLimit
{
public IReadOnlyList<RateLimitCategory> Categories { get; }

public IReadOnlyList<string>? Namespaces { get; }

internal bool IsDefaultNamespace =>
Namespaces is null ||
(Namespaces.Count == 1 && string.Equals(Namespaces[0], "custom", StringComparison.OrdinalIgnoreCase));

public TimeSpan RetryAfter { get; }

public RateLimit(
IReadOnlyList<RateLimitCategory> categories,
TimeSpan retryAfter)
public RateLimit(TimeSpan retryAfter, IReadOnlyList<RateLimitCategory> categories, IReadOnlyList<string>? namespaces = null)
{
Categories = categories;
RetryAfter = retryAfter;
Categories = categories;
Namespaces = namespaces;
}

public static RateLimit Parse(string rateLimitEncoded)
Expand All @@ -21,10 +26,20 @@ public static RateLimit Parse(string rateLimitEncoded)

var retryAfter = TimeSpan.FromSeconds(int.Parse(components[0], CultureInfo.InvariantCulture));
var categories = components[1].Split(';').Select(c => new RateLimitCategory(c)).ToArray();
string[]? namespaces = null;
foreach (var category in categories)
{
if (!string.Equals(category.Name, "metric_bucket", StringComparison.OrdinalIgnoreCase))
{
continue;
}

// Response header looking like this: X-Sentry-Rate-Limits: 2700:metric_bucket:organization:quota_exceeded:custom
namespaces = components.Length > 4 ? components[4].Split(';') : null;
jamescrosswell marked this conversation as resolved.
Show resolved Hide resolved
break;
}

return new RateLimit(
categories,
retryAfter);
return new RateLimit(retryAfter, categories, namespaces);
}

public static IEnumerable<RateLimit> ParseMany(string rateLimitsEncoded) =>
Expand Down
9 changes: 8 additions & 1 deletion src/Sentry/Internal/Http/RateLimitCategory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ public bool Matches(EnvelopeItem item)
return false;
}

return string.Equals(Name, type, StringComparison.OrdinalIgnoreCase);
return type switch
{
EnvelopeItem.TypeValueMetric =>
// Metrics are a bit unique - the envelope item type is `statsd` but the category is `metric_bucket`
string.Equals(Name, "metric_bucket", StringComparison.OrdinalIgnoreCase),
// For most reporting categories, the envelope item type matches the client report category
_ => string.Equals(Name, type, StringComparison.OrdinalIgnoreCase)
};
}

public bool Equals(RateLimitCategory? other)
Expand Down
20 changes: 10 additions & 10 deletions src/Sentry/Protocol/Envelopes/EnvelopeItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@ public sealed class EnvelopeItem : ISerializable, IDisposable
{
private const string TypeKey = "type";

private const string TypeValueEvent = "event";
private const string TypeValueUserReport = "user_report";
private const string TypeValueTransaction = "transaction";
private const string TypeValueSession = "session";
private const string TypeValueCheckIn = "check_in";
private const string TypeValueAttachment = "attachment";
private const string TypeValueClientReport = "client_report";
private const string TypeValueProfile = "profile";
private const string TypeValueMetric = "statsd";
private const string TypeValueCodeLocations = "metric_meta";
internal const string TypeValueEvent = "event";
internal const string TypeValueUserReport = "user_report";
internal const string TypeValueTransaction = "transaction";
internal const string TypeValueSession = "session";
internal const string TypeValueCheckIn = "check_in";
internal const string TypeValueAttachment = "attachment";
internal const string TypeValueClientReport = "client_report";
internal const string TypeValueProfile = "profile";
internal const string TypeValueMetric = "statsd";
internal const string TypeValueCodeLocations = "metric_meta";

private const string LengthKey = "length";
private const string FileNameKey = "filename";
Expand Down
44 changes: 31 additions & 13 deletions test/Sentry.Tests/Internals/Http/HttpTransportTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -276,13 +276,17 @@ public async Task SendEnvelopeAsync_ResponseNotOkNoMessage_LogsError()
).Should().BeTrue();
}

[Fact]
public async Task SendEnvelopeAsync_ItemRateLimit_DropsItem()
[Theory]
[InlineData("2700:metric_bucket:organization:quota_exceeded:custom", true)] // Default namespace... we back off
[InlineData("2700:metric_bucket:organization:quota_exceeded", true)] // No namespace... we back off
jamescrosswell marked this conversation as resolved.
Show resolved Hide resolved
[InlineData("2700:metric_bucket:organization:", true)] // Empty namespace... we back off
[InlineData("2700:metric_bucket:organization:quota_exceeded:foo", false)] // Specific namespace... we don't back off for these yet
public async Task SendEnvelopeAsync_ItemRateLimit_DropsItem(string metricNamespace, bool shouldDropMetricEnvelope)
{
// Arrange
using var httpHandler = new RecordingHttpMessageHandler(
new FakeHttpMessageHandler(
() => SentryResponses.GetRateLimitResponse("1234:event, 897:transaction")
() => SentryResponses.GetRateLimitResponse($"1234:event, 897:transaction, {metricNamespace}")
));

var httpTransport = new HttpTransport(
Expand All @@ -305,29 +309,43 @@ public async Task SendEnvelopeAsync_ItemRateLimit_DropsItem()
{
// Should be dropped
new EnvelopeItem(
new Dictionary<string, object> {["type"] = "event"},
new Dictionary<string, object> {["type"] = EnvelopeItem.TypeValueEvent},
new EmptySerializable()),
new EnvelopeItem(
new Dictionary<string, object> {["type"] = "event"},
new Dictionary<string, object> {["type"] = EnvelopeItem.TypeValueEvent},
new EmptySerializable()),
new EnvelopeItem(
new Dictionary<string, object> {["type"] = "transaction"},
new Dictionary<string, object> {["type"] = EnvelopeItem.TypeValueTransaction},
new EmptySerializable()),

// Should stay
new EnvelopeItem(
new Dictionary<string, object> {["type"] = "other"},
new EmptySerializable())
new EmptySerializable()),

// Dropped if metricNamespace is "custom" or empty
new EnvelopeItem(
new Dictionary<string, object> {["type"] = EnvelopeItem.TypeValueMetric},
new EmptySerializable()),
});

var expectedItems = new List<EnvelopeItem>
{
new EnvelopeItem(
new Dictionary<string, object> {["type"] = "other"},
new EmptySerializable())
};
if (!shouldDropMetricEnvelope)
{
expectedItems.Add(
new EnvelopeItem(
new Dictionary<string, object> { ["type"] = EnvelopeItem.TypeValueMetric },
new EmptySerializable()));
}
var expectedEnvelope = new Envelope(
new Dictionary<string, object>(),
new[]
{
new EnvelopeItem(
new Dictionary<string, object> {["type"] = "other"},
new EmptySerializable())
});
expectedItems
);

var expectedEnvelopeSerialized = await expectedEnvelope.SerializeToStringAsync(_testOutputLogger, _fakeClock);

Expand Down
23 changes: 13 additions & 10 deletions test/Sentry.Tests/Internals/Http/RateLimitCategoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ namespace Sentry.Tests.Internals.Http;
public class RateLimitCategoryTests
{
[Theory]
[InlineData("event", "event")]
[InlineData("session", "session")]
[InlineData("transaction", "transaction")]
[InlineData("attachment", "attachment")]
[InlineData("", "event")]
[InlineData("", "session")]
[InlineData("", "transaction")]
[InlineData("event", EnvelopeItem.TypeValueEvent)]
[InlineData("metric_bucket", EnvelopeItem.TypeValueMetric)]
[InlineData("session", EnvelopeItem.TypeValueSession)]
[InlineData("transaction", EnvelopeItem.TypeValueTransaction)]
[InlineData("attachment", EnvelopeItem.TypeValueAttachment)]
[InlineData("", EnvelopeItem.TypeValueEvent)]
[InlineData("", EnvelopeItem.TypeValueMetric)]
[InlineData("", EnvelopeItem.TypeValueSession)]
[InlineData("", EnvelopeItem.TypeValueTransaction)]
public void Matches_IncludedItemType_ShouldMatch(string categoryName, string itemType)
{
// Arrange
Expand All @@ -29,9 +31,10 @@ public void Matches_IncludedItemType_ShouldMatch(string categoryName, string ite
}

[Theory]
[InlineData("event", "transaction")]
[InlineData("error", "attachment")]
[InlineData("session", "event")]
[InlineData("event", EnvelopeItem.TypeValueTransaction)]
[InlineData("error", EnvelopeItem.TypeValueAttachment)]
[InlineData("session", EnvelopeItem.TypeValueEvent)]
[InlineData("metric_bucket", EnvelopeItem.TypeValueSession)]
public void Matches_NotIncludedItemType_ShouldNotMatch(string categoryName, string itemType)
{
// Arrange
Expand Down
81 changes: 58 additions & 23 deletions test/Sentry.Tests/Internals/Http/RateLimitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ public void Parse_MinimalFormat_Works()
var rateLimit = RateLimit.Parse(value);

// Assert
rateLimit.Should().BeEquivalentTo(new RateLimit(
new[] { new RateLimitCategory("transaction") },
TimeSpan.FromSeconds(60)
));
rateLimit.Should().BeEquivalentTo(new RateLimit(TimeSpan.FromSeconds(60), new[] { new RateLimitCategory("transaction") }));
}

[Fact]
Expand All @@ -30,10 +27,7 @@ public void Parse_MinimalFormat_EmptyCatgetory_Works()
var rateLimit = RateLimit.Parse(value);

// Assert
rateLimit.Should().BeEquivalentTo(new RateLimit(
new[] { new RateLimitCategory("") },
TimeSpan.FromSeconds(60)
));
rateLimit.Should().BeEquivalentTo(new RateLimit(TimeSpan.FromSeconds(60), new[] { new RateLimitCategory("") }));
}

[Fact]
Expand All @@ -46,10 +40,7 @@ public void Parse_MinimalFormat_EmptyCategory_IgnoresScope()
var rateLimit = RateLimit.Parse(value);

// Assert
rateLimit.Should().BeEquivalentTo(new RateLimit(
new[] { new RateLimitCategory("") },
TimeSpan.FromSeconds(60)
));
rateLimit.Should().BeEquivalentTo(new RateLimit(TimeSpan.FromSeconds(60), new[] { new RateLimitCategory("") }));
}

[Fact]
Expand All @@ -62,10 +53,7 @@ public void Parse_FullFormat_Works()
var rateLimit = RateLimit.Parse(value);

// Assert
rateLimit.Should().BeEquivalentTo(new RateLimit(
new[] { new RateLimitCategory("transaction") },
TimeSpan.FromSeconds(60)
));
rateLimit.Should().BeEquivalentTo(new RateLimit(TimeSpan.FromSeconds(60), new[] { new RateLimitCategory("transaction") }));
}

[Fact]
Expand All @@ -77,15 +65,62 @@ public void Parse_MultipleCategories_Works()
// Act
var rateLimit = RateLimit.Parse(value);

// Assert
rateLimit.Should().BeEquivalentTo(new RateLimit(TimeSpan.FromSeconds(2700), new[]
{
new RateLimitCategory("default"),
new RateLimitCategory("error"),
new RateLimitCategory("security")
}));
}

[Fact]
public void Parse_SingleNamespace_Works()
{
// Arrange
const string value = "2700:metric_bucket:organization:quota_exceeded:custom";

// Act
var rateLimit = RateLimit.Parse(value);

// Assert
rateLimit.Should().BeEquivalentTo(new RateLimit(
TimeSpan.FromSeconds(2700),
[new RateLimitCategory("metric_bucket")],
["custom"]
));
}

[Fact]
public void Parse_MultipleNamespaces_Works()
{
// Arrange
const string value = "2700:metric_bucket:organization:quota_exceeded:apples;oranges;pears";

// Act
var rateLimit = RateLimit.Parse(value);

// Assert
rateLimit.Should().BeEquivalentTo(new RateLimit(
TimeSpan.FromSeconds(2700),
[new RateLimitCategory("metric_bucket")],
["apples", "oranges", "pears"]
));
}

[Fact]
public void Parse_NotMetricBucket_NamespacesIgnored()
{
// Arrange
const string value = "2700:default:organization:quota_exceeded:custom";

// Act
var rateLimit = RateLimit.Parse(value);

// Assert
rateLimit.Should().BeEquivalentTo(new RateLimit(
new[]
{
new RateLimitCategory("default"),
new RateLimitCategory("error"),
new RateLimitCategory("security")
},
TimeSpan.FromSeconds(2700)
TimeSpan.FromSeconds(2700),
[new RateLimitCategory("default")]
));
}
}
Loading