Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 5 additions & 2 deletions Explorer/Assets/DCL/BugReporting/BugReportInput.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using UnityEngine;

namespace DCL.BugReporting
Expand All @@ -7,8 +8,10 @@ public struct BugReportInput
{
public BugReportIssueType IssueType;
public string Description;
public byte[]? Image;
public string? ImageContentType;

/// <summary>In the order the user attached them; null or empty when there are none.</summary>
public IReadOnlyList<EvidenceImage>? Images;

public string? ContactEmail;
public string? UserName;
public Vector2Int? Coordinates;
Expand Down
63 changes: 52 additions & 11 deletions Explorer/Assets/DCL/BugReporting/BugReportService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using DCL.Diagnostics;
using DCL.Diagnostics.Sentry;
using DCL.Utility.Types;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using UnityEngine;
Expand All @@ -10,7 +12,7 @@ namespace DCL.BugReporting
{
/// <summary>
/// Submits one bug report end to end: first the Sentry User Feedback entry carrying the
/// user's image and the client log, then the Intercom ticket whose description links to it.
/// user's first image and the client log, then the Intercom ticket whose description links to it.
/// </summary>
public class BugReportService
{
Expand All @@ -31,12 +33,15 @@ public BugReportService(SentryUserFeedbackService feedbackService, IntercomTicke
/// <returns>The id of the created Intercom ticket.</returns>
public virtual async UniTask<Result<string>> SubmitAsync(BugReportInput input, CancellationToken ct)
{
// A feedback envelope carries a single attachment, so Sentry gets the first image only.
EvidenceImage? firstImage = input.Images is { Count: > 0 } ? input.Images[0] : null;

var feedbackReport = new SentryUserFeedbackReport(
$"[{input.IssueType.Label}] {input.Description}",
input.ContactEmail,
input.UserName,
input.Image,
input.ImageContentType);
firstImage?.Bytes,
firstImage?.ContentType);

Result<string> feedbackLink = await feedbackService.SubmitAsync(feedbackReport, ct);

Expand All @@ -55,11 +60,13 @@ public virtual async UniTask<Result<string>> SubmitAsync(BugReportInput input, C
GraphicCard = SystemInfo.graphicsDeviceName,
Ram = $"{SystemInfo.systemMemorySize} MB",
ClientVersion = Application.version,

// Explorer ships for desktop only.
Platform = IntercomTicketPlatform.Desktop,
SdkVersion = input.SceneSdkVersion,
LauncherVersion = input.LauncherVersion,
MeetsMinimumRequirementsOptionId = MinimumSpecOptionId(input.MeetsMinimumSpecs),
EvidenceImage = SelectEvidenceImage(input.Image),
EvidenceContentType = input.ImageContentType,
Evidence = SelectEvidenceImages(input.Images),
};

return await ticketClient.CreateTicketAsync(ticket, ct);
Expand All @@ -72,14 +79,48 @@ public virtual async UniTask<Result<string>> SubmitAsync(BugReportInput input, C
? BugReportMinimumSpecOptions.MEETS_MIN_SPEC
: BugReportMinimumSpecOptions.BELOW_MIN_SPEC;

/// <summary>The proxy rejects the whole ticket over an oversized image, so one degrades to the Sentry copy instead.</summary>
public static byte[]? SelectEvidenceImage(byte[]? image)
/// <summary>
/// The proxy rejects the whole ticket over an oversized image, a fourth image or an oversized request,
/// so every image that would trip one of those caps is dropped from the ticket instead. Order is kept.
/// </summary>
public static IReadOnlyList<EvidenceImage> SelectEvidenceImages(IReadOnlyList<EvidenceImage>? images)
{
if (image is not { Length: > IntercomTicketPayload.MAX_EVIDENCE_BYTES })
return image;
if (images == null || images.Count == 0)
return Array.Empty<EvidenceImage>();

ReportHub.LogWarning(ReportCategory.UNSPECIFIED, $"The attached image exceeds the {IntercomTicketPayload.MAX_EVIDENCE_BYTES / (1024 * 1024)}MB ticket evidence cap: it travels to Sentry only");
return null;
var selected = new List<EvidenceImage>(Math.Min(images.Count, IntercomTicketPayload.MAX_EVIDENCE_IMAGES));
var totalBytes = 0;

for (var i = 0; i < images.Count; i++)
{
int length = images[i].Bytes.Length;

if (length == 0)
continue;

if (selected.Count == IntercomTicketPayload.MAX_EVIDENCE_IMAGES)
{
ReportHub.LogWarning(ReportCategory.UNSPECIFIED, $"Only the first {IntercomTicketPayload.MAX_EVIDENCE_IMAGES} attached images travel with the ticket: the rest are dropped");
break;
}

if (length > IntercomTicketPayload.MAX_EVIDENCE_BYTES)
{
ReportHub.LogWarning(ReportCategory.UNSPECIFIED, $"Attached image {i + 1} exceeds the {IntercomTicketPayload.MAX_EVIDENCE_BYTES / (1024 * 1024)}MB ticket evidence cap: it is dropped from the ticket");
continue;
}

if (totalBytes + length > IntercomTicketPayload.MAX_EVIDENCE_TOTAL_BYTES)
{
ReportHub.LogWarning(ReportCategory.UNSPECIFIED, $"Attached image {i + 1} does not fit in the {IntercomTicketPayload.MAX_EVIDENCE_TOTAL_BYTES / (1024 * 1024)}MB ticket evidence budget: it is dropped from the ticket");
continue;
}

selected.Add(images[i]);
totalBytes += length;
}

return selected;
}

public static string ComposeTicketDescription(string description, Vector2Int? coordinates, string? feedbackLink)
Expand Down
57 changes: 48 additions & 9 deletions Explorer/Assets/DCL/BugReporting/IntercomTicketPayload.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@

namespace DCL.BugReporting
{
/// <summary>
/// Codes of the "Platform" list attribute as the proxy takes them; it maps each code to Intercom's option id.
/// The proxy rejects 0, the value an unassigned enum field serializes to, so no code is 0.
/// </summary>
public enum IntercomTicketPlatform
{
Desktop = 1,
Mobile = 2,
}

/// <summary>One image the proxy uploads and inlines into the ticket description.</summary>
public readonly struct EvidenceImage
{
public readonly byte[] Bytes;
public readonly string ContentType;

public EvidenceImage(byte[] bytes, string contentType)
{
Bytes = bytes;
ContentType = contentType;
}
}

public struct IntercomTicketData
{
public string Title;
Expand All @@ -16,22 +39,30 @@ public struct IntercomTicketData
public string GraphicCard;
public string Ram;
public string ClientVersion;
public IntercomTicketPlatform Platform;
public string? SdkVersion;
public string? LauncherVersion;

/// <summary>Option id of the "Meets Minimum Requirements" list attribute: Intercom takes the id, never the label.</summary>
public string? MeetsMinimumRequirementsOptionId;

public byte[]? EvidenceImage;
public string? EvidenceContentType;
/// <summary>Inlined into the description in this order; the proxy numbers them when there is more than one.</summary>
public IReadOnlyList<EvidenceImage>? Evidence;
}

public static class IntercomTicketPayload
{
/// <summary>The proxy rejects a bigger image, and with it the whole ticket.</summary>
public const int MAX_EVIDENCE_BYTES = 3 * 1024 * 1024;

private const string DEFAULT_EVIDENCE_CONTENT_TYPE = "image/jpeg";
/// <summary>The proxy rejects a fourth image, and with it the whole ticket.</summary>
public const int MAX_EVIDENCE_IMAGES = 3;

/// <summary>
/// Bound for all the images together. The proxy caps the request body at 4.5 MB and base64 inflates
/// the bytes by a third, so the images share the budget that one image alone may fill.
/// </summary>
public const int MAX_EVIDENCE_TOTAL_BYTES = MAX_EVIDENCE_BYTES;
Comment on lines +60 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The total budget constant aliases the per-image constant (MAX_EVIDENCE_TOTAL_BYTES = MAX_EVIDENCE_BYTES). While the comment explains the derivation (4.5 MB proxy body cap ÷ base64 inflation ≈ 3 MB raw budget), the identical value leaves a reader wondering whether the equality is coincidence or intentional coupling. Consider either inlining a reference to MAX_EVIDENCE_BYTES directly in the running-total check with a comment explaining the shared cap, or adding a note here that the equality is intentional and when each would diverge.

Suggested change
/// <summary>
/// Bound for all the images together. The proxy caps the request body at 4.5 MB and base64 inflates
/// the bytes by a third, so the images share the budget that one image alone may fill.
/// </summary>
public const int MAX_EVIDENCE_TOTAL_BYTES = MAX_EVIDENCE_BYTES;
/// <summary>
/// Bound for all the images together. Currently equals MAX_EVIDENCE_BYTES because the proxy
/// caps the request body at 4.5 MB and base64 inflates by a third — so the raw budget for all
/// images together is the same 3 MB that one image alone may fill.
/// </summary>
public const int MAX_EVIDENCE_TOTAL_BYTES = MAX_EVIDENCE_BYTES;


/// <summary>
/// Builds the body of POST /intercom/tickets. The proxy accepts only ticket_attributes and evidence
Expand All @@ -48,6 +79,7 @@ public static string BuildCreateTicketJson(in IntercomTicketData data)
["Graphic Card"] = data.GraphicCard,
["RAM"] = data.Ram,
["Client version"] = data.ClientVersion,
["Platform"] = (int)data.Platform,
};

// Intercom keeps an absent attribute empty, while an empty string would show as a filled-in blank.
Expand All @@ -65,12 +97,19 @@ public static string BuildCreateTicketJson(in IntercomTicketData data)
["ticket_attributes"] = attributes,
};

if (data.EvidenceImage is { Length: > 0 })
payload["evidence"] = new Dictionary<string, object>
{
["content_type"] = data.EvidenceContentType ?? DEFAULT_EVIDENCE_CONTENT_TYPE,
["data"] = Convert.ToBase64String(data.EvidenceImage),
};
if (data.Evidence is { Count: > 0 })
{
var evidence = new List<Dictionary<string, object>>(data.Evidence.Count);

for (var i = 0; i < data.Evidence.Count; i++)
evidence.Add(new Dictionary<string, object>
{
["content_type"] = data.Evidence[i].ContentType,
["data"] = Convert.ToBase64String(data.Evidence[i].Bytes),
});

payload["evidence"] = evidence;
}

return JsonConvert.SerializeObject(payload);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ public void ResolveThePrefilledIssueTypeToItsDropdownIndex()
public async Task SendDraftValuesToService()
{
// Arrange
byte[] imageBytes = { 1, 2, 3 };
var image = new BugReportImage(imageBytes, "image/png", null!);
var draft = new BugReportDraft(ISSUE_TYPE_INDEX, $" {DESCRIPTION} ", image);
byte[] firstBytes = { 1, 2, 3 };
byte[] secondBytes = { 4, 5, 6 };
BugReportImage[] images = { new (firstBytes, "image/png", null!), new (secondBytes, "image/jpeg", null!) };
var draft = new BugReportDraft(ISSUE_TYPE_INDEX, $" {DESCRIPTION} ", images);

// Act
Result<string> result = await controller.SubmitDraftAsync(draft, CancellationToken.None);
Expand All @@ -86,8 +87,11 @@ public async Task SendDraftValuesToService()
Assert.AreEqual("ticket-1", result.Value);
Assert.AreEqual(BugReportIssueTypes.ALL[ISSUE_TYPE_INDEX].OptionId, captured.IssueType.OptionId);
Assert.AreEqual(DESCRIPTION, captured.Description);
Assert.AreEqual(imageBytes, captured.Image);
Assert.AreEqual("image/png", captured.ImageContentType);
Assert.AreEqual(2, captured.Images!.Count);
Assert.AreSame(firstBytes, captured.Images[0].Bytes);
Assert.AreEqual("image/png", captured.Images[0].ContentType);
Assert.AreSame(secondBytes, captured.Images[1].Bytes);
Assert.AreEqual("image/jpeg", captured.Images[1].ContentType);
Assert.IsNull(captured.UserName);
Assert.IsNull(captured.Coordinates);
}
Expand Down Expand Up @@ -172,6 +176,6 @@ public async Task ForwardServiceFailureAsResult()
}

private static BugReportDraft Draft() =>
new (ISSUE_TYPE_INDEX, DESCRIPTION, null);
new (ISSUE_TYPE_INDEX, DESCRIPTION, Array.Empty<BugReportImage>());
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using NUnit.Framework;
using System;
using System.Collections.Generic;
using UnityEngine;

namespace DCL.BugReporting.Tests
Expand All @@ -7,6 +9,7 @@ public class BugReportServiceShould
{
private const string DESCRIPTION = "The avatar falls through the floor.";
private const string LINK = "https://decentraland.sentry.io/issues/feedback/?projectSlug=explorer&eventId=80f9a06b97e94d8686cb232bb681f266";
private const string CONTENT_TYPE = "image/png";

[Test]
public void IncludeFeedbackLinkInDescription()
Expand Down Expand Up @@ -49,24 +52,82 @@ public void MapTheMinimumSpecOutcomeToItsListOption()
}

[Test]
public void KeepAnImageWithinTheEvidenceCap()
public void KeepImagesWithinTheEvidenceCapsInOrder()
{
// Arrange
var image = new byte[16];
EvidenceImage first = Image(16);
EvidenceImage second = Image(32);

// Act
IReadOnlyList<EvidenceImage> selected = BugReportService.SelectEvidenceImages(new[] { first, second });

// Assert
Assert.AreEqual(2, selected.Count);
Assert.AreSame(first.Bytes, selected[0].Bytes);
Assert.AreSame(second.Bytes, selected[1].Bytes);
}

[Test]
public void SelectNothingWithoutImages()
{
Assert.AreEqual(0, BugReportService.SelectEvidenceImages(null).Count);
Assert.AreEqual(0, BugReportService.SelectEvidenceImages(Array.Empty<EvidenceImage>()).Count);
Assert.AreEqual(0, BugReportService.SelectEvidenceImages(new[] { Image(0) }).Count);
}

[Test]
public void DropAnImageAboveThePerImageCapAndKeepTheRest()
{
// Arrange
EvidenceImage oversized = Image(IntercomTicketPayload.MAX_EVIDENCE_BYTES + 1);
EvidenceImage small = Image(16);

// Act
IReadOnlyList<EvidenceImage> selected = BugReportService.SelectEvidenceImages(new[] { oversized, small });

// Assert
Assert.AreSame(image, BugReportService.SelectEvidenceImage(image));
Assert.IsNull(BugReportService.SelectEvidenceImage(null));
Assert.AreEqual(1, selected.Count);
Assert.AreSame(small.Bytes, selected[0].Bytes);
}

[Test]
public void DropImagesBeyondTheProxyLimit()
{
// Arrange
var images = new EvidenceImage[IntercomTicketPayload.MAX_EVIDENCE_IMAGES + 1];

for (var i = 0; i < images.Length; i++)
images[i] = Image(16 + i);

// Act
IReadOnlyList<EvidenceImage> selected = BugReportService.SelectEvidenceImages(images);

// Assert - the first ones win: the user attached them first.
Assert.AreEqual(IntercomTicketPayload.MAX_EVIDENCE_IMAGES, selected.Count);

for (var i = 0; i < selected.Count; i++)
Assert.AreSame(images[i].Bytes, selected[i].Bytes);
}

[Test]
public void DropAnImageAboveTheEvidenceCap()
public void DropAnImageThatOverflowsTheTotalBudgetAndKeepALaterOneThatFits()
{
// Arrange
var image = new byte[IntercomTicketPayload.MAX_EVIDENCE_BYTES + 1];
int twoThirds = IntercomTicketPayload.MAX_EVIDENCE_TOTAL_BYTES / 3 * 2;
EvidenceImage first = Image(twoThirds);
EvidenceImage second = Image(twoThirds);
EvidenceImage third = Image(16);

// Act
IReadOnlyList<EvidenceImage> selected = BugReportService.SelectEvidenceImages(new[] { first, second, third });

// Assert
Assert.IsNull(BugReportService.SelectEvidenceImage(image));
Assert.AreEqual(2, selected.Count);
Assert.AreSame(first.Bytes, selected[0].Bytes);
Assert.AreSame(third.Bytes, selected[1].Bytes);
}

private static EvidenceImage Image(int length) =>
new (new byte[length], CONTENT_TYPE);
}
}
Loading
Loading