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 bdb328e242..b4235aa6b9 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 @@ -18,6 +18,7 @@ import io.sentry.SentryEnvelope import io.sentry.SentryEvent import io.sentry.SentryLogEvent import io.sentry.SentryLogEvents +import io.sentry.SentryMetricsEvents import io.sentry.SentryReplayEvent import io.sentry.Session import io.sentry.TraceContext @@ -192,6 +193,10 @@ class SessionTrackingIntegrationTest { TODO("Not yet implemented") } + override fun captureBatchedMetricsEvents(metricsEvents: SentryMetricsEvents) { + TODO("Not yet implemented") + } + override fun getRateLimiter(): RateLimiter? { TODO("Not yet implemented") } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 62421cd278..9a8f3d6867 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1029,6 +1029,7 @@ public abstract interface class io/sentry/IScopesStorage { public abstract interface class io/sentry/ISentryClient { public abstract fun captureBatchedLogEvents (Lio/sentry/SentryLogEvents;)V + public abstract fun captureBatchedMetricsEvents (Lio/sentry/SentryMetricsEvents;)V public abstract fun captureCheckIn (Lio/sentry/CheckIn;Lio/sentry/IScope;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; @@ -2852,6 +2853,7 @@ public final class io/sentry/SentryBaseEvent$Serializer { public final class io/sentry/SentryClient : io/sentry/ISentryClient { public fun (Lio/sentry/SentryOptions;)V public fun captureBatchedLogEvents (Lio/sentry/SentryLogEvents;)V + public fun captureBatchedMetricsEvents (Lio/sentry/SentryMetricsEvents;)V public fun captureCheckIn (Lio/sentry/CheckIn;Lio/sentry/IScope;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/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -2938,6 +2940,7 @@ public final class io/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 fromLogs (Lio/sentry/ISerializer;Lio/sentry/SentryLogEvents;)Lio/sentry/SentryEnvelopeItem; + public static fun fromMetrics (Lio/sentry/ISerializer;Lio/sentry/SentryMetricsEvents;)Lio/sentry/SentryEnvelopeItem; public static fun fromProfileChunk (Lio/sentry/ProfileChunk;Lio/sentry/ISerializer;)Lio/sentry/SentryEnvelopeItem; public static fun fromProfileChunk (Lio/sentry/ProfileChunk;Lio/sentry/ISerializer;Lio/sentry/IProfileConverter;)Lio/sentry/SentryEnvelopeItem; public static fun fromProfilingTrace (Lio/sentry/ProfilingTraceData;JLio/sentry/ISerializer;)Lio/sentry/SentryEnvelopeItem; @@ -3322,6 +3325,66 @@ public final class io/sentry/SentryLongDate : io/sentry/SentryDate { public fun nanoTimestamp ()J } +public final class io/sentry/SentryMetricsEvent : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun (Lio/sentry/protocol/SentryId;Lio/sentry/SentryDate;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Double;)V + public fun (Lio/sentry/protocol/SentryId;Ljava/lang/Double;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Double;)V + public fun getAttributes ()Ljava/util/Map; + public fun getName ()Ljava/lang/String; + public fun getSpanId ()Lio/sentry/SpanId; + public fun getTimestamp ()Ljava/lang/Double; + public fun getType ()Ljava/lang/String; + public fun getUnit ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun getValue ()Ljava/lang/Double; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setAttribute (Ljava/lang/String;Lio/sentry/SentryLogEventAttributeValue;)V + public fun setAttributes (Ljava/util/Map;)V + public fun setName (Ljava/lang/String;)V + public fun setSpanId (Lio/sentry/SpanId;)V + public fun setTimestamp (Ljava/lang/Double;)V + public fun setType (Ljava/lang/String;)V + public fun setUnit (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V + public fun setValue (Ljava/lang/Double;)V +} + +public final class io/sentry/SentryMetricsEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryMetricsEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/SentryMetricsEvent$JsonKeys { + public static final field ATTRIBUTES Ljava/lang/String; + public static final field NAME Ljava/lang/String; + public static final field SPAN_ID Ljava/lang/String; + public static final field TIMESTAMP Ljava/lang/String; + public static final field TRACE_ID Ljava/lang/String; + 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/SentryMetricsEvents : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun (Ljava/util/List;)V + public fun getItems ()Ljava/util/List; + public fun getUnknown ()Ljava/util/Map; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/SentryMetricsEvents$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryMetricsEvents; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/SentryMetricsEvents$JsonKeys { + public static final field ITEMS Ljava/lang/String; + public fun ()V +} + public final class io/sentry/SentryNanotimeDate : io/sentry/SentryDate { public fun ()V public fun (Ljava/util/Date;J)V @@ -3698,7 +3761,7 @@ public final class io/sentry/SentryOptions$Metrics { } public abstract interface class io/sentry/SentryOptions$Metrics$BeforeSendMetricCallback { - public abstract fun execute (Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun execute (Lio/sentry/SentryMetricsEvents;)Lio/sentry/SentryMetricsEvents; } public abstract interface class io/sentry/SentryOptions$OnDiscardCallback { @@ -5127,20 +5190,53 @@ public final class io/sentry/logger/SentryLogParameters { public fun setTimestamp (Lio/sentry/SentryDate;)V } +public final class io/sentry/metrics/DefaultMetricsBatchProcessorFactory : io/sentry/metrics/IMetricsBatchProcessorFactory { + public fun ()V + public fun create (Lio/sentry/SentryOptions;Lio/sentry/SentryClient;)Lio/sentry/metrics/IMetricsBatchProcessor; +} + public abstract interface class io/sentry/metrics/IMetricsApi { public abstract fun count (Ljava/lang/String;)V } +public abstract interface class io/sentry/metrics/IMetricsBatchProcessor { + public abstract fun add (Lio/sentry/SentryMetricsEvent;)V + public abstract fun close (Z)V + public abstract fun flush (J)V +} + +public abstract interface class io/sentry/metrics/IMetricsBatchProcessorFactory { + public abstract fun create (Lio/sentry/SentryOptions;Lio/sentry/SentryClient;)Lio/sentry/metrics/IMetricsBatchProcessor; +} + public final class io/sentry/metrics/MetricsApi : io/sentry/metrics/IMetricsApi { public fun (Lio/sentry/Scopes;)V public fun count (Ljava/lang/String;)V } +public class io/sentry/metrics/MetricsBatchProcessor : io/sentry/metrics/IMetricsBatchProcessor { + public static final field FLUSH_AFTER_MS I + public static final field MAX_BATCH_SIZE I + public static final field MAX_QUEUE_SIZE I + protected final field options Lio/sentry/SentryOptions; + public fun (Lio/sentry/SentryOptions;Lio/sentry/ISentryClient;)V + public fun add (Lio/sentry/SentryMetricsEvent;)V + public fun close (Z)V + public fun flush (J)V +} + public final class io/sentry/metrics/NoOpMetricsApi : io/sentry/metrics/IMetricsApi { public fun count (Ljava/lang/String;)V public static fun getInstance ()Lio/sentry/metrics/NoOpMetricsApi; } +public final class io/sentry/metrics/NoOpMetricsBatchProcessor : io/sentry/metrics/IMetricsBatchProcessor { + public fun add (Lio/sentry/SentryMetricsEvent;)V + public fun close (Z)V + public fun flush (J)V + public static fun getInstance ()Lio/sentry/metrics/NoOpMetricsBatchProcessor; +} + public final class io/sentry/opentelemetry/OpenTelemetryUtil { public fun ()V public static fun applyIgnoredSpanOrigins (Lio/sentry/SentryOptions;)V diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index c2bc05516f..cd9ce85e9a 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -308,6 +308,9 @@ SentryId captureProfileChunk( @ApiStatus.Internal void captureBatchedLogEvents(@NotNull SentryLogEvents logEvents); + @ApiStatus.Internal + void captureBatchedMetricsEvents(@NotNull SentryMetricsEvents metricsEvents); + @ApiStatus.Internal @Nullable RateLimiter getRateLimiter(); diff --git a/sentry/src/main/java/io/sentry/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java index 17e4becbc7..cd2ed885ca 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java @@ -94,6 +94,12 @@ public void captureBatchedLogEvents(@NotNull SentryLogEvents logEvents) { // do nothing } + @ApiStatus.Internal + @Override + public void captureBatchedMetricsEvents(@NotNull SentryMetricsEvents metricsEvents) { + // do nothing + } + @Override public @Nullable RateLimiter getRateLimiter() { return null; diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 0a767ed1b8..ed6c5cbe03 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -693,6 +693,19 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { return new SentryEnvelope(envelopeHeader, envelopeItems); } + private @NotNull SentryEnvelope buildEnvelope(final @NotNull SentryMetricsEvents metricsEvents) { + final List envelopeItems = new ArrayList<>(); + + final SentryEnvelopeItem metricsItem = + SentryEnvelopeItem.fromMetrics(options.getSerializer(), metricsEvents); + envelopeItems.add(metricsItem); + + final SentryEnvelopeHeader envelopeHeader = + new SentryEnvelopeHeader(null, options.getSdkVersion(), null); + + return new SentryEnvelope(envelopeHeader, envelopeItems); + } + private @NotNull SentryEnvelope buildEnvelope( final @NotNull SentryReplayEvent event, final @Nullable ReplayRecording replayRecording, @@ -1218,7 +1231,18 @@ public void captureBatchedLogEvents(final @NotNull SentryLogEvents logEvents) { final @NotNull SentryEnvelope envelope = buildEnvelope(logEvents); sendEnvelope(envelope, null); } catch (IOException e) { - options.getLogger().log(SentryLevel.WARNING, e, "Capturing log failed."); + options.getLogger().log(SentryLevel.WARNING, e, "Capturing logs failed."); + } + } + + @ApiStatus.Internal + @Override + public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metricsEvents) { + try { + final @NotNull SentryEnvelope envelope = buildEnvelope(metricsEvents); + sendEnvelope(envelope, null); + } catch (IOException e) { + options.getLogger().log(SentryLevel.WARNING, e, "Capturing metrics failed."); } } diff --git a/sentry/src/main/java/io/sentry/metrics/DefaultMetricsBatchProcessorFactory.java b/sentry/src/main/java/io/sentry/metrics/DefaultMetricsBatchProcessorFactory.java new file mode 100644 index 0000000000..dee7e5b511 --- /dev/null +++ b/sentry/src/main/java/io/sentry/metrics/DefaultMetricsBatchProcessorFactory.java @@ -0,0 +1,13 @@ +package io.sentry.metrics; + +import io.sentry.SentryClient; +import io.sentry.SentryOptions; +import org.jetbrains.annotations.NotNull; + +public final class DefaultMetricsBatchProcessorFactory implements IMetricsBatchProcessorFactory { + @Override + public @NotNull IMetricsBatchProcessor create( + @NotNull SentryOptions options, @NotNull SentryClient client) { + return new MetricsBatchProcessor(options, client); + } +} diff --git a/sentry/src/main/java/io/sentry/metrics/IMetricsBatchProcessor.java b/sentry/src/main/java/io/sentry/metrics/IMetricsBatchProcessor.java new file mode 100644 index 0000000000..f019c2a89b --- /dev/null +++ b/sentry/src/main/java/io/sentry/metrics/IMetricsBatchProcessor.java @@ -0,0 +1,17 @@ +package io.sentry.metrics; + +import io.sentry.SentryMetricsEvent; +import org.jetbrains.annotations.NotNull; + +public interface IMetricsBatchProcessor { + void add(@NotNull SentryMetricsEvent event); + + void close(boolean isRestarting); + + /** + * Flushes log events. + * + * @param timeoutMillis time in milliseconds + */ + void flush(long timeoutMillis); +} diff --git a/sentry/src/main/java/io/sentry/metrics/IMetricsBatchProcessorFactory.java b/sentry/src/main/java/io/sentry/metrics/IMetricsBatchProcessorFactory.java new file mode 100644 index 0000000000..a909512864 --- /dev/null +++ b/sentry/src/main/java/io/sentry/metrics/IMetricsBatchProcessorFactory.java @@ -0,0 +1,12 @@ +package io.sentry.metrics; + +import io.sentry.SentryClient; +import io.sentry.SentryOptions; +import org.jetbrains.annotations.NotNull; + +public interface IMetricsBatchProcessorFactory { + + @NotNull + IMetricsBatchProcessor create( + final @NotNull SentryOptions options, final @NotNull SentryClient client); +} diff --git a/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java b/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java new file mode 100644 index 0000000000..10e58000e7 --- /dev/null +++ b/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java @@ -0,0 +1,155 @@ +package io.sentry.metrics; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.DataCategory; +import io.sentry.ISentryClient; +import io.sentry.ISentryExecutorService; +import io.sentry.ISentryLifecycleToken; +import io.sentry.SentryExecutorService; +import io.sentry.SentryLevel; +import io.sentry.SentryMetricsEvent; +import io.sentry.SentryMetricsEvents; +import io.sentry.SentryOptions; +import io.sentry.clientreport.DiscardReason; +import io.sentry.transport.ReusableCountLatch; +import io.sentry.util.AutoClosableReentrantLock; +import java.util.ArrayList; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Open +public class MetricsBatchProcessor implements IMetricsBatchProcessor { + + public static final int FLUSH_AFTER_MS = 5000; + public static final int MAX_BATCH_SIZE = 1000; + public static final int MAX_QUEUE_SIZE = 10000; + + protected final @NotNull SentryOptions options; + private final @NotNull ISentryClient client; + private final @NotNull Queue queue; + private final @NotNull ISentryExecutorService executorService; + private volatile @Nullable Future scheduledFlush; + private static final @NotNull AutoClosableReentrantLock scheduleLock = + new AutoClosableReentrantLock(); + private volatile boolean hasScheduled = false; + + private final @NotNull ReusableCountLatch pendingCount = new ReusableCountLatch(); + + public MetricsBatchProcessor( + final @NotNull SentryOptions options, final @NotNull ISentryClient client) { + this.options = options; + this.client = client; + this.queue = new ConcurrentLinkedQueue<>(); + this.executorService = new SentryExecutorService(options); + } + + @Override + public void add(final @NotNull SentryMetricsEvent metricsEvent) { + if (pendingCount.getCount() >= MAX_QUEUE_SIZE) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.QUEUE_OVERFLOW, DataCategory.TraceMetric); + return; + } + pendingCount.increment(); + queue.offer(metricsEvent); + maybeSchedule(false, false); + } + + @SuppressWarnings("FutureReturnValueIgnored") + @Override + public void close(final boolean isRestarting) { + if (isRestarting) { + maybeSchedule(true, true); + executorService.submit(() -> executorService.close(options.getShutdownTimeoutMillis())); + } else { + executorService.close(options.getShutdownTimeoutMillis()); + while (!queue.isEmpty()) { + flushBatch(); + } + } + } + + private void maybeSchedule(boolean forceSchedule, boolean immediately) { + if (hasScheduled && !forceSchedule) { + return; + } + try (final @NotNull ISentryLifecycleToken ignored = scheduleLock.acquire()) { + final @Nullable Future latestScheduledFlush = scheduledFlush; + if (forceSchedule + || latestScheduledFlush == null + || latestScheduledFlush.isDone() + || latestScheduledFlush.isCancelled()) { + hasScheduled = true; + final int flushAfterMs = immediately ? 0 : FLUSH_AFTER_MS; + try { + scheduledFlush = executorService.schedule(new BatchRunnable(), flushAfterMs); + } catch (RejectedExecutionException e) { + hasScheduled = false; + options + .getLogger() + .log(SentryLevel.WARNING, "Metrics batch processor flush task rejected", e); + } + } + } + } + + @Override + public void flush(long timeoutMillis) { + maybeSchedule(true, true); + try { + pendingCount.waitTillZero(timeoutMillis, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to flush metrics events", e); + Thread.currentThread().interrupt(); + } + } + + private void flush() { + flushInternal(); + try (final @NotNull ISentryLifecycleToken ignored = scheduleLock.acquire()) { + if (!queue.isEmpty()) { + maybeSchedule(true, false); + } else { + hasScheduled = false; + } + } + } + + private void flushInternal() { + do { + flushBatch(); + } while (queue.size() >= MAX_BATCH_SIZE); + } + + private void flushBatch() { + final @NotNull List metricsEvents = new ArrayList<>(MAX_BATCH_SIZE); + do { + final @Nullable SentryMetricsEvent metricsEvent = queue.poll(); + if (metricsEvent != null) { + metricsEvents.add(metricsEvent); + } + } while (!queue.isEmpty() && metricsEvents.size() < MAX_BATCH_SIZE); + + if (!metricsEvents.isEmpty()) { + client.captureBatchedMetricsEvents(new SentryMetricsEvents(metricsEvents)); + for (int i = 0; i < metricsEvents.size(); i++) { + pendingCount.decrement(); + } + } + } + + private class BatchRunnable implements Runnable { + + @Override + public void run() { + flush(); + } + } +} diff --git a/sentry/src/main/java/io/sentry/metrics/NoOpMetricsBatchProcessor.java b/sentry/src/main/java/io/sentry/metrics/NoOpMetricsBatchProcessor.java new file mode 100644 index 0000000000..3d963fb061 --- /dev/null +++ b/sentry/src/main/java/io/sentry/metrics/NoOpMetricsBatchProcessor.java @@ -0,0 +1,30 @@ +package io.sentry.metrics; + +import io.sentry.SentryMetricsEvent; +import org.jetbrains.annotations.NotNull; + +public final class NoOpMetricsBatchProcessor implements IMetricsBatchProcessor { + + private static final NoOpMetricsBatchProcessor instance = new NoOpMetricsBatchProcessor(); + + private NoOpMetricsBatchProcessor() {} + + public static NoOpMetricsBatchProcessor getInstance() { + return instance; + } + + @Override + public void add(@NotNull SentryMetricsEvent event) { + // do nothing + } + + @Override + public void close(final boolean isRestarting) { + // do nothing + } + + @Override + public void flush(long timeoutMillis) { + // do nothing + } +}