diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43d0b933413..954443542d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
# 3.1.2
+* feat: Manually capturing User Feedback
* Enhancement: Set environment to "production" by default.
# 3.1.1
diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/sample/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/sample/MainActivity.java
index a6e424df7f0..eb7980ed739 100644
--- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/sample/MainActivity.java
+++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/sample/MainActivity.java
@@ -3,6 +3,8 @@
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import io.sentry.Sentry;
+import io.sentry.UserFeedback;
+import io.sentry.protocol.SentryId;
import io.sentry.protocol.User;
import io.sentry.sample.databinding.ActivityMainBinding;
import java.util.Collections;
@@ -22,6 +24,16 @@ protected void onCreate(Bundle savedInstanceState) {
binding.sendMessage.setOnClickListener(view -> Sentry.captureMessage("Some message."));
+ binding.sendUserFeedback.setOnClickListener(view -> {
+ SentryId sentryId = Sentry.captureException(new Exception("I have feedback"));
+
+ UserFeedback userFeedback = new UserFeedback(sentryId);
+ userFeedback.setComments("It broke on Android. I don't know why, but this happens.");
+ userFeedback.setEmail("john@me.com");
+ userFeedback.setName("John Me");
+ Sentry.captureUserFeedback(userFeedback);
+ });
+
binding.captureException.setOnClickListener(
view ->
Sentry.captureException(
diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml
index 54cac756107..b1d82bbb0d7 100644
--- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml
+++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml
@@ -16,7 +16,13 @@
android:id="@+id/send_message"
android:text="@string/send_message"/>
-
+
+
diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/strings.xml b/sentry-samples/sentry-samples-android/src/main/res/values/strings.xml
index bba1a4e499d..ef6fe2322f6 100644
--- a/sentry-samples/sentry-samples-android/src/main/res/values/strings.xml
+++ b/sentry-samples/sentry-samples-android/src/main/res/values/strings.xml
@@ -2,6 +2,7 @@
Sentry sample
Crash from Java (UncaughtException)
Send Message
+ Send User Feedback
Capture Exception
Breadcrumb
Set user
diff --git a/sentry/src/main/java/io/sentry/GsonSerializer.java b/sentry/src/main/java/io/sentry/GsonSerializer.java
index 5bb53c9a1df..6a496998c76 100644
--- a/sentry/src/main/java/io/sentry/GsonSerializer.java
+++ b/sentry/src/main/java/io/sentry/GsonSerializer.java
@@ -106,6 +106,19 @@ Device.DeviceOrientation.class, new OrientationDeserializerAdapter(logger))
return gson.fromJson(reader, SentryEvent.class);
}
+ /**
+ * Deserialize UserFeedback from a stream Reader (JSON)
+ *
+ * @param reader the Reader
+ * @return the UserFeedback class or null
+ */
+ @Override
+ public UserFeedback deserializeUserFeedback(Reader reader) {
+ Objects.requireNonNull(reader, "The Reader object is required.");
+
+ return gson.fromJson(reader, UserFeedback.class);
+ }
+
/**
* Deserialize a Session from a stream Reader (JSON)
*
@@ -170,6 +183,22 @@ public void serialize(final @NotNull Session session, final @NotNull Writer writ
writer.flush();
}
+ /**
+ * Serialize UserFeedback to a stream Writer (JSON)
+ *
+ * @param userFeedback the Session
+ * @param writer the Writer
+ * @throws IOException an IOException
+ */
+ @Override
+ public void serialize(UserFeedback userFeedback, Writer writer) throws IOException {
+ Objects.requireNonNull(userFeedback, "The UserFeedback object is required.");
+ Objects.requireNonNull(writer, "The Writer object is required.");
+
+ gson.toJson(userFeedback, UserFeedback.class, writer);
+ writer.flush();
+ }
+
/**
* Serialize a SentryEnvelope to a stream Writer (JSON)
*
diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java
index e4722166802..940e80f18bf 100644
--- a/sentry/src/main/java/io/sentry/Hub.java
+++ b/sentry/src/main/java/io/sentry/Hub.java
@@ -188,6 +188,28 @@ public SentryId captureEnvelope(
return sentryId;
}
+ @Override
+ public void captureUserFeedback(UserFeedback userFeedback) {
+ if (!isEnabled()) {
+ options
+ .getLogger()
+ .log(
+ SentryLevel.WARNING, "Instance is disabled and this 'captureUserFeedback' call is a no-op.");
+ } else {
+ try {
+ final StackItem item = stack.peek();
+ if (item != null) {
+ item.client.captureUserFeedback(userFeedback);
+ } else {
+ options.getLogger().log(SentryLevel.FATAL, "Stack peek was null when captureUserFeedback");
+ }
+ } catch (Exception e) {
+ options.getLogger().log(SentryLevel.ERROR,
+ "Error while capturing captureUserFeedback: " + userFeedback.toString(), e);
+ }
+ }
+ }
+
@Override
public void startSession() {
if (!isEnabled()) {
diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java
index ed4c0b47465..2ed357bb1af 100644
--- a/sentry/src/main/java/io/sentry/HubAdapter.java
+++ b/sentry/src/main/java/io/sentry/HubAdapter.java
@@ -42,6 +42,11 @@ public SentryId captureException(Throwable throwable, @Nullable Object hint) {
return Sentry.captureException(throwable, hint);
}
+ @Override
+ public void captureUserFeedback(UserFeedback userFeedback) {
+ Sentry.captureUserFeedback(userFeedback);
+ }
+
@Override
public void startSession() {
Sentry.startSession();
diff --git a/sentry/src/main/java/io/sentry/IHub.java b/sentry/src/main/java/io/sentry/IHub.java
index 9f0369d88fb..f06112639e2 100644
--- a/sentry/src/main/java/io/sentry/IHub.java
+++ b/sentry/src/main/java/io/sentry/IHub.java
@@ -92,6 +92,13 @@ default SentryId captureException(Throwable throwable) {
return captureException(throwable, null);
}
+ /**
+ * Captures a manually created user feedback and sends it to Sentry.
+ *
+ * @param userFeedback The user feedback to send to Sentry.
+ */
+ void captureUserFeedback(UserFeedback userFeedback);
+
/** Starts a new session. If there's a running session, it ends it before starting the new one. */
void startSession();
diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java
index e41efec3242..d6a282b1ebf 100644
--- a/sentry/src/main/java/io/sentry/ISentryClient.java
+++ b/sentry/src/main/java/io/sentry/ISentryClient.java
@@ -141,6 +141,13 @@ default SentryId captureException(Throwable throwable, @Nullable Scope scope) {
return captureException(throwable, scope, null);
}
+ /**
+ * Captures a manually created user feedback and sends it to Sentry.
+ *
+ * @param userFeedback The user feedback to send to Sentry.
+ */
+ void captureUserFeedback(UserFeedback userFeedback);
+
/**
* Captures a session. This method transform a session to an envelope and forwards to
* captureEnvelope
diff --git a/sentry/src/main/java/io/sentry/ISerializer.java b/sentry/src/main/java/io/sentry/ISerializer.java
index b7c941b05b5..d97fbeec3af 100644
--- a/sentry/src/main/java/io/sentry/ISerializer.java
+++ b/sentry/src/main/java/io/sentry/ISerializer.java
@@ -9,6 +9,8 @@
public interface ISerializer {
SentryEvent deserializeEvent(Reader reader);
+ UserFeedback deserializeUserFeedback(Reader reader);
+
Session deserializeSession(Reader reader);
SentryEnvelope deserializeEnvelope(InputStream inputStream);
@@ -17,6 +19,8 @@ public interface ISerializer {
void serialize(Session session, Writer writer) throws IOException;
+ void serialize(UserFeedback userFeedback, Writer writer) throws IOException;
+
void serialize(SentryEnvelope envelope, Writer writer) throws Exception;
String serialize(Map data) throws Exception;
diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java
index 8319ff55436..e65ce15b464 100644
--- a/sentry/src/main/java/io/sentry/NoOpHub.java
+++ b/sentry/src/main/java/io/sentry/NoOpHub.java
@@ -40,6 +40,9 @@ public SentryId captureException(Throwable throwable, @Nullable Object hint) {
return SentryId.EMPTY_ID;
}
+ @Override
+ public void captureUserFeedback(UserFeedback userFeedback) { }
+
@Override
public void startSession() {}
diff --git a/sentry/src/main/java/io/sentry/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java
index 65c6256d790..70afd14d2f4 100644
--- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java
+++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java
@@ -29,6 +29,9 @@ public void close() {}
@Override
public void flush(long timeoutMillis) {}
+ @Override
+ public void captureUserFeedback(UserFeedback userFeedback) { }
+
@Override
public void captureSession(Session session, @Nullable Object hint) {}
diff --git a/sentry/src/main/java/io/sentry/NoOpSerializer.java b/sentry/src/main/java/io/sentry/NoOpSerializer.java
index 5e7abd94d28..66d643059ba 100644
--- a/sentry/src/main/java/io/sentry/NoOpSerializer.java
+++ b/sentry/src/main/java/io/sentry/NoOpSerializer.java
@@ -22,6 +22,11 @@ public SentryEvent deserializeEvent(Reader reader) {
return null;
}
+ @Override
+ public UserFeedback deserializeUserFeedback(Reader reader) {
+ return null;
+ }
+
@Override
public Session deserializeSession(Reader reader) {
return null;
@@ -38,6 +43,9 @@ public void serialize(SentryEvent event, Writer writer) {}
@Override
public void serialize(Session session, Writer writer) throws IOException {}
+ @Override
+ public void serialize(UserFeedback userFeedback, Writer writer) throws IOException { }
+
@Override
public void serialize(SentryEnvelope envelope, Writer outputStream) throws Exception {}
diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java
index fe3f391211f..3339b8f997c 100644
--- a/sentry/src/main/java/io/sentry/Sentry.java
+++ b/sentry/src/main/java/io/sentry/Sentry.java
@@ -293,6 +293,15 @@ public static synchronized void close() {
return getCurrentHub().captureException(throwable, hint);
}
+ /**
+ * Captures a manually created user feedback and sends it to Sentry.
+ *
+ * @param userFeedback The user feedback to send to Sentry.
+ */
+ public static void captureUserFeedback(UserFeedback userFeedback) {
+ getCurrentHub().captureUserFeedback(userFeedback);
+ }
+
/**
* Adds a breadcrumb to the current Scope
*
diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java
index 2dee70f8f8a..c8b4db21b05 100644
--- a/sentry/src/main/java/io/sentry/SentryClient.java
+++ b/sentry/src/main/java/io/sentry/SentryClient.java
@@ -183,6 +183,32 @@ private SentryEvent processEvent(
return event;
}
+ @Override
+ public void captureUserFeedback(UserFeedback userFeedback) {
+ Objects.requireNonNull(userFeedback, "SentryEvent is required.");
+
+ options.getLogger().log(SentryLevel.DEBUG, "Capturing userFeedback: %s", userFeedback.getEventId());
+
+ try {
+ final SentryEnvelope envelope = buildEnvelope(userFeedback);
+ connection.send(envelope);
+ } catch (IOException e) {
+ options.getLogger().log(SentryLevel.WARNING, e, "Capturing user feedback %s failed.", userFeedback.getEventId());
+ }
+ }
+
+ private SentryEnvelope buildEnvelope(@NotNull UserFeedback userFeedback) {
+ final List envelopeItems = new ArrayList<>();
+
+ final SentryEnvelopeItem userFeedbackItem = SentryEnvelopeItem.fromUserFeedback(options.getSerializer(), userFeedback);
+ envelopeItems.add(userFeedbackItem);
+
+ final SentryEnvelopeHeader envelopeHeader =
+ new SentryEnvelopeHeader(userFeedback.getEventId(), options.getSdkVersion());
+
+ return new SentryEnvelope(envelopeHeader, envelopeItems);
+ }
+
/**
* Updates the session data based on the event, hint and scope data
*
diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java
index 7e71d6552ed..6040e94a302 100644
--- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java
+++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java
@@ -108,6 +108,28 @@ public final class SentryEnvelopeItem {
return new SentryEnvelopeItem(itemHeader, () -> cachedItem.getBytes());
}
+ public static SentryEnvelopeItem fromUserFeedback(
+ final @NotNull ISerializer serializer, final @NotNull UserFeedback userFeedback) {
+ Objects.requireNonNull(serializer, "ISerializer is required.");
+ Objects.requireNonNull(userFeedback, "UserFeedback is required.");
+
+ final CachedItem cachedItem =
+ new CachedItem(
+ () -> {
+ try (final ByteArrayOutputStream stream = new ByteArrayOutputStream();
+ final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) {
+ serializer.serialize(userFeedback, writer);
+ return stream.toByteArray();
+ }
+ });
+
+ SentryEnvelopeItemHeader itemHeader =
+ new SentryEnvelopeItemHeader(
+ SentryItemType.UserFeedback, () -> cachedItem.getBytes().length, "application/json", null);
+
+ return new SentryEnvelopeItem(itemHeader, cachedItem::getBytes);
+ }
+
private static class CachedItem {
private @Nullable byte[] bytes;
private final @Nullable Callable dataFactory;
diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeaderAdapter.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeaderAdapter.java
index ee3f9030d15..ee57e400965 100644
--- a/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeaderAdapter.java
+++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeaderAdapter.java
@@ -31,7 +31,7 @@ public void write(JsonWriter writer, SentryEnvelopeItemHeader value) throws IOEx
if (!SentryItemType.Unknown.equals(value.getType())) {
writer.name("type");
- writer.value(value.getType().name().toLowerCase(Locale.ROOT));
+ writer.value(value.getType().getItemType().toLowerCase(Locale.ROOT));
}
writer.name("length");
diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java
index 3ad9b3afb80..88e4606bf04 100644
--- a/sentry/src/main/java/io/sentry/SentryItemType.java
+++ b/sentry/src/main/java/io/sentry/SentryItemType.java
@@ -6,6 +6,7 @@
public enum SentryItemType {
Session("session"),
Event("event"), // DataCategory.Error
+ UserFeedback("user_report"), // Sentry backend still uses user_report
Attachment("attachment"),
Transaction("transaction"),
Unknown("__unknown__"); // DataCategory.Unknown
diff --git a/sentry/src/main/java/io/sentry/UserFeedback.java b/sentry/src/main/java/io/sentry/UserFeedback.java
new file mode 100644
index 00000000000..db4765972dc
--- /dev/null
+++ b/sentry/src/main/java/io/sentry/UserFeedback.java
@@ -0,0 +1,116 @@
+package io.sentry;
+
+import org.jetbrains.annotations.Nullable;
+
+import io.sentry.protocol.SentryId;
+
+/**
+ * Adds additional information about what happened to an event.
+ */
+public final class UserFeedback {
+
+ private final SentryId eventId;
+ private @Nullable String name;
+ private @Nullable String email;
+ private @Nullable String comments;
+
+
+ /**
+ * Initializes SentryUserFeedback and sets the required eventId.
+ *
+ * @param eventId The eventId of the event to which the user feedback is associated.
+ */
+ public UserFeedback(SentryId eventId) {
+ this(eventId, null, null, null);
+ }
+
+ /**
+ * Initializes SentryUserFeedback and sets the required eventId.
+ * @param eventId The eventId of the event to which the user feedback is associated.
+ * @param name the name of the user.
+ * @param email the email of the user.
+ * @param comments comments of the user about what happened.
+ */
+ public UserFeedback(SentryId eventId,
+ @Nullable String name,
+ @Nullable String email,
+ @Nullable String comments) {
+ this.eventId = eventId;
+ this.name = name;
+ this.email = email;
+ this.comments = comments;
+ }
+
+ /**
+ * Gets the eventId of the event to which the user feedback is associated.
+ *
+ * @return the eventId
+ */
+ public SentryId getEventId() {
+ return eventId;
+ }
+
+ /**
+ * Gets the name of the user.
+ *
+ * @return the name.
+ */
+ public @Nullable String getName() {
+ return name;
+ }
+
+ /**
+ * Sets the name of the user.
+ *
+ * @param name the name of the user.
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * Gets the email of the user.
+ *
+ * @return the email.
+ */
+ public @Nullable String getEmail() {
+ return email;
+ }
+
+ /**
+ * Sets the email of the user.
+ *
+ * @param email the email of the user.
+ */
+ public void setEmail(@Nullable String email) {
+ this.email = email;
+ }
+
+ /**
+ * Gets comments of the user about what happened.
+ *
+ * @return the comments
+ */
+ public @Nullable String getComments() {
+ return comments;
+ }
+
+ /**
+ * Sets comments of the user about what happened.
+ *
+ * @param comments the comments
+ */
+ public void setComments(@Nullable String comments) {
+ this.comments = comments;
+ }
+
+ @Override
+ public String toString() {
+ return "UserFeedback{" +
+ "eventId=" + eventId +
+ ", name='" + name + '\'' +
+ ", email='" + email + '\'' +
+ ", comments='" + comments + '\'' +
+ '}';
+ }
+}
diff --git a/sentry/src/test/java/io/sentry/GsonSerializerTest.kt b/sentry/src/test/java/io/sentry/GsonSerializerTest.kt
index cd66a310fb9..53572103b60 100644
--- a/sentry/src/test/java/io/sentry/GsonSerializerTest.kt
+++ b/sentry/src/test/java/io/sentry/GsonSerializerTest.kt
@@ -8,6 +8,7 @@ import com.nhaarman.mockitokotlin2.whenever
import io.sentry.protocol.Contexts
import io.sentry.protocol.Device
import io.sentry.protocol.SdkVersion
+import io.sentry.protocol.SentryId
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
@@ -29,20 +30,24 @@ class GsonSerializerTest {
private val serializer = GsonSerializer(mock(), EnvelopeReader())
private fun serializeToString(ev: SentryEvent): String {
- val wrt = StringWriter()
- serializer.serialize(ev, wrt)
- return wrt.toString()
+ return serializeToString { wrt -> serializer.serialize(ev, wrt) }
}
private fun serializeToString(session: Session): String {
- val wrt = StringWriter()
- serializer.serialize(session, wrt)
- return wrt.toString()
+ return serializeToString { wrt -> serializer.serialize(session, wrt) }
}
private fun serializeToString(envelope: SentryEnvelope): String {
+ return serializeToString { wrt -> serializer.serialize(envelope, wrt) }
+ }
+
+ private fun serializeToString(userFeedback: UserFeedback): String {
+ return serializeToString { wrt -> serializer.serialize(userFeedback, wrt) }
+ }
+
+ private fun serializeToString(serialize: (StringWriter) -> Unit): String {
val wrt = StringWriter()
- serializer.serialize(envelope, wrt)
+ serialize(wrt)
return wrt.toString()
}
@@ -422,6 +427,28 @@ class GsonSerializerTest {
assertEquals(expected, dataJson)
}
+ @Test
+ fun `serializing user feedback`() {
+ val actual = serializeToString(userFeedback)
+
+ val expected = "{\"event_id\":\"${userFeedback.eventId}\",\"name\":\"${userFeedback.name}\"," +
+ "\"email\":\"${userFeedback.email}\",\"comments\":\"${userFeedback.comments}\"}"
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `deserializing user feedback`() {
+ val jsonUserFeedback = "{\"event_id\":\"c2fb8fee2e2b49758bcb67cda0f713c7\"," +
+ "\"name\":\"John\",\"email\":\"john@me.com\",\"comments\":\"comment\"}"
+ val actual = serializer.deserializeUserFeedback(StringReader(jsonUserFeedback))
+
+ assertEquals(userFeedback.eventId, actual.eventId)
+ assertEquals(userFeedback.name, actual.name)
+ assertEquals(userFeedback.email, actual.email)
+ assertEquals(userFeedback.comments, actual.comments)
+ }
+
private fun assertSessionData(expectedSession: Session?) {
assertNotNull(expectedSession)
assertEquals(UUID.fromString("c81d4e2e-bcf2-11e6-869b-7df92533d2db"), expectedSession.sessionId)
@@ -472,4 +499,14 @@ class GsonSerializerTest {
"debug",
"io.sentry@1.0+123"
)
+
+ private val userFeedback: UserFeedback get() {
+ val eventId = SentryId("c2fb8fee2e2b49758bcb67cda0f713c7")
+ return UserFeedback(eventId).apply {
+ name = "John"
+ email = "john@me.com"
+ comments = "comment"
+ }
+ }
}
+
diff --git a/sentry/src/test/java/io/sentry/HubTest.kt b/sentry/src/test/java/io/sentry/HubTest.kt
index 89c6f803dd3..0e5305ffe94 100644
--- a/sentry/src/test/java/io/sentry/HubTest.kt
+++ b/sentry/src/test/java/io/sentry/HubTest.kt
@@ -3,7 +3,9 @@ package io.sentry
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.anyOrNull
import com.nhaarman.mockitokotlin2.argWhere
+import com.nhaarman.mockitokotlin2.check
import com.nhaarman.mockitokotlin2.doAnswer
+import com.nhaarman.mockitokotlin2.doThrow
import com.nhaarman.mockitokotlin2.eq
import com.nhaarman.mockitokotlin2.isNull
import com.nhaarman.mockitokotlin2.mock
@@ -121,8 +123,7 @@ class HubTest {
fun `when beforeBreadcrumb returns null, crumb is dropped`() {
val options = SentryOptions()
options.cacheDirPath = file.absolutePath
- options.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback {
- _: Breadcrumb, _: Any? -> null }
+ options.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { _: Breadcrumb, _: Any? -> null }
options.dsn = "https://key@sentry.io/proj"
options.setSerializer(mock())
val sut = Hub(options)
@@ -235,13 +236,7 @@ class HubTest {
@Test
fun `when flush is called on disabled client, no-op`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
sut.close()
sut.flush(1000)
@@ -250,13 +245,7 @@ class HubTest {
@Test
fun `when flush is called, client flush gets called`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
sut.flush(1000)
verify(mockClient).flush(1000)
@@ -276,13 +265,7 @@ class HubTest {
@Test
fun `when captureEvent is called on disabled client, do nothing`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
sut.close()
sut.captureEvent(SentryEvent())
@@ -291,13 +274,7 @@ class HubTest {
@Test
fun `when captureEvent is called with a valid argument, captureEvent on the client should be called`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
val event = SentryEvent()
val hint = { }
@@ -307,13 +284,7 @@ class HubTest {
@Test
fun `when captureEvent is called and session tracking is disabled, it should not capture a session`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
val event = SentryEvent()
val hint = { }
@@ -324,13 +295,7 @@ class HubTest {
@Test
fun `when captureEvent is called but no session started, it should not capture a session`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
val event = SentryEvent()
val hint = { }
@@ -354,13 +319,7 @@ class HubTest {
@Test
fun `when captureMessage is called on disabled client, do nothing`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
sut.close()
sut.captureMessage("test")
@@ -369,13 +328,7 @@ class HubTest {
@Test
fun `when captureMessage is called with a valid message, captureMessage on the client should be called`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
sut.captureMessage("test")
verify(mockClient).captureMessage(any(), any(), any())
@@ -383,13 +336,7 @@ class HubTest {
@Test
fun `when captureMessage is called, level is INFO by default`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
sut.captureMessage("test")
verify(mockClient).captureMessage(eq("test"), eq(SentryLevel.INFO), any())
}
@@ -409,13 +356,7 @@ class HubTest {
@Test
fun `when captureException is called on disabled client, do nothing`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
sut.close()
sut.captureException(Throwable())
@@ -424,13 +365,7 @@ class HubTest {
@Test
fun `when captureException is called with a valid argument and hint, captureException on the client should be called`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
sut.captureException(Throwable(), Object())
verify(mockClient).captureException(any(), any(), any())
@@ -438,29 +373,60 @@ class HubTest {
@Test
fun `when captureException is called with a valid argument but no hint, captureException on the client should be called`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
sut.captureException(Throwable())
verify(mockClient).captureException(any(), any(), isNull())
}
//endregion
+ //region captureUserFeedback tests
+
+ @Test
+ fun `when captureUserFeedback is called it is forwarded to the client`() {
+ val (sut, mockClient) = getEnabledHub()
+ sut.captureUserFeedback(userFeedback)
+
+ verify(mockClient).captureUserFeedback(check {
+ assertEquals(userFeedback.eventId, it.eventId)
+ assertEquals(userFeedback.email, it.email)
+ assertEquals(userFeedback.name, it.name)
+ assertEquals(userFeedback.comments, it.comments)
+ })
+ }
+
+ @Test
+ fun `when captureUserFeedback is called on disabled client, do nothing`() {
+ val (sut, mockClient) = getEnabledHub()
+ sut.close()
+
+ sut.captureUserFeedback(userFeedback)
+ verify(mockClient, never()).captureUserFeedback(any())
+ }
+ @Test
+ fun `when captureUserFeedback is called and client throws, don't crash`() {
+ val (sut, mockClient) = getEnabledHub()
+
+ whenever(mockClient.captureUserFeedback(any())).doThrow(InvalidDsnException(""))
+
+ sut.captureUserFeedback(userFeedback)
+ }
+
+ private val userFeedback: UserFeedback get() {
+ val eventId = SentryId("c2fb8fee2e2b49758bcb67cda0f713c7")
+ return UserFeedback(eventId).apply {
+ name = "John"
+ email = "john@me.com"
+ comments = "comment"
+ }
+ }
+
+ //endregion
+
//region close tests
@Test
fun `when close is called on disabled client, do nothing`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
sut.close()
sut.close()
@@ -469,13 +435,7 @@ class HubTest {
@Test
fun `when close is called and client is alive, close on the client should be called`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut, mockClient) = getEnabledHub()
sut.close()
verify(mockClient).close()
@@ -485,13 +445,7 @@ class HubTest {
//region withScope tests
@Test
fun `when withScope is called on disabled client, do nothing`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut) = getEnabledHub()
val scopeCallback = mock()
sut.close()
@@ -502,13 +456,7 @@ class HubTest {
@Test
fun `when withScope is called with alive client, run should be called`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut) = getEnabledHub()
val scopeCallback = mock()
@@ -520,13 +468,7 @@ class HubTest {
//region configureScope tests
@Test
fun `when configureScope is called on disabled client, do nothing`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut) = getEnabledHub()
val scopeCallback = mock()
sut.close()
@@ -537,13 +479,7 @@ class HubTest {
@Test
fun `when configureScope is called with alive client, run should be called`() {
- val options = SentryOptions()
- options.cacheDirPath = file.absolutePath
- options.dsn = "https://key@sentry.io/proj"
- options.setSerializer(mock())
- val sut = Hub(options)
- val mockClient = mock()
- sut.bindClient(mockClient)
+ val (sut) = getEnabledHub()
val scopeCallback = mock()
@@ -978,4 +914,15 @@ class HubTest {
}
return Hub(options)
}
+
+ private fun getEnabledHub(): Pair {
+ val options = SentryOptions()
+ options.cacheDirPath = file.absolutePath
+ options.dsn = "https://key@sentry.io/proj"
+ options.setSerializer(mock())
+ val sut = Hub(options)
+ val mockClient = mock()
+ sut.bindClient(mockClient)
+ return Pair(sut, mockClient)
+ }
}
diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt
index 04d916e5b75..5d9da992764 100644
--- a/sentry/src/test/java/io/sentry/SentryClientTest.kt
+++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt
@@ -22,11 +22,16 @@ import io.sentry.protocol.User
import io.sentry.transport.AsyncConnection
import io.sentry.transport.HttpTransport
import io.sentry.transport.ITransportGate
-import java.io.ByteArrayInputStream
+import java.io.BufferedWriter
+import java.io.ByteArrayOutputStream
import java.io.IOException
+import java.io.OutputStreamWriter
import java.io.InputStreamReader
+import java.io.ByteArrayInputStream
import java.lang.RuntimeException
import java.net.URL
+import java.nio.charset.Charset
+import java.util.Arrays
import java.util.UUID
import kotlin.test.Ignore
import kotlin.test.Test
@@ -47,6 +52,8 @@ class SentryClientTest {
name = "test"
version = "1.2.3"
}
+ isDebug = true
+ setDiagnosticLevel(SentryLevel.DEBUG)
setSerializer(GsonSerializer(mock(), envelopeReader))
}
var connection: AsyncConnection = mock()
@@ -328,6 +335,50 @@ class SentryClientTest {
assertEquals(allEvents, mockingDetails(fixture.connection).invocations.size - 1) // 1 extra invocation outside .send()
}
+ @Test
+ fun `when captureUserFeedback, envelope is sent`() {
+ val sut = fixture.getSut()
+
+ sut.captureUserFeedback(userFeedback)
+
+ verify(fixture.connection).send(check { actual ->
+ assertEquals(userFeedback.eventId, actual.header.eventId)
+ assertEquals(fixture.sentryOptions.sdkVersion, actual.header.sdkVersion)
+
+ assertEquals(1, actual.items.count())
+ val item = actual.items.first()
+ assertEquals(SentryItemType.UserFeedback, item.header.type)
+ assertEquals("application/json", item.header.contentType)
+
+ assertEnvelopeItemDataForUserFeedback(item)
+ })
+ }
+
+ private fun assertEnvelopeItemDataForUserFeedback(item: SentryEnvelopeItem) {
+ val stream = ByteArrayOutputStream()
+ val writer = stream.bufferedWriter(Charset.forName("UTF-8"))
+ fixture.sentryOptions.serializer.serialize(userFeedback, writer)
+ val expectedData = stream.toByteArray()
+ assertTrue(Arrays.equals(expectedData, item.data))
+ }
+
+ @Test
+ fun `when captureUserFeedback and connection throws, log exception`() {
+ val sut = fixture.getSut()
+
+ val exception = IOException("No connection")
+ whenever(fixture.connection.send(any())).thenThrow(exception)
+
+ val logger = mock()
+ fixture.sentryOptions.setLogger(logger)
+
+ sut.captureUserFeedback(userFeedback)
+
+ verify(logger)
+ .log(SentryLevel.WARNING, exception,
+ "Capturing user feedback %s failed.", userFeedback.eventId);
+ }
+
@Test
fun `when hint is Cached, scope is not applied`() {
val sut = fixture.getSut()
@@ -675,6 +726,18 @@ class SentryClientTest {
return Session("dis", User(), "env", release)
}
+ private val userFeedback: UserFeedback get() {
+ val eventId = SentryId("c2fb8fee2e2b49758bcb67cda0f713c7")
+ val userFeedback = UserFeedback(eventId)
+ userFeedback.apply {
+ name = "John"
+ email = "john@me.com"
+ comments = "comment"
+ }
+
+ return userFeedback
+ }
+
internal class CustomTransportGate : ITransportGate {
override fun isConnected(): Boolean = false
}
diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt
index 9cc15c9ae21..cdd1a0b5a94 100644
--- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt
+++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt
@@ -1,6 +1,7 @@
package io.sentry
import com.nhaarman.mockitokotlin2.mock
+import io.sentry.protocol.SentryId
import io.sentry.protocol.User
import kotlin.test.Test
import kotlin.test.assertEquals
diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt
index 940a8a69621..8b158a94a5d 100644
--- a/sentry/src/test/java/io/sentry/SentryTest.kt
+++ b/sentry/src/test/java/io/sentry/SentryTest.kt
@@ -1,8 +1,10 @@
package io.sentry
+import com.nhaarman.mockitokotlin2.argThat
import com.nhaarman.mockitokotlin2.eq
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.verify
+import io.sentry.protocol.SentryId
import java.io.File
import java.nio.file.Files
import java.util.concurrent.CompletableFuture
@@ -16,6 +18,8 @@ import org.junit.rules.TemporaryFolder
class SentryTest {
+ private val dsn = "http://key@localhost/proj"
+
@BeforeTest
@AfterTest
fun beforeTest() {
@@ -26,7 +30,7 @@ class SentryTest {
fun `outboxDir should be created at initialization`() {
var sentryOptions: SentryOptions? = null
Sentry.init {
- it.dsn = "http://key@localhost/proj"
+ it.dsn = dsn
it.cacheDirPath = getTempPath()
sentryOptions = it
}
@@ -40,7 +44,7 @@ class SentryTest {
fun `envelopesDir should be created at initialization`() {
var sentryOptions: SentryOptions? = null
Sentry.init {
- it.dsn = "http://key@localhost/proj"
+ it.dsn = dsn
it.cacheDirPath = getTempPath()
sentryOptions = it
}
@@ -54,7 +58,7 @@ class SentryTest {
fun `Init sets SystemOutLogger if logger is NoOp and debug is enabled`() {
var sentryOptions: SentryOptions? = null
Sentry.init {
- it.dsn = "http://key@localhost/proj"
+ it.dsn = dsn
it.cacheDirPath = getTempPath()
sentryOptions = it
it.isDebug = true
@@ -67,7 +71,7 @@ class SentryTest {
fun `Init sets GsonSerializer if serializer is NoOp`() {
var sentryOptions: SentryOptions? = null
Sentry.init {
- it.dsn = "http://key@localhost/proj"
+ it.dsn = dsn
it.cacheDirPath = getTempPath()
sentryOptions = it
}
@@ -78,7 +82,7 @@ class SentryTest {
@Test
fun `scope changes are isolated to a thread`() {
Sentry.init {
- it.dsn = "http://key@localhost/proj"
+ it.dsn = dsn
}
Sentry.configureScope {
it.setTag("a", "a")
@@ -103,10 +107,10 @@ class SentryTest {
fun `warns about multiple Sentry initializations`() {
val logger = mock()
Sentry.init {
- it.dsn = "http://key@localhost/proj"
+ it.dsn = dsn
}
Sentry.init {
- it.dsn = "http://key@localhost/proj"
+ it.dsn = dsn
it.isDebug = true
it.setLogger(logger)
}
@@ -134,6 +138,21 @@ class SentryTest {
}
}
+ @Test
+ fun `captureUserFeedback gets forwarded to client`() {
+ Sentry.init { it.dsn = dsn }
+
+ val client = mock()
+ Sentry.getCurrentHub().bindClient(client)
+
+ val userFeedback = UserFeedback(SentryId.EMPTY_ID)
+ Sentry.captureUserFeedback(userFeedback)
+
+ verify(client).captureUserFeedback(argThat {
+ eventId == userFeedback.eventId
+ })
+ }
+
private fun getTempPath(): String {
val tempFile = Files.createTempDirectory("cache").toFile()
tempFile.delete()