diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c384783a5..29490479ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Add `sendModules` option for disable sending modules ([#2926](https://github.com/getsentry/sentry-java/pull/2926)) - Send `db.system` and `db.name` in span data for androidx.sqlite spans ([#2928](https://github.com/getsentry/sentry-java/pull/2928)) +- Add API for sending checkins (CRONS) manually ([#2935](https://github.com/getsentry/sentry-java/pull/2935)) ### Fixes diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt index b9bd902b50..73f445940e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.Lifecycle.Event.ON_STOP import androidx.lifecycle.LifecycleRegistry import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.CheckIn import io.sentry.Hint import io.sentry.ISentryClient import io.sentry.ProfilingTraceData @@ -162,5 +163,9 @@ class SessionTrackingIntegrationTest { ): SentryId { TODO("Not yet implemented") } + + override fun captureCheckIn(checkIn: CheckIn, scope: Scope?, hint: Hint?): SentryId { + TODO("Not yet implemented") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java index a9be71fb3e..4bc9c817e2 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java @@ -1,6 +1,12 @@ package io.sentry.samples.spring.boot.jakarta; +import io.sentry.CheckIn; +import io.sentry.CheckInStatus; +import io.sentry.DateUtils; +import io.sentry.Sentry; +import io.sentry.protocol.SentryId; import io.sentry.spring.jakarta.tracing.SentryTransaction; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -16,9 +22,24 @@ public class CustomJob { private static final Logger LOGGER = LoggerFactory.getLogger(CustomJob.class); - @Scheduled(fixedRate = 3 * 1000L) + @Scheduled(fixedRate = 3 * 60 * 1000L) void execute() throws InterruptedException { - LOGGER.info("Executing scheduled job"); - Thread.sleep(2000L); + final @NotNull SentryId checkInId = + Sentry.captureCheckIn(new CheckIn("my_monitor_slug", CheckInStatus.IN_PROGRESS)); + final long startTime = System.currentTimeMillis(); + boolean didError = false; + try { + LOGGER.info("Executing scheduled job"); + Thread.sleep(2000L); + Sentry.captureCheckIn(new CheckIn(checkInId, "my_monitor_slug", CheckInStatus.OK)); + } catch (Throwable t) { + didError = true; + throw t; + } finally { + final @NotNull CheckInStatus status = didError ? CheckInStatus.ERROR : CheckInStatus.OK; + CheckIn checkIn = new CheckIn(checkInId, "my_monitor_slug", status); + checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); + Sentry.captureCheckIn(checkIn); + } } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 7967e2d70a..8f44d415e1 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -155,6 +155,57 @@ public final class io/sentry/BuildConfig { public static final field VERSION_NAME Ljava/lang/String; } +public final class io/sentry/CheckIn : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun (Lio/sentry/protocol/SentryId;Ljava/lang/String;Lio/sentry/CheckInStatus;)V + public fun (Lio/sentry/protocol/SentryId;Ljava/lang/String;Ljava/lang/String;)V + public fun (Ljava/lang/String;Lio/sentry/CheckInStatus;)V + public fun getCheckInId ()Lio/sentry/protocol/SentryId; + public fun getContexts ()Lio/sentry/MonitorContexts; + public fun getDuration ()Ljava/lang/Double; + public fun getEnvironment ()Ljava/lang/String; + public fun getMonitorConfig ()Lio/sentry/MonitorConfig; + public fun getMonitorSlug ()Ljava/lang/String; + public fun getRelease ()Ljava/lang/String; + public fun getStatus ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setDuration (Ljava/lang/Double;)V + public fun setEnvironment (Ljava/lang/String;)V + public fun setMonitorConfig (Lio/sentry/MonitorConfig;)V + public fun setMonitorSlug (Ljava/lang/String;)V + public fun setRelease (Ljava/lang/String;)V + public fun setStatus (Lio/sentry/CheckInStatus;)V + public fun setStatus (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/CheckIn$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/CheckIn; + public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/CheckIn$JsonKeys { + public static final field CHECK_IN_ID Ljava/lang/String; + public static final field CONTEXTS Ljava/lang/String; + public static final field DURATION Ljava/lang/String; + public static final field ENVIRONMENT Ljava/lang/String; + public static final field MONITOR_CONFIG Ljava/lang/String; + public static final field MONITOR_SLUG Ljava/lang/String; + public static final field RELEASE Ljava/lang/String; + public static final field STATUS Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/CheckInStatus : java/lang/Enum { + public static final field ERROR Lio/sentry/CheckInStatus; + public static final field IN_PROGRESS Lio/sentry/CheckInStatus; + public static final field OK Lio/sentry/CheckInStatus; + public fun apiName ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/CheckInStatus; + public static fun values ()[Lio/sentry/CheckInStatus; +} + public final class io/sentry/CpuCollectionData { public fun (JD)V public fun getCpuUsagePercentage ()D @@ -354,6 +405,7 @@ public final class io/sentry/Hub : io/sentry/IHub { public fun (Lio/sentry/SentryOptions;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public fun bindClient (Lio/sentry/ISentryClient;)V + public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; @@ -400,6 +452,7 @@ public final class io/sentry/Hub : io/sentry/IHub { public final class io/sentry/HubAdapter : io/sentry/IHub { public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public fun bindClient (Lio/sentry/ISentryClient;)V + public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; @@ -464,6 +517,7 @@ public abstract interface class io/sentry/IHub { public fun addBreadcrumb (Ljava/lang/String;)V public fun addBreadcrumb (Ljava/lang/String;Ljava/lang/String;)V public abstract fun bindClient (Lio/sentry/ISentryClient;)V + public abstract fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;)Lio/sentry/protocol/SentryId; public abstract fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEvent (Lio/sentry/SentryEvent;)Lio/sentry/protocol/SentryId; @@ -563,6 +617,7 @@ public abstract interface class io/sentry/IScopeObserver { } public abstract interface class io/sentry/ISentryClient { + public abstract fun captureCheckIn (Lio/sentry/CheckIn;Lio/sentry/Scope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;)Lio/sentry/protocol/SentryId; public abstract fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEvent (Lio/sentry/SentryEvent;)Lio/sentry/protocol/SentryId; @@ -850,6 +905,99 @@ public final class io/sentry/MemoryCollectionData { public fun getUsedNativeMemory ()J } +public final class io/sentry/MonitorConfig : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun (Lio/sentry/MonitorSchedule;)V + public fun getCheckinMargin ()Ljava/lang/Long; + public fun getMaxRuntime ()Ljava/lang/Long; + public fun getSchedule ()Lio/sentry/MonitorSchedule; + public fun getTimezone ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setCheckinMargin (Ljava/lang/Long;)V + public fun setMaxRuntime (Ljava/lang/Long;)V + public fun setSchedule (Lio/sentry/MonitorSchedule;)V + public fun setTimezone (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/MonitorConfig$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorConfig; + public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/MonitorConfig$JsonKeys { + public static final field CHECKIN_MARGIN Ljava/lang/String; + public static final field MAX_RUNTIME Ljava/lang/String; + public static final field SCHEDULE Ljava/lang/String; + public static final field TIMEZONE Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/MonitorContexts : java/util/concurrent/ConcurrentHashMap, io/sentry/JsonSerializable { + public fun ()V + public fun (Lio/sentry/MonitorContexts;)V + public fun getTrace ()Lio/sentry/SpanContext; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setTrace (Lio/sentry/SpanContext;)V +} + +public final class io/sentry/MonitorContexts$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorContexts; + public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/MonitorSchedule : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public static fun crontab (Ljava/lang/String;)Lio/sentry/MonitorSchedule; + public fun getType ()Ljava/lang/String; + public fun getUnit ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun getValue ()Ljava/lang/String; + public static fun interval (Ljava/lang/Integer;Lio/sentry/MonitorScheduleUnit;)Lio/sentry/MonitorSchedule; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setType (Ljava/lang/String;)V + public fun setUnit (Lio/sentry/MonitorScheduleUnit;)V + public fun setUnit (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V + public fun setValue (Ljava/lang/Integer;)V + public fun setValue (Ljava/lang/String;)V +} + +public final class io/sentry/MonitorSchedule$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorSchedule; + public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/MonitorSchedule$JsonKeys { + public static final field TYPE Ljava/lang/String; + public static final field UNIT Ljava/lang/String; + public static final field VALUE Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/MonitorScheduleType : java/lang/Enum { + public static final field CRONTAB Lio/sentry/MonitorScheduleType; + public static final field INTERVAL Lio/sentry/MonitorScheduleType; + public fun apiName ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/MonitorScheduleType; + public static fun values ()[Lio/sentry/MonitorScheduleType; +} + +public final class io/sentry/MonitorScheduleUnit : java/lang/Enum { + public static final field DAY Lio/sentry/MonitorScheduleUnit; + public static final field HOUR Lio/sentry/MonitorScheduleUnit; + public static final field MINUTE Lio/sentry/MonitorScheduleUnit; + public static final field MONTH Lio/sentry/MonitorScheduleUnit; + public static final field WEEK Lio/sentry/MonitorScheduleUnit; + public static final field YEAR Lio/sentry/MonitorScheduleUnit; + public fun apiName ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/MonitorScheduleUnit; + public static fun values ()[Lio/sentry/MonitorScheduleUnit; +} + public final class io/sentry/NoOpEnvelopeReader : io/sentry/IEnvelopeReader { public static fun getInstance ()Lio/sentry/NoOpEnvelopeReader; public fun read (Ljava/io/InputStream;)Lio/sentry/SentryEnvelope; @@ -858,6 +1006,7 @@ public final class io/sentry/NoOpEnvelopeReader : io/sentry/IEnvelopeReader { public final class io/sentry/NoOpHub : io/sentry/IHub { public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public fun bindClient (Lio/sentry/ISentryClient;)V + public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; @@ -1310,6 +1459,7 @@ public final class io/sentry/Sentry { public static fun addBreadcrumb (Ljava/lang/String;)V public static fun addBreadcrumb (Ljava/lang/String;Ljava/lang/String;)V public static fun bindClient (Lio/sentry/ISentryClient;)V + public static fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public static fun captureEvent (Lio/sentry/SentryEvent;)Lio/sentry/protocol/SentryId; public static fun captureEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public static fun captureEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; @@ -1456,6 +1606,7 @@ public final class io/sentry/SentryBaseEvent$Serializer { } public final class io/sentry/SentryClient : io/sentry/ISentryClient { + public fun captureCheckIn (Lio/sentry/CheckIn;Lio/sentry/Scope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEvent (Lio/sentry/SentryEvent;Lio/sentry/Scope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureSession (Lio/sentry/Session;Lio/sentry/Hint;)V @@ -1530,6 +1681,7 @@ public final class io/sentry/SentryEnvelopeHeader$JsonKeys { public final class io/sentry/SentryEnvelopeItem { public static fun fromAttachment (Lio/sentry/ISerializer;Lio/sentry/ILogger;Lio/sentry/Attachment;J)Lio/sentry/SentryEnvelopeItem; + public static fun fromCheckIn (Lio/sentry/ISerializer;Lio/sentry/CheckIn;)Lio/sentry/SentryEnvelopeItem; public static fun fromClientReport (Lio/sentry/ISerializer;Lio/sentry/clientreport/ClientReport;)Lio/sentry/SentryEnvelopeItem; public static fun fromEvent (Lio/sentry/ISerializer;Lio/sentry/SentryBaseEvent;)Lio/sentry/SentryEnvelopeItem; public static fun fromProfilingTrace (Lio/sentry/ProfilingTraceData;JLio/sentry/ISerializer;)Lio/sentry/SentryEnvelopeItem; @@ -1648,6 +1800,7 @@ public final class io/sentry/SentryIntegrationPackageStorage { public final class io/sentry/SentryItemType : java/lang/Enum, io/sentry/JsonSerializable { public static final field Attachment Lio/sentry/SentryItemType; + public static final field CheckIn Lio/sentry/SentryItemType; public static final field ClientReport Lio/sentry/SentryItemType; public static final field Event Lio/sentry/SentryItemType; public static final field Profile Lio/sentry/SentryItemType; diff --git a/sentry/src/main/java/io/sentry/CheckIn.java b/sentry/src/main/java/io/sentry/CheckIn.java new file mode 100644 index 0000000000..7e6822971f --- /dev/null +++ b/sentry/src/main/java/io/sentry/CheckIn.java @@ -0,0 +1,253 @@ +package io.sentry; + +import io.sentry.protocol.SentryId; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Adds additional information about what happened to an event. */ +public final class CheckIn implements JsonUnknown, JsonSerializable { + + private final @NotNull SentryId checkInId; + private @NotNull String monitorSlug; + private @NotNull String status; + private @Nullable Double duration; // in seconds + private @Nullable String release; + private @Nullable String environment; + + private final @NotNull MonitorContexts contexts = new MonitorContexts(); + private @Nullable MonitorConfig monitorConfig; + + private @Nullable Map unknown; + + public CheckIn(final @NotNull String monitorSlug, final @NotNull CheckInStatus status) { + this(new SentryId(), monitorSlug, status.apiName()); + } + + public CheckIn( + final @NotNull SentryId checkInId, + final @NotNull String monitorSlug, + final @NotNull CheckInStatus status) { + this(checkInId, monitorSlug, status.apiName()); + } + + @ApiStatus.Internal + public CheckIn( + final @NotNull SentryId checkInId, + final @NotNull String monitorSlug, + final @NotNull String status) { + this.checkInId = checkInId; + this.monitorSlug = monitorSlug; + this.status = status; + } + + // JsonKeys + + public static final class JsonKeys { + public static final String CHECK_IN_ID = "check_in_id"; + public static final String MONITOR_SLUG = "monitor_slug"; + public static final String STATUS = "status"; + public static final String DURATION = "duration"; + public static final String RELEASE = "release"; + public static final String ENVIRONMENT = "environment"; + public static final String CONTEXTS = "contexts"; + public static final String MONITOR_CONFIG = "monitor_config"; + } + + public @NotNull SentryId getCheckInId() { + return checkInId; + } + + public @NotNull String getMonitorSlug() { + return monitorSlug; + } + + public void setMonitorSlug(@NotNull String monitorSlug) { + this.monitorSlug = monitorSlug; + } + + public @NotNull String getStatus() { + return status; + } + + public void setStatus(@NotNull String status) { + this.status = status; + } + + public void setStatus(@NotNull CheckInStatus status) { + this.status = status.apiName(); + } + + public @Nullable Double getDuration() { + return duration; + } + + public void setDuration(@Nullable Double duration) { + this.duration = duration; + } + + public @Nullable String getRelease() { + return release; + } + + public void setRelease(@Nullable String release) { + this.release = release; + } + + public @Nullable String getEnvironment() { + return environment; + } + + public void setEnvironment(@Nullable String environment) { + this.environment = environment; + } + + public @Nullable MonitorConfig getMonitorConfig() { + return monitorConfig; + } + + public void setMonitorConfig(@Nullable MonitorConfig monitorConfig) { + this.monitorConfig = monitorConfig; + } + + public @NotNull MonitorContexts getContexts() { + return contexts; + } + // JsonUnknown + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + // JsonSerializable + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.CHECK_IN_ID); + checkInId.serialize(writer, logger); + writer.name(JsonKeys.MONITOR_SLUG).value(monitorSlug); + writer.name(JsonKeys.STATUS).value(status); + if (duration != null) { + writer.name(JsonKeys.DURATION).value(duration); + } + if (release != null) { + writer.name(JsonKeys.RELEASE).value(release); + } + if (environment != null) { + writer.name(JsonKeys.ENVIRONMENT).value(environment); + } + if (monitorConfig != null) { + writer.name(JsonKeys.MONITOR_CONFIG); + monitorConfig.serialize(writer, logger); + } + if (contexts != null) { + writer.name(JsonKeys.CONTEXTS); + contexts.serialize(writer, logger); + } + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + // JsonDeserializer + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull CheckIn deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + throws Exception { + SentryId sentryId = null; + MonitorConfig monitorConfig = null; + String monitorSlug = null; + String status = null; + Double duration = null; + String release = null; + String environment = null; + MonitorContexts contexts = null; + Map unknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.CHECK_IN_ID: + sentryId = new SentryId.Deserializer().deserialize(reader, logger); + break; + case JsonKeys.MONITOR_SLUG: + monitorSlug = reader.nextStringOrNull(); + break; + case JsonKeys.STATUS: + status = reader.nextStringOrNull(); + break; + case JsonKeys.DURATION: + duration = reader.nextDoubleOrNull(); + break; + case JsonKeys.RELEASE: + release = reader.nextStringOrNull(); + break; + case JsonKeys.ENVIRONMENT: + environment = reader.nextStringOrNull(); + break; + case JsonKeys.MONITOR_CONFIG: + monitorConfig = new MonitorConfig.Deserializer().deserialize(reader, logger); + break; + case JsonKeys.CONTEXTS: + contexts = new MonitorContexts.Deserializer().deserialize(reader, logger); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + reader.endObject(); + + if (sentryId == null) { + String message = "Missing required field \"" + JsonKeys.CHECK_IN_ID + "\""; + Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + if (monitorSlug == null) { + String message = "Missing required field \"" + JsonKeys.MONITOR_SLUG + "\""; + Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + if (status == null) { + String message = "Missing required field \"" + JsonKeys.STATUS + "\""; + Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + CheckIn checkIn = new CheckIn(sentryId, monitorSlug, status); + checkIn.setDuration(duration); + checkIn.setRelease(release); + checkIn.setEnvironment(environment); + checkIn.setMonitorConfig(monitorConfig); + checkIn.getContexts().putAll(contexts); + checkIn.setUnknown(unknown); + return checkIn; + } + } +} diff --git a/sentry/src/main/java/io/sentry/CheckInStatus.java b/sentry/src/main/java/io/sentry/CheckInStatus.java new file mode 100644 index 0000000000..42ed7bac54 --- /dev/null +++ b/sentry/src/main/java/io/sentry/CheckInStatus.java @@ -0,0 +1,15 @@ +package io.sentry; + +import java.util.Locale; +import org.jetbrains.annotations.NotNull; + +/** Status of a CheckIn */ +public enum CheckInStatus { + IN_PROGRESS, + OK, + ERROR; + + public @NotNull String apiName() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java index 3171c67a95..5810390069 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -862,4 +862,25 @@ private Scope buildLocalScope( return null; } + + @Override + public @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn) { + SentryId sentryId = SentryId.EMPTY_ID; + if (!isEnabled()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Instance is disabled and this 'captureCheckIn' call is a no-op."); + } else { + try { + StackItem item = stack.peek(); + sentryId = item.getClient().captureCheckIn(checkIn, item.getScope(), null); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error while capturing check-in for slug", e); + } + } + this.lastEventId = sentryId; + return sentryId; + } } diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index e6ec220874..05f348cb15 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -251,4 +251,9 @@ public void reportFullyDisplayed() { public @Nullable BaggageHeader getBaggage() { return Sentry.getBaggage(); } + + @Override + public @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn) { + return Sentry.captureCheckIn(checkIn); + } } diff --git a/sentry/src/main/java/io/sentry/IHub.java b/sentry/src/main/java/io/sentry/IHub.java index 06f36f1a54..71bbb0f731 100644 --- a/sentry/src/main/java/io/sentry/IHub.java +++ b/sentry/src/main/java/io/sentry/IHub.java @@ -626,4 +626,7 @@ TransactionContext continueTrace( */ @Nullable BaggageHeader getBaggage(); + + @NotNull + SentryId captureCheckIn(final @NotNull CheckIn checkIn); } diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index 4789eb3dec..28ce8111d0 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -264,4 +264,7 @@ SentryId captureTransaction( default @NotNull SentryId captureTransaction(@NotNull SentryTransaction transaction) { return captureTransaction(transaction, null, null, null); } + + @NotNull + SentryId captureCheckIn(@NotNull CheckIn checkIn, @Nullable Scope scope, @Nullable Hint hint); } diff --git a/sentry/src/main/java/io/sentry/MonitorConfig.java b/sentry/src/main/java/io/sentry/MonitorConfig.java new file mode 100644 index 0000000000..b6ad81ee8f --- /dev/null +++ b/sentry/src/main/java/io/sentry/MonitorConfig.java @@ -0,0 +1,155 @@ +package io.sentry; + +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class MonitorConfig implements JsonUnknown, JsonSerializable { + + private @NotNull MonitorSchedule schedule; + private @Nullable Long checkinMargin; + private @Nullable Long maxRuntime; + private @Nullable String timezone; + + private @Nullable Map unknown; + + public MonitorConfig(final @NotNull MonitorSchedule schedule) { + this.schedule = schedule; + } + + public @NotNull MonitorSchedule getSchedule() { + return schedule; + } + + public void setSchedule(@NotNull MonitorSchedule schedule) { + this.schedule = schedule; + } + + public @Nullable Long getCheckinMargin() { + return checkinMargin; + } + + public void setCheckinMargin(@Nullable Long checkinMargin) { + this.checkinMargin = checkinMargin; + } + + public @Nullable Long getMaxRuntime() { + return maxRuntime; + } + + public void setMaxRuntime(@Nullable Long maxRuntime) { + this.maxRuntime = maxRuntime; + } + + public @Nullable String getTimezone() { + return timezone; + } + + public void setTimezone(@Nullable String timezone) { + this.timezone = timezone; + } + + // JsonKeys + + public static final class JsonKeys { + public static final String SCHEDULE = "schedule"; + public static final String CHECKIN_MARGIN = "checkin_margin"; + public static final String MAX_RUNTIME = "max_runtime"; + public static final String TIMEZONE = "timezone"; + } + + // JsonUnknown + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + // JsonSerializable + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.SCHEDULE); + schedule.serialize(writer, logger); + if (checkinMargin != null) { + writer.name(JsonKeys.CHECKIN_MARGIN).value(checkinMargin); + } + if (maxRuntime != null) { + writer.name(JsonKeys.MAX_RUNTIME).value(maxRuntime); + } + if (timezone != null) { + writer.name(JsonKeys.TIMEZONE).value(timezone); + } + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + // JsonDeserializer + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull MonitorConfig deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + MonitorSchedule schedule = null; + Long checkinMargin = null; + Long maxRuntime = null; + String timezone = null; + Map unknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.SCHEDULE: + schedule = new MonitorSchedule.Deserializer().deserialize(reader, logger); + break; + case JsonKeys.CHECKIN_MARGIN: + checkinMargin = reader.nextLongOrNull(); + break; + case JsonKeys.MAX_RUNTIME: + maxRuntime = reader.nextLongOrNull(); + break; + case JsonKeys.TIMEZONE: + timezone = reader.nextStringOrNull(); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + reader.endObject(); + + if (schedule == null) { + String message = "Missing required field \"" + JsonKeys.SCHEDULE + "\""; + Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + MonitorConfig monitorConfig = new MonitorConfig(schedule); + monitorConfig.setCheckinMargin(checkinMargin); + monitorConfig.setMaxRuntime(maxRuntime); + monitorConfig.setTimezone(timezone); + monitorConfig.setUnknown(unknown); + return monitorConfig; + } + } +} diff --git a/sentry/src/main/java/io/sentry/MonitorContexts.java b/sentry/src/main/java/io/sentry/MonitorContexts.java new file mode 100644 index 0000000000..461a4d549a --- /dev/null +++ b/sentry/src/main/java/io/sentry/MonitorContexts.java @@ -0,0 +1,90 @@ +package io.sentry; + +import io.sentry.util.Objects; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class MonitorContexts extends ConcurrentHashMap + implements JsonSerializable { + private static final long serialVersionUID = 3987329379811822556L; + + public MonitorContexts() {} + + public MonitorContexts(final @NotNull MonitorContexts contexts) { + for (final Entry entry : contexts.entrySet()) { + if (entry != null) { + final Object value = entry.getValue(); + if (SpanContext.TYPE.equals(entry.getKey()) && value instanceof SpanContext) { + this.setTrace(new SpanContext((SpanContext) value)); + } else { + this.put(entry.getKey(), value); + } + } + } + } + + private @Nullable T toContextType(final @NotNull String key, final @NotNull Class clazz) { + final Object item = get(key); + return clazz.isInstance(item) ? clazz.cast(item) : null; + } + + public @Nullable SpanContext getTrace() { + return toContextType(SpanContext.TYPE, SpanContext.class); + } + + public void setTrace(final @Nullable SpanContext traceContext) { + Objects.requireNonNull(traceContext, "traceContext is required"); + this.put(SpanContext.TYPE, traceContext); + } + + // region json + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + // Serialize in alphabetical order to keep determinism. + final List sortedKeys = Collections.list(keys()); + Collections.sort(sortedKeys); + for (final String key : sortedKeys) { + final Object value = get(key); + if (value != null) { + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull MonitorContexts deserialize( + final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws Exception { + final MonitorContexts contexts = new MonitorContexts(); + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case SpanContext.TYPE: + contexts.setTrace(new SpanContext.Deserializer().deserialize(reader, logger)); + break; + default: + Object object = reader.nextObjectOrNull(); + if (object != null) { + contexts.put(nextName, object); + } + break; + } + } + reader.endObject(); + return contexts; + } + } + + // endregion +} diff --git a/sentry/src/main/java/io/sentry/MonitorSchedule.java b/sentry/src/main/java/io/sentry/MonitorSchedule.java new file mode 100644 index 0000000000..904e84aeac --- /dev/null +++ b/sentry/src/main/java/io/sentry/MonitorSchedule.java @@ -0,0 +1,173 @@ +package io.sentry; + +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class MonitorSchedule implements JsonUnknown, JsonSerializable { + + public static @NotNull MonitorSchedule crontab(final @NotNull String value) { + return new MonitorSchedule(MonitorScheduleType.CRONTAB.apiName(), value, null); + } + + public static @NotNull MonitorSchedule interval( + final @NotNull Integer value, final @NotNull MonitorScheduleUnit unit) { + return new MonitorSchedule( + MonitorScheduleType.INTERVAL.apiName(), value.toString(), unit.apiName()); + } + + /** crontab | interval */ + private @NotNull String type; + + private @NotNull String value; + /** only required for type=interval */ + private @Nullable String unit; + + private @Nullable Map unknown; + + @ApiStatus.Internal + public MonitorSchedule( + final @NotNull String type, final @NotNull String value, final @Nullable String unit) { + this.type = type; + this.value = value; + this.unit = unit; + } + + public @NotNull String getType() { + return type; + } + + public void setType(final @NotNull String type) { + this.type = type; + } + + public @NotNull String getValue() { + return value; + } + + public void setValue(final @NotNull String value) { + this.value = value; + } + + public void setValue(final @NotNull Integer value) { + this.value = value.toString(); + } + + public @Nullable String getUnit() { + return unit; + } + + public void setUnit(final @Nullable String unit) { + this.unit = unit; + } + + public void setUnit(final @Nullable MonitorScheduleUnit unit) { + this.unit = unit == null ? null : unit.apiName(); + } + + // JsonKeys + + public static final class JsonKeys { + public static final String TYPE = "type"; + public static final String VALUE = "value"; + public static final String UNIT = "unit"; + } + + // JsonUnknown + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + // JsonSerializable + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.TYPE).value(type); + if (MonitorScheduleType.INTERVAL.apiName().equalsIgnoreCase(type)) { + try { + writer.name(JsonKeys.VALUE).value(Integer.valueOf(value)); + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Unable to serialize monitor schedule value: %s", value); + } + } else { + writer.name(JsonKeys.VALUE).value(value); + } + if (unit != null) { + writer.name(JsonKeys.UNIT).value(unit); + } + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + // JsonDeserializer + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull MonitorSchedule deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + String type = null; + String value = null; + String unit = null; + Map unknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TYPE: + type = reader.nextStringOrNull(); + break; + case JsonKeys.VALUE: + value = reader.nextStringOrNull(); + break; + case JsonKeys.UNIT: + unit = reader.nextStringOrNull(); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + reader.endObject(); + + if (type == null) { + String message = "Missing required field \"" + JsonKeys.TYPE + "\""; + Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + if (value == null) { + String message = "Missing required field \"" + JsonKeys.VALUE + "\""; + Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + MonitorSchedule monitorSchedule = new MonitorSchedule(type, value, unit); + monitorSchedule.setUnknown(unknown); + return monitorSchedule; + } + } +} diff --git a/sentry/src/main/java/io/sentry/MonitorScheduleType.java b/sentry/src/main/java/io/sentry/MonitorScheduleType.java new file mode 100644 index 0000000000..ac168e3845 --- /dev/null +++ b/sentry/src/main/java/io/sentry/MonitorScheduleType.java @@ -0,0 +1,14 @@ +package io.sentry; + +import java.util.Locale; +import org.jetbrains.annotations.NotNull; + +/** Type of a monitor schedule */ +public enum MonitorScheduleType { + CRONTAB, + INTERVAL; + + public @NotNull String apiName() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/sentry/src/main/java/io/sentry/MonitorScheduleUnit.java b/sentry/src/main/java/io/sentry/MonitorScheduleUnit.java new file mode 100644 index 0000000000..adfb0771fe --- /dev/null +++ b/sentry/src/main/java/io/sentry/MonitorScheduleUnit.java @@ -0,0 +1,18 @@ +package io.sentry; + +import java.util.Locale; +import org.jetbrains.annotations.NotNull; + +/** Time unit of a monitor schedule. */ +public enum MonitorScheduleUnit { + MINUTE, + HOUR, + DAY, + WEEK, + MONTH, + YEAR; + + public @NotNull String apiName() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index aa5d846975..4d40efe976 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -207,4 +207,9 @@ public void reportFullyDisplayed() {} public @Nullable BaggageHeader getBaggage() { return null; } + + @Override + public @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn) { + return SentryId.EMPTY_ID; + } } diff --git a/sentry/src/main/java/io/sentry/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java index 4afbdbb8c8..adeb63e356 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java @@ -52,4 +52,10 @@ public SentryId captureEnvelope(@NotNull SentryEnvelope envelope, @Nullable Hint @Nullable ProfilingTraceData profilingTraceData) { return SentryId.EMPTY_ID; } + + @Override + public @NotNull SentryId captureCheckIn( + @NotNull CheckIn checkIn, @Nullable Scope scope, @Nullable Hint hint) { + return SentryId.EMPTY_ID; + } } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 6325193118..822822340f 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1011,4 +1011,8 @@ public interface OptionsConfiguration { public static @Nullable BaggageHeader getBaggage() { return getCurrentHub().getBaggage(); } + + public static @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn) { + return getCurrentHub().captureCheckIn(checkIn); + } } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index bd981842e3..8640a3985c 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -71,6 +71,20 @@ private boolean shouldApplyScopeData( } } + private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNull Hint hint) { + if (HintUtils.shouldApplyScopeData(hint)) { + return true; + } else { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Check-in was cached so not applying scope: %s", + event.getCheckInId()); + return false; + } + } + @Override public @NotNull SentryId captureEvent( @NotNull SentryEvent event, final @Nullable Scope scope, @Nullable Hint hint) { @@ -449,6 +463,20 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { return new SentryEnvelope(envelopeHeader, envelopeItems); } + private @NotNull SentryEnvelope buildEnvelope( + final @NotNull CheckIn checkIn, final @Nullable TraceContext traceContext) { + final List envelopeItems = new ArrayList<>(); + + final SentryEnvelopeItem checkInItem = + SentryEnvelopeItem.fromCheckIn(options.getSerializer(), checkIn); + envelopeItems.add(checkInItem); + + final SentryEnvelopeHeader envelopeHeader = + new SentryEnvelopeHeader(checkIn.getCheckInId(), options.getSdkVersion(), traceContext); + + return new SentryEnvelope(envelopeHeader, envelopeItems); + } + /** * Updates the session data based on the event, hint and scope data * @@ -642,6 +670,55 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint return sentryId; } + @Override + public @NotNull SentryId captureCheckIn( + @NotNull CheckIn checkIn, final @Nullable Scope scope, @Nullable Hint hint) { + if (hint == null) { + hint = new Hint(); + } + + if (checkIn.getEnvironment() == null) { + checkIn.setEnvironment(options.getEnvironment()); + } + + if (checkIn.getRelease() == null) { + checkIn.setRelease(options.getRelease()); + } + + if (shouldApplyScopeData(checkIn, hint)) { + checkIn = applyScope(checkIn, scope); + } + + options.getLogger().log(SentryLevel.DEBUG, "Capturing check-in: %s", checkIn.getCheckInId()); + + SentryId sentryId = checkIn.getCheckInId(); + + try { + @Nullable TraceContext traceContext = null; + if (scope != null) { + final @Nullable ITransaction transaction = scope.getTransaction(); + if (transaction != null) { + traceContext = transaction.traceContext(); + } else { + final @NotNull PropagationContext propagationContext = + TracingUtils.maybeUpdateBaggage(scope, options); + traceContext = propagationContext.traceContext(); + } + } + + final SentryEnvelope envelope = buildEnvelope(checkIn, traceContext); + + hint.clear(); + transport.send(envelope, hint); + } catch (IOException e) { + options.getLogger().log(SentryLevel.WARNING, e, "Capturing check-in %s failed.", sentryId); + // if there was an error capturing the event, we return an emptyId + sentryId = SentryId.EMPTY_ID; + } + + return sentryId; + } + private @Nullable List filterForTransaction(@Nullable List attachments) { if (attachments == null) { return null; @@ -689,6 +766,23 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint return event; } + private @NotNull CheckIn applyScope(@NotNull CheckIn checkIn, final @Nullable Scope scope) { + if (scope != null) { + // Set trace data from active span to connect events with transactions + final ISpan span = scope.getSpan(); + if (checkIn.getContexts().getTrace() == null) { + if (span == null) { + checkIn + .getContexts() + .setTrace(TransactionContext.fromPropagationContext(scope.getPropagationContext())); + } else { + checkIn.getContexts().setTrace(span.getSpanContext()); + } + } + } + return checkIn; + } + private @NotNull T applyScope( final @NotNull T sentryBaseEvent, final @Nullable Scope scope) { if (scope != null) { diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index f095dea15b..61a7819942 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -168,6 +168,30 @@ public static SentryEnvelopeItem fromUserFeedback( return new SentryEnvelopeItem(itemHeader, () -> cachedItem.getBytes()); } + public static SentryEnvelopeItem fromCheckIn( + final @NotNull ISerializer serializer, final @NotNull CheckIn checkIn) { + Objects.requireNonNull(serializer, "ISerializer is required."); + Objects.requireNonNull(checkIn, "CheckIn 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(checkIn, writer); + return stream.toByteArray(); + } + }); + + SentryEnvelopeItemHeader itemHeader = + new SentryEnvelopeItemHeader( + SentryItemType.CheckIn, () -> cachedItem.getBytes().length, "application/json", null); + + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef + return new SentryEnvelopeItem(itemHeader, () -> cachedItem.getBytes()); + } + public static SentryEnvelopeItem fromAttachment( final @NotNull ISerializer serializer, final @NotNull ILogger logger, diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index 69b94faa8e..c4535cb6a1 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -18,6 +18,7 @@ public enum SentryItemType implements JsonSerializable { ClientReport("client_report"), ReplayEvent("replay_event"), ReplayRecording("replay_recording"), + CheckIn("check_in"), Unknown("__unknown__"); // DataCategory.Unknown private final String itemType; diff --git a/sentry/src/test/java/io/sentry/CheckInSerializationTest.kt b/sentry/src/test/java/io/sentry/CheckInSerializationTest.kt new file mode 100644 index 0000000000..d7a3860fe6 --- /dev/null +++ b/sentry/src/test/java/io/sentry/CheckInSerializationTest.kt @@ -0,0 +1,145 @@ +package io.sentry + +import io.sentry.protocol.SentryId +import io.sentry.protocol.SerializationUtils +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import java.io.StringReader +import java.io.StringWriter +import java.time.ZoneId +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +class CheckInSerializationTest { + + private class Fixture { + val logger = mock() + + fun getSut(type: MonitorScheduleType): CheckIn { + return CheckIn("some_slug", CheckInStatus.ERROR).apply { + contexts.trace = TransactionContext.fromPropagationContext( + PropagationContext().also { + it.traceId = SentryId("f382e3180c714217a81371f8c644aefe") + it.spanId = SpanId("85694b9f567145a6") + } + ) + duration = 12.3 + environment = "env" + release = "1.0.1" + val monitorConfigTmp = + if (MonitorScheduleType.CRONTAB.equals(type)) { + MonitorConfig(MonitorSchedule.crontab("0 * * * *")) + } else { + MonitorConfig(MonitorSchedule.interval(42, MonitorScheduleUnit.MINUTE)) + } + monitorConfig = monitorConfigTmp.apply { + checkinMargin = 8L + maxRuntime = 9L + timezone = ZoneId.of("Europe/Vienna").id + } + } + } + } + private val fixture = Fixture() + + @Test + fun serializeInterval() { + val checkIn = fixture.getSut(MonitorScheduleType.INTERVAL) + val actual = serialize(checkIn) + val expected = SerializationUtils.sanitizedFile("json/checkin_interval.json") + .replace("6d55218195564d6d88cf3883b84666f1", checkIn.checkInId.toString()) + + assertEquals(expected, actual) + } + + @Test + fun serializeCrontab() { + val checkIn = fixture.getSut(MonitorScheduleType.CRONTAB) + val actual = serialize(checkIn) + val expected = SerializationUtils.sanitizedFile("json/checkin_crontab.json") + .replace("6d55218195564d6d88cf3883b84666f1", checkIn.checkInId.toString()) + .replace("0_*_*_*_*", "0 * * * *") + + assertEquals(expected, actual) + } + + @Test + fun deserializeCrontab() { + val checkIn = fixture.getSut(MonitorScheduleType.CRONTAB) + val jsonCheckIn = SerializationUtils.sanitizedFile("json/checkin_crontab.json") + .replace("0_*_*_*_*", "0 * * * *") + val reader = JsonObjectReader(StringReader(jsonCheckIn)) + val actual = CheckIn.Deserializer().deserialize(reader, fixture.logger) + assertNotNull(actual) + assertEquals("6d55218195564d6d88cf3883b84666f1", actual.checkInId.toString()) + assertEquals(checkIn.status, actual.status) + assertTrue((checkIn.duration!! - actual.duration!!) < 0.01) + assertEquals(checkIn.release, actual.release) + assertEquals(checkIn.environment, actual.environment) + val actualContext = actual.contexts + assertEquals(checkIn.contexts.trace!!.traceId, actualContext.trace!!.traceId) + val actualConfig = actual.monitorConfig!! + val actualSchedule = actualConfig.schedule!! + val expectedConfig = checkIn.monitorConfig!! + val expectedSchedule = expectedConfig.schedule!! + assertEquals(expectedConfig.maxRuntime, actualConfig.maxRuntime) + assertEquals(expectedConfig.checkinMargin, actualConfig.checkinMargin) + assertEquals(expectedConfig.timezone, actualConfig.timezone) + assertEquals(expectedSchedule.type, actualSchedule.type) + assertEquals(expectedSchedule.value, actualSchedule.value) + assertEquals(expectedSchedule.unit, actualSchedule.unit) + } + + @Test + fun deserializeInterval() { + val checkIn = fixture.getSut(MonitorScheduleType.INTERVAL) + val jsonCheckIn = SerializationUtils.sanitizedFile("json/checkin_interval.json") + val reader = JsonObjectReader(StringReader(jsonCheckIn)) + val actual = CheckIn.Deserializer().deserialize(reader, fixture.logger) + assertNotNull(actual) + assertEquals("6d55218195564d6d88cf3883b84666f1", actual.checkInId.toString()) + assertEquals(checkIn.status, actual.status) + assertTrue((checkIn.duration!! - actual.duration!!) < 0.01) + assertEquals(checkIn.release, actual.release) + assertEquals(checkIn.environment, actual.environment) + val actualContext = actual.contexts + assertEquals(checkIn.contexts.trace!!.traceId, actualContext.trace!!.traceId) + val actualConfig = actual.monitorConfig!! + val actualSchedule = actualConfig.schedule!! + val expectedConfig = checkIn.monitorConfig!! + val expectedSchedule = expectedConfig.schedule!! + assertEquals(expectedConfig.maxRuntime, actualConfig.maxRuntime) + assertEquals(expectedConfig.checkinMargin, actualConfig.checkinMargin) + assertEquals(expectedConfig.timezone, actualConfig.timezone) + assertEquals(expectedSchedule.type, actualSchedule.type) + assertEquals(expectedSchedule.value, actualSchedule.value) + assertEquals(expectedSchedule.unit, actualSchedule.unit) + } + + @Test + fun `deserializing checkin with missing required fields`() { + val jsonCheckInWithoutId = "{\"status\":\"error\",\"monitor_slug\":\"some_slug\"}" + val reader = JsonObjectReader(StringReader(jsonCheckInWithoutId)) + + try { + CheckIn.Deserializer().deserialize(reader, fixture.logger) + fail() + } catch (exception: Exception) { + verify(fixture.logger).log(eq(SentryLevel.ERROR), any(), any()) + } + } + + // Helper + + private fun serialize(jsonSerializable: JsonSerializable): String { + val wrt = StringWriter() + val jsonWrt = JsonObjectWriter(wrt, 100) + jsonSerializable.serialize(jsonWrt, fixture.logger) + return wrt.toString() + } +} diff --git a/sentry/src/test/java/io/sentry/HubAdapterTest.kt b/sentry/src/test/java/io/sentry/HubAdapterTest.kt index 6fec20ca7d..aa302efb34 100644 --- a/sentry/src/test/java/io/sentry/HubAdapterTest.kt +++ b/sentry/src/test/java/io/sentry/HubAdapterTest.kt @@ -73,6 +73,12 @@ class HubAdapterTest { verify(hub).captureUserFeedback(eq(userFeedback)) } + @Test fun `captureCheckIn calls Hub`() { + val checkIn = mock() + HubAdapter.getInstance().captureCheckIn(checkIn) + verify(hub).captureCheckIn(eq(checkIn)) + } + @Test fun `startSession calls Hub`() { HubAdapter.getInstance().startSession() verify(hub).startSession() diff --git a/sentry/src/test/java/io/sentry/HubTest.kt b/sentry/src/test/java/io/sentry/HubTest.kt index 96a44462d4..2a7991f120 100644 --- a/sentry/src/test/java/io/sentry/HubTest.kt +++ b/sentry/src/test/java/io/sentry/HubTest.kt @@ -726,6 +726,44 @@ class HubTest { } } + //region captureCheckIn tests + + @Test + fun `when captureCheckIn is called it is forwarded to the client`() { + val (sut, mockClient) = getEnabledHub() + sut.captureCheckIn(checkIn) + + verify(mockClient).captureCheckIn( + check { + assertEquals(checkIn.checkInId, it.checkInId) + assertEquals(checkIn.monitorSlug, it.monitorSlug) + assertEquals(checkIn.status, it.status) + }, + any(), + anyOrNull() + ) + } + + @Test + fun `when captureCheckIn is called on disabled client, do nothing`() { + val (sut, mockClient) = getEnabledHub() + sut.close() + + sut.captureCheckIn(checkIn) + verify(mockClient, never()).captureCheckIn(any(), any(), anyOrNull()) + } + + @Test + fun `when captureCheckIn is called and client throws, don't crash`() { + val (sut, mockClient) = getEnabledHub() + + whenever(mockClient.captureCheckIn(any(), any(), anyOrNull())).doThrow(IllegalArgumentException("")) + + sut.captureCheckIn(checkIn) + } + + private val checkIn: CheckIn = CheckIn("some_slug", CheckInStatus.OK) + //endregion //region close tests diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 41d3947a5e..6f259743c1 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -535,6 +535,57 @@ class SentryClientTest { ) } + @Test + fun `when captureCheckIn, envelope is sent`() { + val sut = fixture.getSut() + + sut.captureCheckIn(checkIn, null, null) + + verify(fixture.transport).send( + check { actual -> + assertEquals(checkIn.checkInId, actual.header.eventId) + assertEquals(fixture.sentryOptions.sdkVersion, actual.header.sdkVersion) + + assertEquals(1, actual.items.count()) + val item = actual.items.first() + assertEquals(SentryItemType.CheckIn, item.header.type) + assertEquals("application/json", item.header.contentType) + + assertEnvelopeItemDataForCheckIn(item) + }, + any() + ) + } + + private fun assertEnvelopeItemDataForCheckIn(item: SentryEnvelopeItem) { + val stream = ByteArrayOutputStream() + val writer = stream.bufferedWriter(Charset.forName("UTF-8")) + fixture.sentryOptions.serializer.serialize(checkIn, writer) + val expectedData = stream.toByteArray() + assertTrue(Arrays.equals(expectedData, item.data)) + } + + @Test + fun `when captureCheckIn and connection throws, log exception`() { + val sut = fixture.getSut() + + val exception = IOException("No connection") + whenever(fixture.transport.send(any(), any())).thenThrow(exception) + + val logger = mock() + fixture.sentryOptions.setLogger(logger) + + sut.captureCheckIn(checkIn, null, null) + + verify(logger) + .log( + SentryLevel.WARNING, + exception, + "Capturing check-in %s failed.", + checkIn.checkInId + ) + } + @Test fun `when hint is Cached, scope is not applied`() { val sut = fixture.getSut() @@ -2477,6 +2528,8 @@ class SentryClientTest { return userFeedback } + private val checkIn = CheckIn("some_slug", CheckInStatus.OK) + internal class CustomTransportGate : ITransportGate { override fun isConnected(): Boolean = false } diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 43265583db..e9342c0004 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -17,6 +17,7 @@ import org.junit.Assert.assertThrows import org.junit.Rule import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -769,6 +770,25 @@ class SentryTest { assertFalse(previousSessionFile.exists()) } + @Test + fun `captureCheckIn gets forwarded to client`() { + Sentry.init { it.dsn = dsn } + + val client = mock() + Sentry.getCurrentHub().bindClient(client) + + val checkIn = CheckIn("some_slug", CheckInStatus.OK) + Sentry.captureCheckIn(checkIn) + + verify(client).captureCheckIn( + argThat { + checkInId == checkIn.checkInId + }, + anyOrNull(), + anyOrNull() + ) + } + @Test fun `if send modules is false, uses NoOpModulesLoader`() { var sentryOptions: SentryOptions? = null diff --git a/sentry/src/test/resources/json/checkin_crontab.json b/sentry/src/test/resources/json/checkin_crontab.json new file mode 100644 index 0000000000..ee3bdf2ca9 --- /dev/null +++ b/sentry/src/test/resources/json/checkin_crontab.json @@ -0,0 +1,29 @@ +{ + "check_in_id": "6d55218195564d6d88cf3883b84666f1", + "monitor_slug": "some_slug", + "status": "error", + "duration": 12.3, + "release": "1.0.1", + "environment": "env", + "monitor_config": + { + "schedule": + { + "type": "crontab", + "value": "0_*_*_*_*" + }, + "checkin_margin": 8, + "max_runtime": 9, + "timezone": "Europe/Vienna" + }, + "contexts": + { + "trace": + { + "trace_id": "f382e3180c714217a81371f8c644aefe", + "span_id": "85694b9f567145a6", + "op": "default", + "origin": "manual" + } + } +} diff --git a/sentry/src/test/resources/json/checkin_interval.json b/sentry/src/test/resources/json/checkin_interval.json new file mode 100644 index 0000000000..b404d610c6 --- /dev/null +++ b/sentry/src/test/resources/json/checkin_interval.json @@ -0,0 +1,30 @@ +{ + "check_in_id": "6d55218195564d6d88cf3883b84666f1", + "monitor_slug": "some_slug", + "status": "error", + "duration": 12.3, + "release": "1.0.1", + "environment": "env", + "monitor_config": + { + "schedule": + { + "type": "interval", + "value": 42, + "unit": "minute" + }, + "checkin_margin": 8, + "max_runtime": 9, + "timezone": "Europe/Vienna" + }, + "contexts": + { + "trace": + { + "trace_id": "f382e3180c714217a81371f8c644aefe", + "span_id": "85694b9f567145a6", + "op": "default", + "origin": "manual" + } + } +}