Skip to content

Commit

Permalink
Added Metrics rate limits (#3276)
Browse files Browse the repository at this point in the history
  • Loading branch information
jamescrosswell committed Apr 12, 2024
1 parent 025d60c commit 59f3164
Show file tree
Hide file tree
Showing 8 changed files with 152 additions and 64 deletions.
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;
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
[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")]
));
}
}

0 comments on commit 59f3164

Please sign in to comment.