From a79f91b34961ef75c26dca1e00f9d747c7412bab Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 5 Jan 2026 13:43:30 +0100 Subject: [PATCH] Add unit tests for Metrics --- sentry/src/test/java/io/sentry/ScopesTest.kt | 852 ++++++++++++++++++ .../test/java/io/sentry/SentryClientTest.kt | 69 ++ .../metrics/MetricsBatchProcessorTest.kt | 90 ++ .../SentryMetricsSerializationTest.kt | 96 ++ .../test/resources/json/sentry_metrics.json | 59 ++ 5 files changed, 1166 insertions(+) create mode 100644 sentry/src/test/java/io/sentry/metrics/MetricsBatchProcessorTest.kt create mode 100644 sentry/src/test/java/io/sentry/protocol/SentryMetricsSerializationTest.kt create mode 100644 sentry/src/test/resources/json/sentry_metrics.json diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index 4eee9d16367..dde633d878d 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -3125,6 +3125,858 @@ class ScopesTest { // endregion + // region metrics + + @Test + fun `when captureMetric is called on disabled client, do nothing`() { + val (sut, mockClient) = getEnabledScopes() + sut.close() + + sut.metrics().count("metric name") + verify(mockClient, never()).captureMetric(any(), anyOrNull()) + } + + @Test + fun `when metrics is not enabled, do nothing`() { + val (sut, mockClient) = getEnabledScopes { it.metrics.isEnabled = false } + + sut.metrics().count("metric name") + verify(mockClient, never()).captureMetric(any(), anyOrNull()) + } + + @Test + fun `creating count metric works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().count("count metric") + + verify(mockClient) + .captureMetric( + check { + assertEquals("count metric", it.name) + assertEquals(1.0, it.value) + assertEquals("counter", it.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating gauge metric works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().gauge("gauge metric", 2.3) + + verify(mockClient) + .captureMetric( + check { + assertEquals("gauge metric", it.name) + assertEquals(2.3, it.value) + assertEquals("gauge", it.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating distribution metric works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().distribution("distribution metric", 3.4) + + verify(mockClient) + .captureMetric( + check { + assertEquals("distribution metric", it.name) + assertEquals(3.4, it.value) + assertEquals("distribution", it.type) + }, + anyOrNull(), + ) + } + + @Test + fun `metric with manual origin does not have origin attribute`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().count("metric name") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertNull(it.attributes!!.get("sentry.origin")) + }, + anyOrNull(), + ) + } + + @Test + fun `metric with non manual origin does have origin attribute`() { + val (sut, mockClient) = getEnabledScopes { it.logs.isEnabled = true } + + sut + .metrics() + .count("metric name", 1.0, "visit", SentryLogParameters().also { it.origin = "other" }) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals( + "other", + (it.attributes!!.get("sentry.origin") as? SentryLogEventAttributeValue)?.value, + ) + }, + anyOrNull(), + ) + } + + @Test + fun `creating count metric with value and unit works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().count("metric name", 1.0, "visit") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals("visit", it.unit) + assertEquals("counter", it.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating count metric with value works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().count("metric name", 1.0) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals("counter", it.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating count metric with unit works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().count("metric name", "visit") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals("visit", it.unit) + assertEquals("counter", it.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating count metric with attributes from map works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .count( + "metric name", + 1.0, + "visit", + SentryLogParameters.create(SentryAttributes.fromMap(mapOf("attrname1" to "attrval1"))), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals("visit", it.unit) + assertEquals("counter", it.type) + + val attr1 = it.attributes?.get("attrname1")!! + assertEquals("attrval1", attr1.value) + assertEquals("string", attr1.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating count metric with attributes works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .count( + "metric name", + 1.0, + "visit", + SentryLogParameters.create( + SentryAttributes.of( + SentryAttribute.stringAttribute("strattr", "strval"), + SentryAttribute.booleanAttribute("boolattr", true), + SentryAttribute.integerAttribute("intattr", 17), + SentryAttribute.doubleAttribute("doubleattr", 3.8), + SentryAttribute.named("namedstrattr", "namedstrval"), + SentryAttribute.named("namedboolattr", false), + SentryAttribute.named("namedintattr", 18), + SentryAttribute.named("nameddoubleattr", 4.9), + ) + ), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals("visit", it.unit) + assertEquals("counter", it.type) + + val strattr = it.attributes?.get("strattr")!! + assertEquals("strval", strattr.value) + assertEquals("string", strattr.type) + + val boolattr = it.attributes?.get("boolattr")!! + assertEquals(true, boolattr.value) + assertEquals("boolean", boolattr.type) + + val intattr = it.attributes?.get("intattr")!! + assertEquals(17, intattr.value) + assertEquals("integer", intattr.type) + + val doubleattr = it.attributes?.get("doubleattr")!! + assertEquals(3.8, doubleattr.value) + assertEquals("double", doubleattr.type) + + val namedstrattr = it.attributes?.get("namedstrattr")!! + assertEquals("namedstrval", namedstrattr.value) + assertEquals("string", namedstrattr.type) + + val namedboolattr = it.attributes?.get("namedboolattr")!! + assertEquals(false, namedboolattr.value) + assertEquals("boolean", namedboolattr.type) + + val namedintattr = it.attributes?.get("namedintattr")!! + assertEquals(18, namedintattr.value) + assertEquals("integer", namedintattr.type) + + val nameddoubleattr = it.attributes?.get("nameddoubleattr")!! + assertEquals(4.9, nameddoubleattr.value) + assertEquals("double", nameddoubleattr.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating count metric with attributes and timestamp works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .count( + "metric name", + 1.0, + "visit", + SentryLogParameters.create( + SentryLongDate(123), + SentryAttributes.of(SentryAttribute.named("attrname1", "attrval1")), + ), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals("visit", it.unit) + assertEquals("counter", it.type) + + val attr1 = it.attributes?.get("attrname1")!! + assertEquals("attrval1", attr1.value) + assertEquals("string", attr1.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating distribution metric with value and unit works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().distribution("metric name", 1.0, "ms") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals("ms", it.unit) + assertEquals("distribution", it.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating distribution metric with value works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().distribution("metric name", 1.0) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals("distribution", it.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating distribution metric with attributes from map works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .distribution( + "metric name", + 3.7, + "ms", + SentryLogParameters.create(SentryAttributes.fromMap(mapOf("attrname1" to "attrval1"))), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(3.7, it.value) + assertEquals("ms", it.unit) + assertEquals("distribution", it.type) + + val attr1 = it.attributes?.get("attrname1")!! + assertEquals("attrval1", attr1.value) + assertEquals("string", attr1.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating distribution metric with attributes works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .distribution( + "metric name", + 3.7, + "ms", + SentryLogParameters.create( + SentryAttributes.of( + SentryAttribute.stringAttribute("strattr", "strval"), + SentryAttribute.booleanAttribute("boolattr", true), + SentryAttribute.integerAttribute("intattr", 17), + SentryAttribute.doubleAttribute("doubleattr", 3.8), + SentryAttribute.named("namedstrattr", "namedstrval"), + SentryAttribute.named("namedboolattr", false), + SentryAttribute.named("namedintattr", 18), + SentryAttribute.named("nameddoubleattr", 4.9), + ) + ), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(3.7, it.value) + assertEquals("ms", it.unit) + assertEquals("distribution", it.type) + + val strattr = it.attributes?.get("strattr")!! + assertEquals("strval", strattr.value) + assertEquals("string", strattr.type) + + val boolattr = it.attributes?.get("boolattr")!! + assertEquals(true, boolattr.value) + assertEquals("boolean", boolattr.type) + + val intattr = it.attributes?.get("intattr")!! + assertEquals(17, intattr.value) + assertEquals("integer", intattr.type) + + val doubleattr = it.attributes?.get("doubleattr")!! + assertEquals(3.8, doubleattr.value) + assertEquals("double", doubleattr.type) + + val namedstrattr = it.attributes?.get("namedstrattr")!! + assertEquals("namedstrval", namedstrattr.value) + assertEquals("string", namedstrattr.type) + + val namedboolattr = it.attributes?.get("namedboolattr")!! + assertEquals(false, namedboolattr.value) + assertEquals("boolean", namedboolattr.type) + + val namedintattr = it.attributes?.get("namedintattr")!! + assertEquals(18, namedintattr.value) + assertEquals("integer", namedintattr.type) + + val nameddoubleattr = it.attributes?.get("nameddoubleattr")!! + assertEquals(4.9, nameddoubleattr.value) + assertEquals("double", nameddoubleattr.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating distribution metric with attributes and timestamp works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .distribution( + "metric name", + 3.7, + "ms", + SentryLogParameters.create( + SentryLongDate(123), + SentryAttributes.of(SentryAttribute.named("attrname1", "attrval1")), + ), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(3.7, it.value) + assertEquals("ms", it.unit) + assertEquals("distribution", it.type) + + val attr1 = it.attributes?.get("attrname1")!! + assertEquals("attrval1", attr1.value) + assertEquals("string", attr1.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating gauge metric with value and unit works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().gauge("metric name", 128.0, "byte") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(128.0, it.value) + assertEquals("byte", it.unit) + assertEquals("gauge", it.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating gauge metric with value works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().gauge("metric name", 128.0) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(128.0, it.value) + assertEquals("gauge", it.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating gauge metric with attributes from map works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .gauge( + "metric name", + 256.0, + "byte", + SentryLogParameters.create(SentryAttributes.fromMap(mapOf("attrname1" to "attrval1"))), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(256.0, it.value) + assertEquals("byte", it.unit) + assertEquals("gauge", it.type) + + val attr1 = it.attributes?.get("attrname1")!! + assertEquals("attrval1", attr1.value) + assertEquals("string", attr1.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating gauge metric with attributes works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .gauge( + "metric name", + 256.0, + "byte", + SentryLogParameters.create( + SentryAttributes.of( + SentryAttribute.stringAttribute("strattr", "strval"), + SentryAttribute.booleanAttribute("boolattr", true), + SentryAttribute.integerAttribute("intattr", 17), + SentryAttribute.doubleAttribute("doubleattr", 3.8), + SentryAttribute.named("namedstrattr", "namedstrval"), + SentryAttribute.named("namedboolattr", false), + SentryAttribute.named("namedintattr", 18), + SentryAttribute.named("nameddoubleattr", 4.9), + ) + ), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(256.0, it.value) + assertEquals("byte", it.unit) + assertEquals("gauge", it.type) + + val strattr = it.attributes?.get("strattr")!! + assertEquals("strval", strattr.value) + assertEquals("string", strattr.type) + + val boolattr = it.attributes?.get("boolattr")!! + assertEquals(true, boolattr.value) + assertEquals("boolean", boolattr.type) + + val intattr = it.attributes?.get("intattr")!! + assertEquals(17, intattr.value) + assertEquals("integer", intattr.type) + + val doubleattr = it.attributes?.get("doubleattr")!! + assertEquals(3.8, doubleattr.value) + assertEquals("double", doubleattr.type) + + val namedstrattr = it.attributes?.get("namedstrattr")!! + assertEquals("namedstrval", namedstrattr.value) + assertEquals("string", namedstrattr.type) + + val namedboolattr = it.attributes?.get("namedboolattr")!! + assertEquals(false, namedboolattr.value) + assertEquals("boolean", namedboolattr.type) + + val namedintattr = it.attributes?.get("namedintattr")!! + assertEquals(18, namedintattr.value) + assertEquals("integer", namedintattr.type) + + val nameddoubleattr = it.attributes?.get("nameddoubleattr")!! + assertEquals(4.9, nameddoubleattr.value) + assertEquals("double", nameddoubleattr.type) + }, + anyOrNull(), + ) + } + + @Test + fun `creating gauge metric with attributes and timestamp works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .gauge( + "metric name", + 256.0, + "byte", + SentryLogParameters.create( + SentryLongDate(123), + SentryAttributes.of(SentryAttribute.named("attrname1", "attrval1")), + ), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(256.0, it.value) + assertEquals("byte", it.unit) + assertEquals("gauge", it.type) + + val attr1 = it.attributes?.get("attrname1")!! + assertEquals("attrval1", attr1.value) + assertEquals("string", attr1.type) + }, + anyOrNull(), + ) + } + + @Test + fun `adds user fields to metric attributes if sendDefaultPii is true`() { + val (sut, mockClient) = + getEnabledScopes { + it.distinctId = "distinctId" + it.isSendDefaultPii = true + } + + sut.configureScope { scope -> + scope.user = + User().also { + it.id = "usrid" + it.username = "usrname" + it.email = "user@sentry.io" + } + } + sut.metrics().count("metric name") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + + val userId = it.attributes?.get("user.id")!! + assertEquals("usrid", userId.value) + assertEquals("string", userId.type) + + val userName = it.attributes?.get("user.name")!! + assertEquals("usrname", userName.value) + assertEquals("string", userName.type) + + val userEmail = it.attributes?.get("user.email")!! + assertEquals("user@sentry.io", userEmail.value) + assertEquals("string", userEmail.type) + }, + anyOrNull(), + ) + } + + @Test + fun `does not add user fields to metric attributes by default`() { + val (sut, mockClient) = getEnabledScopes { it.distinctId = "distinctId" } + + sut.configureScope { scope -> + scope.user = + User().also { + it.id = "usrid" + it.username = "usrname" + it.email = "user@sentry.io" + } + } + sut.metrics().count("metric name") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + + assertNull(it.attributes?.get("user.id")) + assertNull(it.attributes?.get("user.name")) + assertNull(it.attributes?.get("user.email")) + }, + anyOrNull(), + ) + } + + @Test + fun `unset user does provide distinct-id as user-id for metrics`() { + val (sut, mockClient) = + getEnabledScopes { + it.isSendDefaultPii = true + it.distinctId = "distinctId" + } + + sut.metrics().count("metric name") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + + assertEquals("distinctId", it.attributes?.get("user.id")?.value) + assertNull(it.attributes?.get("user.name")) + assertNull(it.attributes?.get("user.email")) + }, + anyOrNull(), + ) + } + + @Test + fun `unset user does provide null user-id when distinct-id is missing for metrics`() { + val (sut, mockClient) = + getEnabledScopes { + it.isSendDefaultPii = true + it.distinctId = null + } + + sut.metrics().count("metric name") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertNull(it.attributes?.get("user.id")) + assertNull(it.attributes?.get("user.name")) + assertNull(it.attributes?.get("user.email")) + }, + anyOrNull(), + ) + } + + @Test + fun `missing user fields do not break attributes for metrics`() { + val (sut, mockClient) = + getEnabledScopes { + it.isSendDefaultPii = true + it.distinctId = "distinctId" + } + + sut.configureScope { scope -> scope.user = User() } + sut.metrics().count("metric name") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + + assertNull(it.attributes?.get("user.id")) + assertNull(it.attributes?.get("user.name")) + assertNull(it.attributes?.get("user.email")) + }, + anyOrNull(), + ) + } + + @Test + fun `adds session replay id to metric attributes`() { + val (sut, mockClient) = getEnabledScopes() + val replayId = SentryId() + sut.scope.replayId = replayId + sut.metrics().count("metric name") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + val logReplayId = it.attributes?.get("sentry.replay_id")!! + assertEquals(replayId.toString(), logReplayId.value) + }, + anyOrNull(), + ) + } + + @Test + fun `missing session replay id do not break metric attributes`() { + val (sut, mockClient) = getEnabledScopes() + sut.metrics().count("metric name") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + val logReplayId = it.attributes?.get("sentry.replay_id") + assertNull(logReplayId) + }, + anyOrNull(), + ) + } + + @Test + fun `does not add session replay buffering to metric attributes if no replay id in scope and in controller`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().count("metric name") + assertEquals(SentryId.EMPTY_ID, sut.options.replayController.replayId) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + val logReplayId = it.attributes?.get("sentry.replay_id") + val logReplayType = it.attributes?.get("sentry._internal.replay_is_buffering") + assertNull(logReplayId) + assertNull(logReplayType) + }, + anyOrNull(), + ) + } + + @Test + fun `does not add session replay buffering to metric attributes if replay id in scope`() { + val (sut, mockClient) = getEnabledScopes() + val replayId = SentryId() + sut.scope.replayId = replayId + + sut.metrics().count("metric name") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + val logReplayId = it.attributes?.get("sentry.replay_id") + val logReplayType = it.attributes?.get("sentry._internal.replay_is_buffering") + assertEquals(replayId.toString(), logReplayId!!.value) + assertNull(logReplayType) + }, + anyOrNull(), + ) + } + + @Test + fun `adds session replay buffering to metric attributes if replay id in controller and not in scope`() { + val mockReplayController = mock() + val (sut, mockClient) = getEnabledScopes { it.setReplayController(mockReplayController) } + val replayId = SentryId() + sut.scope.replayId = SentryId.EMPTY_ID + whenever(mockReplayController.replayId).thenReturn(replayId) + + sut.metrics().count("metric name") + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + val logReplayId = it.attributes?.get("sentry.replay_id") + val logReplayType = it.attributes?.get("sentry._internal.replay_is_buffering")!! + assertEquals(replayId.toString(), logReplayId!!.value) + assertTrue(logReplayType.value as Boolean) + }, + anyOrNull(), + ) + } + + // endregion + @Test fun `null tags do not cause NPE`() { val scopes = generateScopes() diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 6d6165e0e85..28d176d85b6 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -14,6 +14,7 @@ import io.sentry.hints.Cached import io.sentry.hints.DiskFlushNotification import io.sentry.hints.TransactionEnd import io.sentry.logger.ILoggerBatchProcessor +import io.sentry.metrics.IMetricsBatchProcessor import io.sentry.protocol.Contexts import io.sentry.protocol.Feedback import io.sentry.protocol.Mechanism @@ -338,6 +339,74 @@ class SentryClientTest { verifyNoMoreInteractions(batchProcessor) } + @Test + fun `when beforeSendMetric is set, callback is invoked`() { + val scope = createScope() + var invoked = false + fixture.sentryOptions.metrics.setBeforeSend { m -> + invoked = true + m + } + val sut = fixture.getSut() + sut.captureMetric( + SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "name", "gauge", 123.0), + scope, + ) + assertTrue(invoked) + } + + @Test + fun `when beforeSendMetric returns null, metric is dropped`() { + val scope = createScope() + fixture.sentryOptions.metrics.setBeforeSend { _: SentryMetricsEvent -> null } + val sut = fixture.getSut() + sut.captureMetric( + SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "name", "gauge", 123.0), + scope, + ) + verify(fixture.transport, never()).send(any(), anyOrNull()) + + assertClientReport( + fixture.sentryOptions.clientReportRecorder, + listOf(DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.TraceMetric.category, 1)), + ) + } + + @Test + fun `when beforeSendMetric throws an exception, metric is dropped`() { + val scope = createScope() + val exception = Exception("test") + + exception.stackTrace.toString() + fixture.sentryOptions.metrics.setBeforeSend { _ -> throw exception } + val sut = fixture.getSut() + sut.captureMetric( + SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "name", "gauge", 123.0), + scope, + ) + + assertClientReport( + fixture.sentryOptions.clientReportRecorder, + listOf(DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.TraceMetric.category, 1)), + ) + } + + @Test + fun `when beforeSendMetric is returns new instance, new instance is sent`() { + val scope = createScope() + val expected = + SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "expected name", "gauge", 123.0) + fixture.sentryOptions.metrics.setBeforeSend { _ -> expected } + val sut = fixture.getSut() + val batchProcessor = mock() + sut.injectForField("metricsBatchProcessor", batchProcessor) + val actual = + SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "actual name", "counter", 97.0) + sut.captureMetric(actual, scope) + verify(batchProcessor).add(check { assertEquals("expected name", it.name) }) + verifyNoMoreInteractions(batchProcessor) + } + @Test fun `when event captured with hint, hint passed to connection`() { val event = SentryEvent() diff --git a/sentry/src/test/java/io/sentry/metrics/MetricsBatchProcessorTest.kt b/sentry/src/test/java/io/sentry/metrics/MetricsBatchProcessorTest.kt new file mode 100644 index 00000000000..da83ad882f6 --- /dev/null +++ b/sentry/src/test/java/io/sentry/metrics/MetricsBatchProcessorTest.kt @@ -0,0 +1,90 @@ +package io.sentry.metrics + +import io.sentry.DataCategory +import io.sentry.ISentryClient +import io.sentry.SentryMetricsEvent +import io.sentry.SentryMetricsEvents +import io.sentry.SentryNanotimeDate +import io.sentry.SentryOptions +import io.sentry.clientreport.ClientReportTestHelper +import io.sentry.clientreport.DiscardReason +import io.sentry.clientreport.DiscardedEvent +import io.sentry.protocol.SentryId +import io.sentry.test.DeferredExecutorService +import io.sentry.test.injectForField +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeast +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +class MetricsBatchProcessorTest { + @Test + fun `drops metrics events after reaching MAX_QUEUE_SIZE limit`() { + // given + val mockClient = mock() + val mockExecutor = DeferredExecutorService() + val options = SentryOptions() + val processor = MetricsBatchProcessor(options, mockClient) + processor.injectForField("executorService", mockExecutor) + + for (i in 1..10001) { + val logEvent = + SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "name $i", "gauge", i.toDouble()) + processor.add(logEvent) + } + + // run twice since a non full batch would be scheduled at the end + mockExecutor.runAll() + mockExecutor.runAll() + + // assert that the transport received 10000 metrics events + val captor = argumentCaptor() + verify(mockClient, atLeast(1)).captureBatchedMetricsEvents(captor.capture()) + + val allCapturedEvents = mutableListOf() + captor.allValues.forEach { metricsEvents -> allCapturedEvents.addAll(metricsEvents.items) } + + assertEquals(10000, allCapturedEvents.size) + + // assert that metric 10001 did not make it but metric 10000 did get sent + val metric10000Found = allCapturedEvents.any { it.name == "name 10000" } + val metric10001Found = allCapturedEvents.any { it.name == "name 10001" } + + assertTrue(metric10000Found, "Metric 10000 should have been sent") + assertFalse(metric10001Found, "Metric 10001 should not have been sent") + } + + @Test + fun `records client report when log event is dropped due to queue overflow`() { + // given + val mockClient = mock() + val mockExecutor = DeferredExecutorService() + val options = SentryOptions() + val processor = MetricsBatchProcessor(options, mockClient) + processor.injectForField("executorService", mockExecutor) + + // fill the queue to MAX_QUEUE_SIZE + for (i in 1..10000) { + val logEvent = + SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "name $i", "gauge", i.toDouble()) + processor.add(logEvent) + } + + // add one more metrics event that should be dropped + val droppedMetricsEvent = + SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "dropped metric", "gauge", 10001.0) + processor.add(droppedMetricsEvent) + + // verify that a client report was recorded for the dropped metrics item + val expectedEvents = + mutableListOf( + DiscardedEvent(DiscardReason.QUEUE_OVERFLOW.reason, DataCategory.TraceMetric.category, 1) + ) + + ClientReportTestHelper.assertClientReport(options.clientReportRecorder, expectedEvents) + } +} diff --git a/sentry/src/test/java/io/sentry/protocol/SentryMetricsSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryMetricsSerializationTest.kt new file mode 100644 index 00000000000..2ce89e0af4b --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/SentryMetricsSerializationTest.kt @@ -0,0 +1,96 @@ +package io.sentry.protocol + +import io.sentry.DateUtils +import io.sentry.FileFromResources +import io.sentry.ILogger +import io.sentry.JsonObjectReader +import io.sentry.JsonObjectWriter +import io.sentry.JsonSerializable +import io.sentry.SentryAttributeType +import io.sentry.SentryLogEventAttributeValue +import io.sentry.SentryMetricsEvent +import io.sentry.SentryMetricsEvents +import io.sentry.SpanId +import java.io.StringReader +import java.io.StringWriter +import kotlin.test.assertEquals +import org.junit.Test +import org.mockito.kotlin.mock + +class SentryMetricsSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = + SentryMetricsEvents( + listOf( + SentryMetricsEvent( + SentryId("5c1f73d39486827b9e60ceb1fc23277a"), + DateUtils.dateToSeconds(DateUtils.getDateTime("2004-04-10T18:24:03.000Z")), + "42e6bd2a-c45e-414d-8066-ed5196fbc686", + "counter", + 123.0, + ) + .also { + it.spanId = SpanId("f28b86350e534671") + it.unit = "visit" + it.attributes = + mutableMapOf( + "sentry.sdk.name" to + SentryLogEventAttributeValue("string", "sentry.java.spring-boot.jakarta"), + "sentry.environment" to SentryLogEventAttributeValue("string", "production"), + "sentry.sdk.version" to SentryLogEventAttributeValue("string", "8.11.1"), + "sentry.trace.parent_span_id" to + SentryLogEventAttributeValue("string", "f28b86350e534671"), + "custom.boolean" to SentryLogEventAttributeValue("boolean", true), + "custom.point2" to + SentryLogEventAttributeValue(SentryAttributeType.STRING, Point(21, 31)), + "custom.double" to SentryLogEventAttributeValue("double", 11.12.toDouble()), + "custom.point" to SentryLogEventAttributeValue("string", Point(20, 30)), + "custom.integer" to SentryLogEventAttributeValue("integer", 10), + ) + } + ) + ) + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = sanitizedFile("json/sentry_metrics.json") + val actual = serialize(fixture.getSut()) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/sentry_metrics.json") + val actual = deserialize(expectedJson) + val actualJson = serialize(actual) + assertEquals(expectedJson, actualJson) + } + + // Helper + + private fun sanitizedFile(path: String): String = + FileFromResources.invoke(path).replace(Regex("[\n\r]"), "").replace(" ", "") + + private fun serialize(jsonSerializable: JsonSerializable): String { + val wrt = StringWriter() + val jsonWrt = JsonObjectWriter(wrt, 100) + jsonSerializable.serialize(jsonWrt, fixture.logger) + return wrt.toString() + } + + private fun deserialize(json: String): SentryMetricsEvents { + val reader = JsonObjectReader(StringReader(json)) + return SentryMetricsEvents.Deserializer().deserialize(reader, fixture.logger) + } + + companion object { + data class Point(val x: Int, val y: Int) { + override fun toString(): String = "Point{x:$x,y:$y}-Hello" + } + } +} diff --git a/sentry/src/test/resources/json/sentry_metrics.json b/sentry/src/test/resources/json/sentry_metrics.json new file mode 100644 index 00000000000..4bacc7ececf --- /dev/null +++ b/sentry/src/test/resources/json/sentry_metrics.json @@ -0,0 +1,59 @@ +{ + "items": + [ + { + "timestamp": 1081621443.000000, + "type": "counter", + "name": "42e6bd2a-c45e-414d-8066-ed5196fbc686", + "value": 123.0, + "trace_id": "5c1f73d39486827b9e60ceb1fc23277a", + "span_id": "f28b86350e534671", + "unit": "visit", + "attributes": + { + "sentry.sdk.name": + { + "type": "string", + "value": "sentry.java.spring-boot.jakarta" + }, + "sentry.environment": + { + "type": "string", + "value": "production" + }, + "sentry.sdk.version": + { + "type": "string", + "value": "8.11.1" + }, + "sentry.trace.parent_span_id": + { + "type": "string", + "value": "f28b86350e534671" + }, + "custom.boolean": + { + "type": "boolean", + "value": true + }, + "custom.point2": { + "type": "string", + "value": "Point{x:21,y:31}-Hello" + }, + "custom.double": { + "type": "double", + "value": 11.12 + }, + "custom.point": { + "type": "string", + "value": "Point{x:20,y:30}-Hello" + }, + "custom.integer": + { + "type": "integer", + "value": 10 + } + } + } + ] +}