Skip to content

Commit

Permalink
Add Client Reports feature (#1556)
Browse files Browse the repository at this point in the history
  • Loading branch information
mattjohnsonpint committed May 3, 2022
1 parent fe5b60b commit a443a18
Show file tree
Hide file tree
Showing 37 changed files with 1,598 additions and 85 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

## Unreleased

**Notice:** If you are using self-hosted Sentry, this version and forward requires either Sentry version >= [21.9.0](https://github.com/getsentry/relay/blob/master/CHANGELOG.md#2190), or you must manually disable sending client reports via the `SendClientReports` option.

### Features

- Collect and send Client Reports to Sentry, which contain counts of discarded events. ([#1556](https://github.com/getsentry/sentry-dotnet/pull/1556))
- Expose `ITransport` and `SentryOptions.Transport` public, to support using custom transports ([#1602](https://github.com/getsentry/sentry-dotnet/pull/1602))

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

return new Envelope(header, items);
}

/// <summary>
/// Creates a new <see cref="Envelope"/> starting from the current one and appends the <paramref name="item"/> given.
/// </summary>
/// <param name="item">The <see cref="EnvelopeItem"/> to append.</param>
/// <returns>A new <see cref="Envelope"/> with the same headers and items, including the new <paramref name="item"/>.</returns>
internal Envelope WithItem(EnvelopeItem item)
{
var items = Items.ToList();
items.Add(item);
return new Envelope(Header, items);
}
}
}
51 changes: 46 additions & 5 deletions src/Sentry/Envelopes/EnvelopeItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ namespace Sentry.Protocol.Envelopes
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 TypeValueAttachment = "attachment";
private const string TypeValueClientReport = "client_report";

private const string LengthKey = "length";
private const string FileNameKey = "filename";

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

internal DataCategory DataCategory => TryGetType() switch
{
// Yes, the "event" item type corresponds to the "error" data category
TypeValueEvent => DataCategory.Error,

// These ones are equivalent
TypeValueTransaction => DataCategory.Transaction,
TypeValueSession => DataCategory.Session,
TypeValueAttachment => DataCategory.Attachment,

// Not all envelope item types equate to data categories
// Specifically, user_report and client_report just use "default"
_ => DataCategory.Default
};

/// <summary>
/// Initializes an instance of <see cref="EnvelopeItem"/>.
/// </summary>
Expand Down Expand Up @@ -187,7 +205,7 @@ public void Serialize(Stream stream, IDiagnosticLogger? logger)
public void Dispose() => (Payload as IDisposable)?.Dispose();

/// <summary>
/// Creates an envelope item from an event.
/// Creates an <see cref="EnvelopeItem"/> from <paramref name="event"/>.
/// </summary>
public static EnvelopeItem FromEvent(SentryEvent @event)
{
Expand All @@ -200,7 +218,7 @@ public static EnvelopeItem FromEvent(SentryEvent @event)
}

/// <summary>
/// Creates an envelope item from user feedback.
/// Creates an <see cref="EnvelopeItem"/> from <paramref name="sentryUserFeedback"/>.
/// </summary>
public static EnvelopeItem FromUserFeedback(UserFeedback sentryUserFeedback)
{
Expand All @@ -213,7 +231,7 @@ public static EnvelopeItem FromUserFeedback(UserFeedback sentryUserFeedback)
}

/// <summary>
/// Creates an envelope item from transaction.
/// Creates an <see cref="EnvelopeItem"/> from <paramref name="transaction"/>.
/// </summary>
public static EnvelopeItem FromTransaction(Transaction transaction)
{
Expand All @@ -226,7 +244,7 @@ public static EnvelopeItem FromTransaction(Transaction transaction)
}

/// <summary>
/// Creates an envelope item from a session update.
/// Creates an <see cref="EnvelopeItem"/> from <paramref name="sessionUpdate"/>.
/// </summary>
public static EnvelopeItem FromSession(SessionUpdate sessionUpdate)
{
Expand All @@ -239,7 +257,7 @@ public static EnvelopeItem FromSession(SessionUpdate sessionUpdate)
}

/// <summary>
/// Creates an envelope item from attachment.
/// Creates an <see cref="EnvelopeItem"/> from <paramref name="attachment"/>.
/// </summary>
public static EnvelopeItem FromAttachment(Attachment attachment)
{
Expand All @@ -266,6 +284,19 @@ public static EnvelopeItem FromAttachment(Attachment attachment)
return new EnvelopeItem(header, new StreamSerializable(stream));
}

/// <summary>
/// Creates an <see cref="EnvelopeItem"/> from <paramref name="report"/>.
/// </summary>
internal static EnvelopeItem FromClientReport(ClientReport report)
{
var header = new Dictionary<string, object?>(1, StringComparer.Ordinal)
{
[TypeKey] = TypeValueClientReport
};

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

private static async Task<IReadOnlyDictionary<string, object?>> DeserializeHeaderAsync(
Stream stream,
CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -343,6 +374,16 @@ private static async Task<ISerializable> DeserializePayloadAsync(
return new JsonSerializable(SessionUpdate.FromJson(json));
}

// Client Report
if (string.Equals(payloadType, TypeValueClientReport, StringComparison.OrdinalIgnoreCase))
{
var bufferLength = (int)(payloadLength ?? stream.Length);
var buffer = await stream.ReadByteChunkAsync(bufferLength, cancellationToken).ConfigureAwait(false);
var json = Json.Parse(buffer);

return new JsonSerializable(ClientReport.FromJson(json));
}

// Arbitrary payload
var payloadStream = new PartialStream(stream, stream.Position, payloadLength);

Expand Down
161 changes: 107 additions & 54 deletions src/Sentry/Http/HttpTransportBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ namespace Sentry.Http
public abstract class HttpTransportBase
{
internal const string DefaultErrorMessage = "No message";

private readonly SentryOptions _options;
private readonly ISystemClock _clock;
private readonly Func<string, string?> _getEnvironmentVariable;
Expand Down Expand Up @@ -59,83 +60,111 @@ protected HttpTransportBase(SentryOptions options,
/// </summary>
/// <param name="envelope">The envelope to process.</param>
/// <returns>The processed envelope, ready to be sent.</returns>
protected Envelope ProcessEnvelope(Envelope envelope)
protected internal Envelope ProcessEnvelope(Envelope envelope)
{
var now = _clock.GetUtcNow();

// Re-package envelope, discarding items that don't fit the rate limit
var envelopeItems = new List<EnvelopeItem>();
foreach (var envelopeItem in envelope.Items)
{
// Check if there is at least one matching category for this item that is rate-limited
var isRateLimited = CategoryLimitResets
.Any(kvp => kvp.Value > now && kvp.Key.Matches(envelopeItem));
ProcessEnvelopeItem(now, envelopeItem, envelopeItems);
}

if (isRateLimited)
{
_options.LogDebug(
"Envelope item of type {0} was discarded because it's rate-limited.",
envelopeItem.TryGetType());

// Check if session update with init=true
if (envelopeItem.Payload is JsonSerializable
{
Source: SessionUpdate {IsInitial: true} discardedSessionUpdate
})
{
_lastDiscardedSessionInitId = discardedSessionUpdate.Id.ToString();
var eventId = envelope.TryGetEventId();

_options.LogDebug(
"Discarded envelope item containing initial session update (SID: {0}).",
discardedSessionUpdate.Id);
}
var clientReport = _options.ClientReportRecorder.GenerateClientReport();
if (clientReport != null)
{
envelopeItems.Add(EnvelopeItem.FromClientReport(clientReport));
_options.LogDebug("Attached client report to envelope {0}.", eventId);
}

continue;
if (envelopeItems.Count == 0)
{
if (_options.SendClientReports)
{
_options.LogInfo("Envelope {0} was discarded because all contained items are rate-limited " +
"and there are no client reports to send.",
eventId);
}

// If attachment, needs to respect attachment size limit
if (string.Equals(envelopeItem.TryGetType(), "attachment", StringComparison.OrdinalIgnoreCase) &&
envelopeItem.TryGetLength() > _options.MaxAttachmentSize)
else
{
_options.LogWarning(
"Attachment '{0}' dropped because it's too large ({1} bytes).",
envelopeItem.TryGetFileName(),
envelopeItem.TryGetLength());

continue;
_options.LogInfo("Envelope {0} was discarded because all contained items are rate-limited.",
eventId);
}
}

// If it's a session update (not discarded) with init=false, check if it continues
// a session with previously dropped init and, if so, promote this update to init=true.
if (envelopeItem.Payload is JsonSerializable {Source: SessionUpdate {IsInitial: false} sessionUpdate} &&
string.Equals(sessionUpdate.Id.ToString(),
Interlocked.Exchange(ref _lastDiscardedSessionInitId, null),
StringComparison.Ordinal))
{
var modifiedEnvelopeItem = new EnvelopeItem(
envelopeItem.Header,
new JsonSerializable(new SessionUpdate(sessionUpdate, true)));
return new Envelope(envelope.Header, envelopeItems);
}

envelopeItems.Add(modifiedEnvelopeItem);
private void ProcessEnvelopeItem(DateTimeOffset now, EnvelopeItem item, List<EnvelopeItem> items)
{
// Check if there is at least one matching category for this item that is rate-limited
var isRateLimited = CategoryLimitResets
.Any(kvp => kvp.Value > now && kvp.Key.Matches(item));

_options.LogDebug(
"Promoted envelope item with session update to initial following a discarded update (SID: {0}).",
sessionUpdate.Id);
}
else
if (isRateLimited)
{
_options.ClientReportRecorder
.RecordDiscardedEvent(DiscardReason.RateLimitBackoff, item.DataCategory);

_options.LogDebug(
"Envelope item of type {0} was discarded because it's rate-limited.",
item.TryGetType());

// Check if session update with init=true
if (item.Payload is JsonSerializable
{
Source: SessionUpdate {IsInitial: true} discardedSessionUpdate
})
{
envelopeItems.Add(envelopeItem);
_lastDiscardedSessionInitId = discardedSessionUpdate.Id.ToString();

_options.LogDebug(
"Discarded envelope item containing initial session update (SID: {0}).",
discardedSessionUpdate.Id);
}

return;
}

if (envelopeItems.Count == 0)
// If attachment, needs to respect attachment size limit
if (string.Equals(item.TryGetType(), "attachment", StringComparison.OrdinalIgnoreCase) &&
item.TryGetLength() > _options.MaxAttachmentSize)
{
_options.LogInfo(
"Envelope {0} was discarded because all contained items are rate-limited.",
envelope.TryGetEventId());
// note: attachment drops are not currently counted in discarded events

_options.LogWarning(
"Attachment '{0}' dropped because it's too large ({1} bytes).",
item.TryGetFileName(),
item.TryGetLength());

return;
}

return new Envelope(envelope.Header, envelopeItems);
// If it's a session update (not discarded) with init=false, check if it continues
// a session with previously dropped init and, if so, promote this update to init=true.
if (item.Payload is JsonSerializable {Source: SessionUpdate {IsInitial: false} sessionUpdate} &&
string.Equals(sessionUpdate.Id.ToString(),
Interlocked.Exchange(ref _lastDiscardedSessionInitId, null),
StringComparison.Ordinal))
{
var modifiedEnvelopeItem = new EnvelopeItem(
item.Header,
new JsonSerializable(new SessionUpdate(sessionUpdate, true)));

items.Add(modifiedEnvelopeItem);

_options.LogDebug(
"Promoted envelope item with session update to initial following a discarded update (SID: {0}).",
sessionUpdate.Id);

return;
}

// Finally, add this item to the result
items.Add(item);
}

/// <summary>
Expand Down Expand Up @@ -289,6 +318,8 @@ private async Task HandleSuccessAsync(Envelope envelope, CancellationToken cance

private void HandleFailure(HttpResponseMessage response, Envelope envelope)
{
IncrementDiscardsForHttpFailure(response.StatusCode, envelope);

// Spare the overhead if level is not enabled
if (_options.DiagnosticLogger?.IsEnabled(SentryLevel.Error) is true && response.Content is { } content)
{
Expand Down Expand Up @@ -341,6 +372,8 @@ private void HandleFailure(HttpResponseMessage response, Envelope envelope)
private async Task HandleFailureAsync(HttpResponseMessage response, Envelope envelope,
CancellationToken cancellationToken)
{
IncrementDiscardsForHttpFailure(response.StatusCode, envelope);

// Spare the overhead if level is not enabled
if (_options.DiagnosticLogger?.IsEnabled(SentryLevel.Error) is true && response.Content is { } content)
{
Expand Down Expand Up @@ -397,6 +430,26 @@ await envelope
}
}

private void IncrementDiscardsForHttpFailure(HttpStatusCode responseStatusCode, Envelope envelope)
{
if ((int)responseStatusCode is 429 or < 400)
{
// Status == 429 or < 400 should not be counted by the client SDK
// See https://develop.sentry.dev/sdk/client-reports/#sdk-side-recommendations
return;
}

_options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, envelope);

// Also restore any counts that were trying to be sent, so they are not lost.
var clientReportItems = envelope.Items.Where(x => x.TryGetType() == "client_report");
foreach (var item in clientReportItems)
{
var clientReport = (ClientReport)((JsonSerializable)item.Payload).Source;
_options.ClientReportRecorder.Load(clientReport);
}
}

private void LogFailure(string responseString, HttpStatusCode responseStatusCode, SentryId? eventId)
{
_options.Log(SentryLevel.Error,
Expand Down
3 changes: 3 additions & 0 deletions src/Sentry/Internal/BackgroundWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Threading;
using System.Threading.Tasks;
using Sentry.Extensibility;
using Sentry.Internal.Extensions;
using Sentry.Protocol.Envelopes;

namespace Sentry.Internal
Expand Down Expand Up @@ -59,6 +60,7 @@ public bool EnqueueEnvelope(Envelope envelope)
if (Interlocked.Increment(ref _currentItems) > _maxItems)
{
_ = Interlocked.Decrement(ref _currentItems);
_options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.QueueOverflow, envelope);
return false;
}

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

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

_options.LogDebug(
Expand Down
Loading

0 comments on commit a443a18

Please sign in to comment.