diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 04bf74fcfe..155c40ae7c 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -546,6 +546,36 @@ public static SentryEnvelopeItem fromLogs( return new SentryEnvelopeItem(itemHeader, () -> cachedItem.getBytes()); } + public static SentryEnvelopeItem fromMetrics( + final @NotNull ISerializer serializer, final @NotNull SentryMetricsEvents metricsEvents) { + Objects.requireNonNull(serializer, "ISerializer is required."); + Objects.requireNonNull(metricsEvents, "SentryMetricsEvents 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(metricsEvents, writer); + return stream.toByteArray(); + } + }); + + SentryEnvelopeItemHeader itemHeader = + new SentryEnvelopeItemHeader( + SentryItemType.TraceMetric, + () -> cachedItem.getBytes().length, + "application/vnd.sentry.items.trace-metric+json", + null, + null, + null, + metricsEvents.getItems().size()); + + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef + 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/SentryMetricsEvent.java b/sentry/src/main/java/io/sentry/SentryMetricsEvent.java new file mode 100644 index 0000000000..1000d58653 --- /dev/null +++ b/sentry/src/main/java/io/sentry/SentryMetricsEvent.java @@ -0,0 +1,290 @@ +package io.sentry; + +import static io.sentry.DateUtils.doubleToBigDecimal; + +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.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentryMetricsEvent implements JsonUnknown, JsonSerializable { + + private @NotNull SentryId traceId; + private @Nullable SpanId spanId; + + /** Timestamp in seconds (epoch time) indicating when the metric was recorded. */ + private @NotNull Double timestamp; + + /** + * The name of the metric. This should follow a hierarchical naming convention using dots as + * separators (e.g., api.response_time, db.query.duration). + */ + private @NotNull String name; + + /** The unit of measurement for the metric value. */ + private @Nullable String unit; + + /** + * The type of metric. One of: - counter: A metric that increments counts - gauge: A metric that + * tracks a value that can go up or down - distribution: A metric that tracks the statistical + * distribution of values + */ + private @NotNull String type; + + /** + * The numeric value of the metric. The interpretation depends on the metric type: - For counter + * metrics: the count to increment by (should default to 1) - For gauge metrics: the current value + * - For distribution metrics: a single measured value + */ + private @NotNull Double value; + + private @Nullable Map attributes; + private @Nullable Map unknown; + + public SentryMetricsEvent( + final @NotNull SentryId traceId, + final @NotNull SentryDate timestamp, + final @NotNull String name, + final @NotNull String type, + final @NotNull Double value) { + this(traceId, DateUtils.nanosToSeconds(timestamp.nanoTimestamp()), name, type, value); + } + + public SentryMetricsEvent( + final @NotNull SentryId traceId, + final @NotNull Double timestamp, + final @NotNull String name, + final @NotNull String type, + final @NotNull Double value) { + this.traceId = traceId; + this.timestamp = timestamp; + this.name = name; + this.type = type; + this.value = value; + } + + @NotNull + public Double getTimestamp() { + return timestamp; + } + + public void setTimestamp(final @NotNull Double timestamp) { + this.timestamp = timestamp; + } + + public @NotNull String getName() { + return name; + } + + public void setName(@NotNull String name) { + this.name = name; + } + + public @NotNull String getType() { + return type; + } + + public void setType(@NotNull String type) { + this.type = type; + } + + public @Nullable String getUnit() { + return unit; + } + + public void setUnit(@Nullable String unit) { + this.unit = unit; + } + + public @Nullable SpanId getSpanId() { + return spanId; + } + + public void setSpanId(@Nullable SpanId spanId) { + this.spanId = spanId; + } + + public @NotNull Double getValue() { + return value; + } + + public void setValue(@NotNull Double value) { + this.value = value; + } + + public @Nullable Map getAttributes() { + return attributes; + } + + public void setAttributes(final @Nullable Map attributes) { + this.attributes = attributes; + } + + public void setAttribute( + final @Nullable String key, final @Nullable SentryLogEventAttributeValue value) { + if (key == null) { + return; + } + if (this.attributes == null) { + this.attributes = new HashMap<>(); + } + this.attributes.put(key, value); + } + + // region json + public static final class JsonKeys { + public static final String TIMESTAMP = "timestamp"; + public static final String TRACE_ID = "trace_id"; + public static final String SPAN_ID = "span_id"; + public static final String NAME = "name"; + public static final String TYPE = "type"; + public static final String UNIT = "unit"; + public static final String VALUE = "value"; + public static final String ATTRIBUTES = "attributes"; + } + + @Override + @SuppressWarnings("JdkObsolete") + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.TIMESTAMP).value(logger, doubleToBigDecimal(timestamp)); + writer.name(JsonKeys.TYPE).value(type); + writer.name(JsonKeys.NAME).value(name); + writer.name(JsonKeys.VALUE).value(value); + writer.name(JsonKeys.TRACE_ID).value(logger, traceId); + if (spanId != null) { + writer.name(JsonKeys.SPAN_ID).value(logger, spanId); + } + if (unit != null) { + writer.name(JsonKeys.UNIT).value(logger, unit); + } + if (attributes != null) { + writer.name(JsonKeys.ATTRIBUTES).value(logger, attributes); + } + + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @SuppressWarnings("unchecked") + @Override + public @NotNull SentryMetricsEvent deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + @Nullable Map unknown = null; + @Nullable SentryId traceId = null; + @Nullable SpanId spanId = null; + @Nullable Double timestamp = null; + @Nullable String type = null; + @Nullable String name = null; + @Nullable String unit = null; + @Nullable Double value = null; + @Nullable Map attributes = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TRACE_ID: + traceId = reader.nextOrNull(logger, new SentryId.Deserializer()); + break; + case JsonKeys.SPAN_ID: + spanId = reader.nextOrNull(logger, new SpanId.Deserializer()); + break; + case JsonKeys.TIMESTAMP: + timestamp = reader.nextDoubleOrNull(); + break; + case JsonKeys.TYPE: + type = reader.nextStringOrNull(); + break; + case JsonKeys.NAME: + name = reader.nextStringOrNull(); + break; + case JsonKeys.UNIT: + unit = reader.nextStringOrNull(); + break; + case JsonKeys.VALUE: + value = reader.nextDoubleOrNull(); + break; + case JsonKeys.ATTRIBUTES: + attributes = + reader.nextMapOrNull(logger, new SentryLogEventAttributeValue.Deserializer()); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + reader.endObject(); + + if (traceId == null) { + String message = "Missing required field \"" + JsonKeys.TRACE_ID + "\""; + Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + if (timestamp == null) { + String message = "Missing required field \"" + JsonKeys.TIMESTAMP + "\""; + Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + if (type == null) { + String message = "Missing required field \"" + JsonKeys.TYPE + "\""; + Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + if (name == null) { + String message = "Missing required field \"" + JsonKeys.NAME + "\""; + 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; + } + + final SentryMetricsEvent logEvent = + new SentryMetricsEvent(traceId, timestamp, name, type, value); + + logEvent.setAttributes(attributes); + logEvent.setSpanId(spanId); + logEvent.setUnit(unit); + logEvent.setUnknown(unknown); + + return logEvent; + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/SentryMetricsEvents.java b/sentry/src/main/java/io/sentry/SentryMetricsEvents.java new file mode 100644 index 0000000000..9227dd47bf --- /dev/null +++ b/sentry/src/main/java/io/sentry/SentryMetricsEvents.java @@ -0,0 +1,98 @@ +package io.sentry; + +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentryMetricsEvents implements JsonUnknown, JsonSerializable { + + private @NotNull List items; + private @Nullable Map unknown; + + public SentryMetricsEvents(final @NotNull List items) { + this.items = items; + } + + public @NotNull List getItems() { + return items; + } + + // region json + public static final class JsonKeys { + public static final String ITEMS = "items"; + } + + @Override + @SuppressWarnings("JdkObsolete") + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + + writer.name(JsonKeys.ITEMS).value(logger, items); + + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + + writer.endObject(); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @SuppressWarnings("unchecked") + @Override + public @NotNull SentryMetricsEvents deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + @Nullable Map unknown = null; + @Nullable List items = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.ITEMS: + items = reader.nextListOrNull(logger, new SentryMetricsEvent.Deserializer()); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + reader.endObject(); + + if (items == null) { + String message = "Missing required field \"" + JsonKeys.ITEMS + "\""; + Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + final SentryMetricsEvents metricsEvent = new SentryMetricsEvents(items); + + metricsEvent.setUnknown(unknown); + + return metricsEvent; + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 6fa246c15c..e832a30726 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3794,9 +3794,8 @@ public interface BeforeSendMetricCallback { * @param metric the metric * @return the original metric, mutated metric or null if metric was dropped */ - // TODO replace with SentryMetric @Nullable - Object execute(@NotNull Object metric); + SentryMetricsEvents execute(@NotNull SentryMetricsEvents metric); } }