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

Add Client Reports feature #1556

Merged
merged 63 commits into from
May 3, 2022
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
c88ade5
Add ThreadsafeCounterDictionary and tests
mattjohnsonpint Mar 25, 2022
4de2c59
Add discard enumerations
mattjohnsonpint Mar 25, 2022
0e8f28e
Count discarded queue overflows
mattjohnsonpint Mar 25, 2022
b81afb6
Add a SendClientReports option
mattjohnsonpint Mar 25, 2022
0f752c4
Separate tests
mattjohnsonpint Mar 26, 2022
16f6c85
Attach client report
mattjohnsonpint Mar 26, 2022
e192ded
Merge branch 'main' into add-client-reports
mattjohnsonpint Mar 26, 2022
af2992c
Update CHANGELOG.md
mattjohnsonpint Mar 26, 2022
9f97777
Update test
mattjohnsonpint Mar 26, 2022
4cf2dcd
Improve test
mattjohnsonpint Mar 27, 2022
7051cdc
Avoid using value tuples
mattjohnsonpint Mar 30, 2022
49c7f2f
Merge branch 'main' into add-client-reports
mattjohnsonpint Mar 31, 2022
36556b5
Merge branch 'main' into add-client-reports
mattjohnsonpint Apr 8, 2022
b75b06a
Update CHANGELOG.md
mattjohnsonpint Apr 8, 2022
5ce3558
Refactor to move discards to transport
mattjohnsonpint Apr 8, 2022
a97c258
Test client report output and fix serialization
mattjohnsonpint Apr 8, 2022
7723ae4
Add test
mattjohnsonpint Apr 8, 2022
9f94f9f
Add test
mattjohnsonpint Apr 9, 2022
dfc86b4
Count ratelimit backoffs
mattjohnsonpint Apr 9, 2022
18c7d1c
Count network failures
mattjohnsonpint Apr 9, 2022
2ad1323
Count discards from BeforeSend and EventProcessors
mattjohnsonpint Apr 9, 2022
daddee1
Count discarded sampled transactions
mattjohnsonpint Apr 9, 2022
5441fcf
count caching failures
mattjohnsonpint Apr 9, 2022
6059f5c
Sort enums
mattjohnsonpint Apr 9, 2022
06f6572
Fix broken test
mattjohnsonpint Apr 18, 2022
fdd4da2
Refactor and skip 429 network errors
mattjohnsonpint Apr 19, 2022
7548f90
Add test for rate limit discarded items
mattjohnsonpint Apr 19, 2022
b532831
Add BeforeSend test
mattjohnsonpint Apr 20, 2022
2381db8
Count discards from exception filters
mattjohnsonpint Apr 22, 2022
483785b
Add tests for discards from event processors and exception filters
mattjohnsonpint Apr 22, 2022
498cc86
Record discard for sampled out events (and test)
mattjohnsonpint Apr 22, 2022
b240b73
Merge branch 'main' into add-client-reports
mattjohnsonpint Apr 25, 2022
e26d21a
Add test for recording cache failure discard
mattjohnsonpint Apr 26, 2022
3617ff0
Refactor to split out ClientReportRecorder
mattjohnsonpint Apr 26, 2022
22f3735
Refactoring
mattjohnsonpint Apr 26, 2022
954eccc
Add tests for ClientReportRecorder
mattjohnsonpint Apr 27, 2022
d1e74bb
Minor refactoring
mattjohnsonpint Apr 27, 2022
84e8832
Add failing test
mattjohnsonpint Apr 27, 2022
70ff32a
Caching transport should implement client reports to fix test
mattjohnsonpint Apr 27, 2022
a66bb2c
Move recorder from transport to options
mattjohnsonpint Apr 27, 2022
c1ea87b
Only count when client reports enabled
mattjohnsonpint Apr 27, 2022
aa77329
Add note about self-hosted sentry version to changelog
mattjohnsonpint Apr 27, 2022
5226af1
Merge branch 'main' into add-client-reports
mattjohnsonpint Apr 27, 2022
d77f8e4
Update CHANGELOG.md
mattjohnsonpint Apr 27, 2022
1165a9d
Ensure client reports can be serialized and deserialized
mattjohnsonpint Apr 27, 2022
d6f9db3
Make caching transport write client reports to disk
mattjohnsonpint Apr 27, 2022
e9a30a5
cleanup
mattjohnsonpint Apr 27, 2022
f2d6b0c
Refactoring
mattjohnsonpint Apr 27, 2022
46940cd
Recover counts if sending client reports fails
mattjohnsonpint Apr 28, 2022
3255e1f
Merge branch 'main' into add-client-reports
mattjohnsonpint Apr 28, 2022
2eaf227
docs fixes
SimonCropp Apr 28, 2022
254b63a
Address PR feedback
mattjohnsonpint Apr 28, 2022
766c795
429 should not restore client reports
mattjohnsonpint Apr 28, 2022
0153e55
oops
mattjohnsonpint Apr 28, 2022
d4efbea
oops
mattjohnsonpint Apr 28, 2022
97ff029
break out ProcessEnvelopeItem (#1608)
SimonCropp Apr 28, 2022
2ede699
redundant this
SimonCropp Apr 28, 2022
2807e0f
add IsExternalInit (#1611)
SimonCropp Apr 29, 2022
43d1834
Add comments
mattjohnsonpint May 2, 2022
9299970
minor docs improvements to client reports (#1630)
SimonCropp May 3, 2022
8cac99e
shorten variable names (#1629)
SimonCropp May 3, 2022
80a773b
Merge branch 'main' into add-client-reports
mattjohnsonpint May 3, 2022
d2eb25b
minor
mattjohnsonpint May 3, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Features

- Collect and send Client Reports to Sentry, which contain counts of discarded events. ([#1556](https://github.com/getsentry/sentry-dotnet/pull/1556))
- Use a default value of 60 seconds if a `Retry-After` header is not present. ([#1537](https://github.com/getsentry/sentry-dotnet/pull/1537))

### Fixes
Expand Down
7 changes: 7 additions & 0 deletions src/Sentry/Envelopes/Envelope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,5 +237,12 @@ public static async Task<Envelope> DeserializeAsync(

return new Envelope(header, items);
}

internal Envelope WithClientReport(ClientReport clientReport)
{
var items = Items.ToList();
items.Add(EnvelopeItem.FromClientReport(clientReport));
return new Envelope(Header, items);
}
}
}
23 changes: 23 additions & 0 deletions src/Sentry/Envelopes/EnvelopeItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public sealed class EnvelopeItem : ISerializable, IDisposable
private const string TypeValueTransaction = "transaction";
private const string TypeValueSession = "session";
private const string TypeValueAttachment = "attachment";
private const string TypeValueClientReport = "client_report";
private const string LengthKey = "length";
private const string FileNameKey = "filename";

Expand All @@ -34,6 +35,15 @@ public sealed class EnvelopeItem : ISerializable, IDisposable
/// </summary>
public ISerializable Payload { get; }

internal DataCategory DataCategory => TryGetType() switch
{
TypeValueEvent => DataCategory.Error,
TypeValueTransaction => DataCategory.Transaction,
TypeValueSession => DataCategory.Session,
TypeValueAttachment => DataCategory.Attachment,
_ => DataCategory.Default
};

/// <summary>
/// Initializes an instance of <see cref="EnvelopeItem"/>.
/// </summary>
Expand Down Expand Up @@ -215,6 +225,19 @@ public static EnvelopeItem FromAttachment(Attachment attachment)
return new EnvelopeItem(header, new StreamSerializable(stream));
}

/// <summary>
/// Creates an envelope item from a client report.
/// </summary>
internal static EnvelopeItem FromClientReport(ClientReport clientReport)
{
var header = new Dictionary<string, object?>(1, StringComparer.Ordinal)
{
[TypeKey] = TypeValueClientReport
};

return new EnvelopeItem(header, new JsonSerializable(clientReport));
}

private static async Task<IReadOnlyDictionary<string, object?>> DeserializeHeaderAsync(
Stream stream,
CancellationToken cancellationToken = default)
Expand Down
31 changes: 30 additions & 1 deletion src/Sentry/Internal/BackgroundWorker.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Sentry.Extensibility;
using Sentry.Infrastructure;
using Sentry.Protocol.Envelopes;

namespace Sentry.Internal
Expand All @@ -13,9 +16,11 @@ internal class BackgroundWorker : IBackgroundWorker, IDisposable
private readonly ITransport _transport;
private readonly SentryOptions _options;
private readonly ConcurrentQueue<Envelope> _queue;
private readonly ISystemClock _clock;
private readonly int _maxItems;
private readonly CancellationTokenSource _shutdownSource;
private readonly SemaphoreSlim _queuedEnvelopeSemaphore;
private readonly ThreadsafeCounterDictionary<(DataCategory Category, DiscardReason Reason)> _discardedEvents = new();
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved

private volatile bool _disposed;
private int _currentItems;
Expand All @@ -24,6 +29,8 @@ internal class BackgroundWorker : IBackgroundWorker, IDisposable

internal Task WorkerTask { get; }

internal IReadOnlyDictionary<(DataCategory Category, DiscardReason Reason), int> DiscardedEvents => _discardedEvents;
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved

public int QueuedItems => _queue.Count;

public BackgroundWorker(
Expand All @@ -37,11 +44,13 @@ internal BackgroundWorker(
ITransport transport,
SentryOptions options,
CancellationTokenSource? shutdownSource = null,
ConcurrentQueue<Envelope>? queue = null)
ConcurrentQueue<Envelope>? queue = null,
ISystemClock? clock = null)
{
_transport = transport;
_options = options;
_queue = queue ?? new ConcurrentQueue<Envelope>();
_clock = clock ?? new SystemClock();
_maxItems = options.MaxQueueItems;
_shutdownSource = shutdownSource ?? new CancellationTokenSource();
_queuedEnvelopeSemaphore = new SemaphoreSlim(0, _maxItems);
Expand All @@ -59,6 +68,10 @@ public bool EnqueueEnvelope(Envelope envelope)
if (Interlocked.Increment(ref _currentItems) > _maxItems)
{
_ = Interlocked.Decrement(ref _currentItems);
foreach (var item in envelope.Items)
{
_discardedEvents.Increment((item.DataCategory, DiscardReason.QueueOverflow));
}
return false;
}

Expand Down Expand Up @@ -121,6 +134,22 @@ private async Task WorkerAsync()
// Dispose inside try/catch
using var _ = envelope;

// Read and reset discards even if we're not sending them (to prevent excessive growth over time)
var discardedEvents = _discardedEvents.ReadAllAndReset();

// Create and attach the client report
if (_options.SendClientReports && discardedEvents.Any(x => x.Value > 0))
{
var timestamp = _clock.GetUtcNow();
var clientReport = new ClientReport(timestamp, discardedEvents);
envelope = envelope.WithClientReport(clientReport);

_options.LogDebug(
"Attached client report to envelope {0}.",
envelope.TryGetEventId());
}

// Send the envelope
var task = _transport.SendEnvelopeAsync(envelope, shutdownTimeout.Token);

_options.LogDebug(
Expand Down
42 changes: 42 additions & 0 deletions src/Sentry/Internal/ClientReport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Sentry.Extensibility;
using Sentry.Internal.Extensions;

namespace Sentry.Internal
{
internal class ClientReport : IJsonSerializable
{
public DateTimeOffset Timestamp { get; }
public IReadOnlyDictionary<(DataCategory Category, DiscardReason Reason), int> DiscardedEvents { get; }

public ClientReport(DateTimeOffset timestamp,
IReadOnlyDictionary<(DataCategory Category, DiscardReason Reason), int> discardedEvents)
{
Timestamp = timestamp;
DiscardedEvents = discardedEvents;
}

public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
{
writer.WriteStartObject();

writer.WriteString("timestamp", Timestamp);

writer.WriteStartArray("discarded_events");
foreach (var ((category, reason), value) in DiscardedEvents.Where(x=> x.Value > 0))
{
writer.WriteStartObject();
writer.WriteString("reason", reason);
writer.WriteString("category", category);
writer.WriteNumber("quantity", value);
writer.WriteEndObject();
}
writer.WriteEndArray();

writer.WriteEndObject();
}
}
}
17 changes: 17 additions & 0 deletions src/Sentry/Internal/DataCategory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Sentry.Internal
{
internal record DataCategory : Enumeration
{
public static DataCategory Default = new("default");
public static DataCategory Error = new("error");
public static DataCategory Transaction = new("transaction");
public static DataCategory Security = new("security");
public static DataCategory Attachment = new("attachment");
public static DataCategory Session = new("session");
public static DataCategory Internal = new("internal");

private DataCategory(string value) : base(value)
{
}
}
}
15 changes: 15 additions & 0 deletions src/Sentry/Internal/DiscardReason.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Sentry.Internal
{
internal record DiscardReason : Enumeration
{
public static DiscardReason QueueOverflow = new("queue_overflow");
public static DiscardReason CacheOverflow = new("cache_overflow");
public static DiscardReason RateLimitBackoff = new("ratelimit_backoff");
public static DiscardReason NetworkError = new("network_error");
public static DiscardReason SampleRate = new("sample_rate");

private DiscardReason(string value) : base(value)
{
}
}
}
9 changes: 9 additions & 0 deletions src/Sentry/Internal/Enumeration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Sentry.Internal
{
internal abstract record Enumeration(string Value)
{
public string Value { get; } = Value;

public override string ToString() => Value;
}
}
15 changes: 15 additions & 0 deletions src/Sentry/Internal/Extensions/JsonExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -523,5 +523,20 @@ public static void WriteDynamicIfNotNull(
writer.WriteDynamic(propertyName, value, logger);
}
}

public static void WriteString(
this Utf8JsonWriter writer,
string propertyName,
Enumeration? value)
{
if (value == null)
{
writer.WriteNull(propertyName);
}
else
{
writer.WriteString(propertyName, value.Value);
}
}
}
}
110 changes: 110 additions & 0 deletions src/Sentry/Internal/ThreadsafeCounterDictionary.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;

namespace Sentry.Internal
{
/// <summary>
/// Provides a keyed set of counters that can be incremented, read, and reset atomically.
/// </summary>
/// <typeparam name="TKey">The type of the key.</typeparam>
internal class ThreadsafeCounterDictionary<TKey> : IReadOnlyDictionary<TKey, int>
where TKey : notnull
{
private class CounterItem
{
private int _value;
public int Value => _value;

public void Increment() => Interlocked.Increment(ref _value);

public int ReadAndReset() => Interlocked.Exchange(ref _value, 0);
}

private readonly ConcurrentDictionary<TKey, CounterItem> _items = new();

/// <summary>
/// Atomically increments a counter based on the key provided, creating the counter if necessary.
/// </summary>
/// <param name="key">The key of the counter to increment.</param>
public void Increment(TKey key) => _items.GetOrAdd(key, new CounterItem()).Increment();

/// <summary>
/// Gets a single counter's value while atomically resetting it to zero.
/// </summary>
/// <param name="key">The key to the counter.</param>
/// <returns>The previous value of the counter.</returns>
/// <remarks>If no counter with the given key has been set, this returns zero.</remarks>
public int ReadAndReset(TKey key) => _items.TryGetValue(key, out var item) ? item.ReadAndReset() : 0;

/// <summary>
/// Gets the keys and values of all of the counters while atomically resetting them to zero.
/// </summary>
/// <returns>A read-only dictionary containing the key and the previous value for each counter.</returns>
public IReadOnlyDictionary<TKey, int> ReadAllAndReset()
{
// Read all the counters while atomically resetting them to zero
var counts = _items.ToDictionary(
x => x.Key,
x => x.Value.ReadAndReset());

return new ReadOnlyDictionary<TKey, int>(counts);
}

/// <summary>
/// Gets an enumerator over the keys and values of the counters.
/// </summary>
/// <returns>An enumerator.</returns>
public IEnumerator<KeyValuePair<TKey, int>> GetEnumerator() => _items
.Select(x => new KeyValuePair<TKey, int>(x.Key, x.Value.Value))
.GetEnumerator();

IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

/// <summary>
/// Gets the number of counters currently being tracked.
/// </summary>
public int Count => _items.Count;

/// <summary>
/// Tests whether or not a counter with the given key exists.
/// </summary>
/// <param name="key">The key to check.</param>
/// <returns>True if the counter exists, false otherwise.</returns>
public bool ContainsKey(TKey key) => _items.ContainsKey(key);

/// <summary>
/// Gets the current value of the counter specified.
/// </summary>
/// <param name="key">The key of the counter.</param>
/// <param name="value">The value of the counter, or zero if the counter does not yet exist.</param>
/// <returns>Returns <c>true</c> in all cases.</returns>
public bool TryGetValue(TKey key, out int value)
{
value = this[key];
return true;
}

/// <summary>
/// Gets the current value of the counter specified, returning zero if the counter does not yet exist.
/// </summary>
/// <param name="key">The key of the counter.</param>
public int this[TKey key] => _items.TryGetValue(key, out var item) ? item.Value : 0;

/// <summary>
/// Gets all of the current counter keys.
/// </summary>
public IEnumerable<TKey> Keys => _items.Keys;

/// <summary>
/// Gets all of the current counter values.
/// </summary>
/// <remarks>
/// Useless, but required by the IReadOnlyDictionary interface.
/// </remarks>
public IEnumerable<int> Values => _items.Values.Select(x => x.Value);
}
}
6 changes: 6 additions & 0 deletions src/Sentry/SentryOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,12 @@ public int MaxCacheItems
/// <see href="https://github.com/getsentry/sentry-dotnet/issues/71"/>
public bool RequestBodyCompressionBuffered { get; set; } = true;

/// <summary>
/// Whether to send client reports, which contain statistics about discarded events.
/// </summary>
/// <see href="https://develop.sentry.dev/sdk/client-reports/"/>
public bool SendClientReports { get; set; } = true;
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved

/// <summary>
/// An optional web proxy
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ namespace Sentry
public System.IO.Compression.CompressionLevel RequestBodyCompressionLevel { get; set; }
public float? SampleRate { get; set; }
public Sentry.IScopeObserver? ScopeObserver { get; set; }
public bool SendClientReports { get; set; }
public bool SendDefaultPii { get; set; }
public Sentry.ISentryScopeStateProcessor SentryScopeStateProcessor { get; set; }
public string? ServerName { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ namespace Sentry
public System.IO.Compression.CompressionLevel RequestBodyCompressionLevel { get; set; }
public float? SampleRate { get; set; }
public Sentry.IScopeObserver? ScopeObserver { get; set; }
public bool SendClientReports { get; set; }
public bool SendDefaultPii { get; set; }
public Sentry.ISentryScopeStateProcessor SentryScopeStateProcessor { get; set; }
public string? ServerName { get; set; }
Expand Down
Loading