diff --git a/.cursor/rules/feature_flags.mdc b/.cursor/rules/feature_flags.mdc index 6ced6064295..f2a78bc71cf 100644 --- a/.cursor/rules/feature_flags.mdc +++ b/.cursor/rules/feature_flags.mdc @@ -10,15 +10,35 @@ There is a scope based and a span based API for tracking feature flag evaluation The `addFeatureFlag` method can be used to track feature flag evaluations. It exists on `Sentry` static API as well as `IScopes` and `IScope`. +When using static API, `IScopes` or COMBINED scope type, Sentry will also invoke `addFeatureFlag` on the current span. This does not happen, when directly invoking `addFeatureFlag` on `IScope` (except for COMBINED scope type). + The `maxFeatureFlags` option controls how many flags are tracked per scope and also how many are sent to Sentry as part of events. Scope based feature flags can also be disabled by setting the value to 0. Defaults to 100 feature flag evaluations. Order of feature flag evaluations is important as we only keep track of the last {maxFeatureFlag} items. -When a feature flag evluation with the same name is added, the previous one is removed and the new one is stored so that it'll be dropped last. +When a feature flag evaluation with the same name is added, the previous one is removed and the new one is stored so that it'll be dropped last. +Refer to `FeatureFlagBuffer` fore more details. `FeatureFlagBuffer` has been optimized for storing scope based feature flag evaluations, especially clone performance. -When sending out an error event, feature flag buffers from all three scope types (global, isolation and current scope) are merged, chosing the newest {maxFeatureFlag} entries across all scope types. Feature flags are sent as part of the `flags` context. +When sending out an error event, feature flag buffers from all three scope types (global, isolation and current scope) are merged, choosing the newest {maxFeatureFlag} entries across all scope types. Feature flags are sent as part of the `flags` context. ## Span Based API -tbd +It's also possible to use the `addFeatureFlag` method on `ISpan` (and by extension `ITransaction`). Feature flag evaluations tracked this way +will not be added to the scope and thus won't be added to error events. + +Each span has its own `SpanFeatureFlagBuffer`. When starting a child span, feature flag evaluations are NOT copied from the parent. Each span starts out with an empty buffer and has its own limit. +`SpanFeatureFlagBuffer` has been optimized for storing feature flag evaluations on spans. + +Spans have a hard coded limit of 10 feature flag evaluations. When full, new entries are rejected. Updates to existing entries are still allowed even if full. + +## Integrations + +We offer integrations that automatically track feature flag evaluations. + +Android: +- LaunchDarkly (`SentryLaunchDarklyAndroidHook`) + +JVM (non Android): +- LaunchDarkly (`SentryLaunchDarklyServerHook`) +- OpenFeature (`SentryOpenFeatureHook`) diff --git a/.cursor/rules/metrics.mdc b/.cursor/rules/metrics.mdc new file mode 100644 index 00000000000..93c82ecb467 --- /dev/null +++ b/.cursor/rules/metrics.mdc @@ -0,0 +1,26 @@ +--- +alwaysApply: false +description: Metrics API +--- +# Java SDK Metrics API + +Metrics are enabled by default. + +API has been namespaced under `Sentry.metrics()` and `IScopes.metrics()` using the `IMetricsApi` interface and `MetricsApi` implementation. + +Options are namespaced under `SentryOptions.getMetrics()`. + +Three different APIs exist: +- `count`: Counters are one of the more basic types of metrics and can be used to count certain event occurrences. +- `distribution`: Distributions help you get the most insights from your data by allowing you to obtain aggregations such as p90, min, max, and avg. +- `gauge`: Gauges let you obtain aggregates like min, max, avg, sum, and count. They can be represented in a more space-efficient way than distributions, but they can't be used to get percentiles. If percentiles aren't important to you, we recommend using gauges. + +Refer to `SentryMetricsEvent` for details about available fields. + +`MetricsBatchProcessor` handles batching (`MAX_BATCH_SIZE`), automatic sending of metrics after a timeout (`FLUSH_AFTER_MS`) and rejecting if `MAX_QUEUE_SIZE` has been hit. + +The flow is `IMetricsApi` -> `IMetricsBatchProcessor` -> `SentryClient.captureBatchedMetricsEvents` -> `ITransport`. + +Each `SentryMetricsEvent` goes through `SentryOptions.metrics.beforeSend` (if configured) and can be modified or dropped. + +For sending, a batch of `SentryMetricsEvent` objects is sent inside a `SentryMetricsEvents` object. diff --git a/.cursor/rules/overview_dev.mdc b/.cursor/rules/overview_dev.mdc index 12ac73a8448..f05d2992d40 100644 --- a/.cursor/rules/overview_dev.mdc +++ b/.cursor/rules/overview_dev.mdc @@ -37,10 +37,21 @@ Use the `fetch_rules` tool to include these rules when working on specific areas - **`feature_flags`**: Use when working with: - Feature flag tracking and evaluation - `addFeatureFlag()`, `getFeatureFlags()` methods - - `FeatureFlagBuffer`, `FeatureFlag` protocol + - `FeatureFlagBuffer`, `SpanFeatureFlagBuffer`, `FeatureFlag` protocol - `maxFeatureFlags` option and buffer management - Feature flag merging across scope types - Scope-based vs span-based feature flag APIs + - Scope-based API: `Sentry`, `IScopes`, `IScope` APIs + - Span-based API: `ISpan`, `ITransaction` APIs + - Integrations: LaunchDarkly (Android/JVM), OpenFeature (JVM) + +- **`metrics`**: Use when working with: + - Metrics API (`Sentry.metrics()`, `IScopes.metrics()`) + - `IMetricsApi`, `MetricsApi` implementation + - Metrics types: `count`, `distribution`, `gauge` + - `MetricsBatchProcessor`, batching and queue management + - `SentryMetricsEvent`, `SentryMetricsEvents` + - `SentryOptions.getMetrics()`, `beforeSend` callback ### Integration & Infrastructure - **`opentelemetry`**: Use when working with: @@ -72,3 +83,4 @@ Use the `fetch_rules` tool to include these rules when working on specific areas - Cache/offline/network → `offline` - System test/e2e/sample → `e2e_tests` - Feature flag/addFeatureFlag/flag evaluation → `feature_flags` + - Metrics/count/distribution/gauge → `metrics` diff --git a/.cursor/rules/scopes.mdc b/.cursor/rules/scopes.mdc index 179b0943565..e054755d4f5 100644 --- a/.cursor/rules/scopes.mdc +++ b/.cursor/rules/scopes.mdc @@ -49,6 +49,15 @@ Data is also passed on to newly forked child scopes but not to parents. Current scope can be retrieved from `Scopes` via `getScope`. +### Combined Scope + +This is a special scope type that combines global, isolation and current scope. + +Refer to `CombinedScopeView` for each field of interest to see whether values from the three individual scopes are merged, +whether a specific one is used or whether we're simply using the first one that has a value. + +Also see the section about `defaultScopeType` further down. + ## Storage of `Scopes` `Scopes` are stored in a `ThreadLocal` by default (NOTE: this is different for OpenTelemetry, see opentelemetry.mdc). diff --git a/.envrc b/.envrc index 97b3f16c6f7..f58a7cee600 100644 --- a/.envrc +++ b/.envrc @@ -1,2 +1,3 @@ -export VIRTUAL_ENV=".venv" -layout python3 +export VIRTUAL_ENV="${PWD}/.venv" +devenv sync +PATH_add "${PWD}/.venv/bin" diff --git a/.gitignore b/.gitignore index be4f11ce3d2..5f87fdbd5c5 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ spring-server.txt spy.log .kotlin **/tomcat.8080/webapps/ +**/__pycache__ diff --git a/.python-version b/.python-version new file mode 100644 index 00000000000..2c20ac9bea3 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d6b0e37e69..8fb05bc617f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ - Add a Tombstone integration that detects native crashes without relying on the NDK integration, but instead using `ApplicationExitInfo.REASON_CRASH_NATIVE` on Android 12+. ([#4933](https://github.com/getsentry/sentry-java/pull/4933)) - Currently exposed via options as an _internal_ API only. - If enabled alongside the NDK integration, crashes will be reported as two separate events. Users should enable only one; deduplication between both integrations will be added in a future release. +- Add Sentry Metrics to Java SDK ([#5026](https://github.com/getsentry/sentry-java/pull/5026)) + - Metrics are enabled by default + - APIs are namespaced under `Sentry.metrics()` + - We offer the following APIs: + - `count`: 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 ## 8.29.0 diff --git a/devenv/config.ini b/devenv/config.ini new file mode 100644 index 00000000000..b546b762c0c --- /dev/null +++ b/devenv/config.ini @@ -0,0 +1,16 @@ +[devenv] +minimum_version = 1.22.1 + +[uv] +darwin_arm64 = https://github.com/astral-sh/uv/releases/download/0.8.2/uv-aarch64-apple-darwin.tar.gz +darwin_arm64_sha256 = 954d24634d5f37fa26c7af75eb79893d11623fc81b4de4b82d60d1ade4bfca22 +darwin_x86_64 = https://github.com/astral-sh/uv/releases/download/0.8.2/uv-x86_64-apple-darwin.tar.gz +darwin_x86_64_sha256 = ae755df53c8c2c1f3dfbee6e3d2e00be0dfbc9c9b4bdffdb040b96f43678b7ce +linux_arm64 = https://github.com/astral-sh/uv/releases/download/0.8.2/uv-aarch64-unknown-linux-gnu.tar.gz +linux_arm64_sha256 = 27da35ef54e9131c2e305de67dd59a07c19257882c6b1f3cf4d8d5fbb8eaf4ca +linux_x86_64 = https://github.com/astral-sh/uv/releases/download/0.8.2/uv-x86_64-unknown-linux-gnu.tar.gz +linux_x86_64_sha256 = 6dcb28a541868a455aefb2e8d4a1283dd6bf888605a2db710f0530cec888b0ad +# used for autoupdate +# NOTE: if using uv-build as a build backend, you'll have to make sure the versions match +version = 0.8.2 + diff --git a/devenv/sync.py b/devenv/sync.py new file mode 100644 index 00000000000..45e663cd99a --- /dev/null +++ b/devenv/sync.py @@ -0,0 +1,23 @@ +from devenv import constants +from devenv.lib import config, proc, uv +import os + +def main(context: dict[str, str]) -> int: + reporoot = context["reporoot"] + cfg = config.get_repo(reporoot) + + uv.install( + cfg["uv"]["version"], + cfg["uv"][constants.SYSTEM_MACHINE], + cfg["uv"][f"{constants.SYSTEM_MACHINE}_sha256"], + reporoot, + ) + + # reporoot/.venv is the default venv location + print(f"syncing .venv ...") + if not os.path.exists(".venv"): + proc.run(("uv", "venv", "--seed")) + proc.run(("uv", "sync", "--frozen", "--quiet")) + + return 0 + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000000..55509e3912b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "javasdk" +version = "0.0.0" diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0ff8f048a40..ff9a0c7597d 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -100,6 +100,18 @@ public class io/sentry/android/core/AndroidMemoryCollector : io/sentry/IPerforma public fun setup ()V } +public final class io/sentry/android/core/AndroidMetricsBatchProcessor : io/sentry/metrics/MetricsBatchProcessor, io/sentry/android/core/AppState$AppStateListener { + public fun (Lio/sentry/SentryOptions;Lio/sentry/ISentryClient;)V + public fun close (Z)V + public fun onBackground ()V + public fun onForeground ()V +} + +public final class io/sentry/android/core/AndroidMetricsBatchProcessorFactory : io/sentry/metrics/IMetricsBatchProcessorFactory { + public fun ()V + public fun create (Lio/sentry/SentryOptions;Lio/sentry/SentryClient;)Lio/sentry/metrics/IMetricsBatchProcessor; +} + public class io/sentry/android/core/AndroidProfiler { protected final field lock Lio/sentry/util/AutoClosableReentrantLock; public fun (Ljava/lang/String;ILio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ISentryExecutorService;Lio/sentry/ILogger;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMetricsBatchProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMetricsBatchProcessor.java new file mode 100644 index 00000000000..290f2a9d4ed --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMetricsBatchProcessor.java @@ -0,0 +1,49 @@ +package io.sentry.android.core; + +import io.sentry.ISentryClient; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.metrics.MetricsBatchProcessor; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public final class AndroidMetricsBatchProcessor extends MetricsBatchProcessor + implements AppState.AppStateListener { + + public AndroidMetricsBatchProcessor( + final @NotNull SentryOptions options, final @NotNull ISentryClient client) { + super(options, client); + AppState.getInstance().addAppStateListener(this); + } + + @Override + public void onForeground() { + // no-op + } + + @Override + public void onBackground() { + try { + options + .getExecutorService() + .submit( + new Runnable() { + @Override + public void run() { + flush(MetricsBatchProcessor.FLUSH_AFTER_MS); + } + }); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.ERROR, t, "Failed to submit metrics flush in onBackground()"); + } + } + + @Override + public void close(boolean isRestarting) { + AppState.getInstance().removeAppStateListener(this); + super.close(isRestarting); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMetricsBatchProcessorFactory.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMetricsBatchProcessorFactory.java new file mode 100644 index 00000000000..319440c27a9 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMetricsBatchProcessorFactory.java @@ -0,0 +1,15 @@ +package io.sentry.android.core; + +import io.sentry.SentryClient; +import io.sentry.SentryOptions; +import io.sentry.metrics.IMetricsBatchProcessor; +import io.sentry.metrics.IMetricsBatchProcessorFactory; +import org.jetbrains.annotations.NotNull; + +public final class AndroidMetricsBatchProcessorFactory implements IMetricsBatchProcessorFactory { + @Override + public @NotNull IMetricsBatchProcessor create( + final @NotNull SentryOptions options, final @NotNull SentryClient client) { + return new AndroidMetricsBatchProcessor(options, client); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 1243c3acb50..b7bb5bf21ac 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -125,6 +125,7 @@ static void loadDefaultAndMetadataOptions( options.setDateProvider(new SentryAndroidDateProvider()); options.setRuntimeManager(new AndroidRuntimeManager()); options.getLogs().setLoggerBatchProcessorFactory(new AndroidLoggerBatchProcessorFactory()); + options.getMetrics().setMetricsBatchProcessorFactory(new AndroidMetricsBatchProcessorFactory()); // set a lower flush timeout on Android to avoid ANRs options.setFlushTimeoutMillis(DEFAULT_FLUSH_TIMEOUT_MS); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index dd4baed4630..5e72ad7cfc2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -93,6 +93,14 @@ public DefaultAndroidEventProcessor( return event; } + @Override + public @Nullable SentryMetricsEvent process( + final @NotNull SentryMetricsEvent event, final @NotNull Hint hint) { + setDevice(event); + setOs(event); + return event; + } + /** * The last exception is usually used for picking the issue title, but the convention is to send * inner exceptions first, e.g. [inner, outer] This doesn't work very well on Android, as some @@ -248,6 +256,34 @@ private void setOs(final @NotNull SentryLogEvent event) { } } + private void setDevice(final @NotNull SentryMetricsEvent event) { + try { + event.setAttribute( + "device.brand", + new SentryLogEventAttributeValue(SentryAttributeType.STRING, Build.BRAND)); + event.setAttribute( + "device.model", + new SentryLogEventAttributeValue(SentryAttributeType.STRING, Build.MODEL)); + event.setAttribute( + "device.family", + new SentryLogEventAttributeValue(SentryAttributeType.STRING, deviceFamily.getValue())); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info", e); + } + } + + private void setOs(final @NotNull SentryMetricsEvent event) { + try { + event.setAttribute( + "os.name", new SentryLogEventAttributeValue(SentryAttributeType.STRING, "Android")); + event.setAttribute( + "os.version", + new SentryLogEventAttributeValue(SentryAttributeType.STRING, Build.VERSION.RELEASE)); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve os system", e); + } + } + // Data to be applied to events that was created in the running process private void processNonCachedEvent( final @NotNull SentryBaseEvent event, final @NotNull Hint hint) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 3acd0a779e1..8cb301778b0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -144,6 +144,8 @@ final class ManifestMetadataReader { static final String ENABLE_LOGS = "io.sentry.logs.enabled"; + static final String ENABLE_METRICS = "io.sentry.metrics.enabled"; + static final String ENABLE_AUTO_TRACE_ID_GENERATION = "io.sentry.traces.enable-auto-id-generation"; @@ -614,6 +616,11 @@ static void applyMetadata( .getLogs() .setEnabled(readBool(metadata, logger, ENABLE_LOGS, options.getLogs().isEnabled())); + options + .getMetrics() + .setEnabled( + readBool(metadata, logger, ENABLE_METRICS, options.getMetrics().isEnabled())); + final @NotNull SentryFeedbackOptions feedbackOptions = options.getFeedbackOptions(); feedbackOptions.setNameRequired( readBool(metadata, logger, FEEDBACK_NAME_REQUIRED, feedbackOptions.isNameRequired())); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorFactoryTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorFactoryTest.kt new file mode 100644 index 00000000000..9ddaa36abf1 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorFactoryTest.kt @@ -0,0 +1,23 @@ +package io.sentry.android.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryClient +import kotlin.test.Test +import kotlin.test.assertIs +import org.junit.runner.RunWith +import org.mockito.kotlin.mock + +@RunWith(AndroidJUnit4::class) +class AndroidMetricsBatchProcessorFactoryTest { + + @Test + fun `create returns AndroidMetricsBatchProcessor instance`() { + val factory = AndroidMetricsBatchProcessorFactory() + val options = SentryAndroidOptions() + val client: SentryClient = mock() + + val processor = factory.create(options, client) + + assertIs(processor) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt new file mode 100644 index 00000000000..fceb9ed3f4d --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt @@ -0,0 +1,96 @@ +package io.sentry.android.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ISentryClient +import io.sentry.SentryMetricsEvent +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +import io.sentry.test.ImmediateExecutorService +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class AndroidMetricsBatchProcessorTest { + + private class Fixture { + val options = SentryAndroidOptions() + val client: ISentryClient = mock() + + fun getSut( + useImmediateExecutor: Boolean = false, + config: ((SentryOptions) -> Unit)? = null, + ): AndroidMetricsBatchProcessor { + if (useImmediateExecutor) { + options.executorService = ImmediateExecutorService() + } + config?.invoke(options) + return AndroidMetricsBatchProcessor(options, client) + } + } + + private val fixture = Fixture() + + @BeforeTest + fun `set up`() { + AppState.getInstance().resetInstance() + } + + @AfterTest + fun `tear down`() { + AppState.getInstance().resetInstance() + } + + @Test + fun `constructor registers as AppState listener`() { + fixture.getSut() + assertNotNull(AppState.getInstance().lifecycleObserver) + } + + @Test + fun `onBackground schedules flush`() { + val sut = fixture.getSut(useImmediateExecutor = true) + val metricsEvent = SentryMetricsEvent(SentryId(), 1.0, "test", "counter", 3.0) + sut.add(metricsEvent) + + sut.onBackground() + + verify(fixture.client).captureBatchedMetricsEvents(any()) + } + + @Test + fun `onBackground handles executor exception gracefully`() { + val sut = + fixture.getSut { options -> + val rejectingExecutor = mock() + whenever(rejectingExecutor.submit(any())).thenThrow(RuntimeException("Rejected")) + options.executorService = rejectingExecutor + } + + // Should not throw + sut.onBackground() + } + + @Test + fun `close removes AppState listener`() { + val sut = fixture.getSut() + sut.close(false) + + assertTrue(AppState.getInstance().lifecycleObserver.listeners.isEmpty()) + } + + @Test + fun `close with isRestarting true still removes listener`() { + val sut = fixture.getSut() + sut.close(true) + + assertTrue(AppState.getInstance().lifecycleObserver.listeners.isEmpty()) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt index 32e82155b2d..2810cb3b00f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt @@ -5,11 +5,15 @@ import android.os.Build import android.os.Looper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.DateUtils import io.sentry.DiagnosticLogger import io.sentry.Hint import io.sentry.IScopes import io.sentry.SentryEvent import io.sentry.SentryLevel +import io.sentry.SentryLogEvent +import io.sentry.SentryLogLevel +import io.sentry.SentryMetricsEvent import io.sentry.SentryTracer import io.sentry.TransactionContext import io.sentry.TypeCheckHint.SENTRY_DART_SDK_NAME @@ -17,6 +21,7 @@ import io.sentry.android.core.internal.util.CpuInfoUtils import io.sentry.protocol.OperatingSystem import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryException +import io.sentry.protocol.SentryId import io.sentry.protocol.SentryStackFrame import io.sentry.protocol.SentryStackTrace import io.sentry.protocol.SentryThread @@ -619,4 +624,46 @@ class DefaultAndroidEventProcessorTest { assertEquals("IllegalArgumentException", it.exceptions!![1].type) } } + + @Test + fun `device and os are set on metric`() { + val sut = fixture.getSut(context) + val processedEvent: SentryMetricsEvent? = + sut.process( + SentryMetricsEvent( + SentryId("5c1f73d39486827b9e60ceb1fc23277a"), + DateUtils.dateToSeconds(DateUtils.getDateTime("2004-04-10T18:24:03.000Z")), + "42e6bd2a-c45e-414d-8066-ed5196fbc686", + "counter", + 123.0, + ), + Hint(), + ) + + assertNotNull(processedEvent?.attributes?.get("device.brand")) + assertNotNull(processedEvent?.attributes?.get("device.model")) + assertNotNull(processedEvent?.attributes?.get("device.family")) + assertNotNull(processedEvent?.attributes?.get("os.name")) + assertNotNull(processedEvent?.attributes?.get("os.version")) + } + + @Test + fun `device and os are set on log`() { + val sut = fixture.getSut(context) + val processedEvent: SentryLogEvent? = + sut.process( + SentryLogEvent( + SentryId("5c1f73d39486827b9e60ceb1fc23277a"), + DateUtils.dateToSeconds(DateUtils.getDateTime("2004-04-10T18:24:03.000Z")), + "message", + SentryLogLevel.WARN, + ) + ) + + assertNotNull(processedEvent?.attributes?.get("device.brand")) + assertNotNull(processedEvent?.attributes?.get("device.model")) + assertNotNull(processedEvent?.attributes?.get("device.family")) + assertNotNull(processedEvent?.attributes?.get("os.name")) + assertNotNull(processedEvent?.attributes?.get("os.version")) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 06d284c5dd7..c17334120b9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1711,6 +1711,44 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.logs.isEnabled) } + @Test + fun `applyMetadata reads metrics enabled and keep default value if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.metrics.isEnabled) + } + + @Test + fun `applyMetadata reads metrics enabled to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ENABLE_METRICS to false) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.metrics.isEnabled) + } + + @Test + fun `applyMetadata reads metrics enabled to options when set to true`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ENABLE_METRICS to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.metrics.isEnabled) + } + @Test fun `applyMetadata reads feedback name required and keep default value if not found`() { // Arrange 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 bdb328e2421..ef920e1d7fe 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,8 @@ import io.sentry.SentryEnvelope import io.sentry.SentryEvent import io.sentry.SentryLogEvent import io.sentry.SentryLogEvents +import io.sentry.SentryMetricsEvent +import io.sentry.SentryMetricsEvents import io.sentry.SentryReplayEvent import io.sentry.Session import io.sentry.TraceContext @@ -192,6 +194,14 @@ class SessionTrackingIntegrationTest { TODO("Not yet implemented") } + override fun captureMetric(event: SentryMetricsEvent, scope: IScope?, hint: Hint?) { + 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-samples/sentry-samples-console-opentelemetry-noagent/src/main/java/io/sentry/samples/console/Main.java b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/main/java/io/sentry/samples/console/Main.java index a41e853e156..9efab21031d 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/main/java/io/sentry/samples/console/Main.java +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/main/java/io/sentry/samples/console/Main.java @@ -63,6 +63,8 @@ public static void main(String[] args) throws InterruptedException { Sentry.addFeatureFlag("my-feature-flag", true); + captureMetrics(); + // Sending exception: Exception exception = new RuntimeException("Some error!"); Sentry.captureException(exception); @@ -136,6 +138,12 @@ public static void main(String[] args) throws InterruptedException { // Sentry.close(); } + private static void captureMetrics() { + Sentry.metrics().count("countMetric"); + Sentry.metrics().gauge("gaugeMetric", 5.0); + Sentry.metrics().distribution("distributionMetric", 7.0); + } + private static class SomeEventProcessor implements EventProcessor { @Override public SentryEvent process(SentryEvent event, Hint hint) { diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt index 8fc3909fa5c..7a89e5b1e6f 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt @@ -107,5 +107,11 @@ class ConsoleApplicationSystemTest { breadcrumb.message?.contains("Processed by") == true } == true } + + testHelper.ensureMetricsReceived { metricsEvents, sentryEnvelopeHeader -> + testHelper.doesContainMetric(metricsEvents, "countMetric", "counter", 1.0) && + testHelper.doesContainMetric(metricsEvents, "gaugeMetric", "gauge", 5.0) && + testHelper.doesContainMetric(metricsEvents, "distributionMetric", "distribution", 7.0) + } } } diff --git a/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java b/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java index 5f557b7ad2a..fd21476f402 100644 --- a/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java +++ b/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java @@ -128,6 +128,8 @@ public static void main(String[] args) throws InterruptedException { Sentry.addFeatureFlag("my-feature-flag", true); + captureMetrics(); + // Sending exception: Exception exception = new RuntimeException("Some error!"); Sentry.captureException(exception); @@ -187,6 +189,12 @@ public static void main(String[] args) throws InterruptedException { // Sentry.close(); } + private static void captureMetrics() { + Sentry.metrics().count("countMetric"); + Sentry.metrics().gauge("gaugeMetric", 5.0); + Sentry.metrics().distribution("distributionMetric", 7.0); + } + private static class SomeEventProcessor implements EventProcessor { @Override public SentryEvent process(SentryEvent event, Hint hint) { diff --git a/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt index ac4849f241a..9f968485345 100644 --- a/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ b/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt @@ -102,5 +102,11 @@ class ConsoleApplicationSystemTest { breadcrumb.message?.contains("Processed by") == true } == true } + + testHelper.ensureMetricsReceived { metricsEvents, sentryEnvelopeHeader -> + testHelper.doesContainMetric(metricsEvents, "countMetric", "counter", 1.0) && + testHelper.doesContainMetric(metricsEvents, "gaugeMetric", "gauge", 5.0) && + testHelper.doesContainMetric(metricsEvents, "distributionMetric", "distribution", 7.0) + } } } diff --git a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/MetricController.java b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/MetricController.java new file mode 100644 index 00000000000..b7f7d566b9c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring7.web; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..3d7afa4f73a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080/sentry-samples-spring-7-0.0.1-SNAPSHOT") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/MetricController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/MetricController.java new file mode 100644 index 00000000000..2a969ec8849 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..dc2ca2a10ae --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/MetricController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/MetricController.java new file mode 100644 index 00000000000..2a969ec8849 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..dc2ca2a10ae --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/MetricController.java b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/MetricController.java new file mode 100644 index 00000000000..2a969ec8849 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..dc2ca2a10ae --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/MetricController.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/MetricController.java new file mode 100644 index 00000000000..2a969ec8849 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..dc2ca2a10ae --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java new file mode 100644 index 00000000000..f7c7529525f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot.jakarta; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..dc2ca2a10ae --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java new file mode 100644 index 00000000000..f7c7529525f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot.jakarta; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..dc2ca2a10ae --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java new file mode 100644 index 00000000000..f7c7529525f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot.jakarta; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..dc2ca2a10ae --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/MetricController.java b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/MetricController.java new file mode 100644 index 00000000000..da5b1d655de --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..dc2ca2a10ae --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/MetricController.java b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/MetricController.java new file mode 100644 index 00000000000..da5b1d655de --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..dc2ca2a10ae --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java new file mode 100644 index 00000000000..f7c7529525f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot.jakarta; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..dc2ca2a10ae --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/MetricController.java b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/MetricController.java new file mode 100644 index 00000000000..da5b1d655de --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..dc2ca2a10ae --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/MetricController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/MetricController.java new file mode 100644 index 00000000000..da5b1d655de --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..dc2ca2a10ae --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-jakarta/src/main/java/io/sentry/samples/spring/jakarta/web/MetricController.java b/sentry-samples/sentry-samples-spring-jakarta/src/main/java/io/sentry/samples/spring/jakarta/web/MetricController.java new file mode 100644 index 00000000000..6c236a76a96 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-jakarta/src/main/java/io/sentry/samples/spring/jakarta/web/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.jakarta.web; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..cef07e4866e --- /dev/null +++ b/sentry-samples/sentry-samples-spring-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080/sentry-samples-spring-jakarta-0.0.1-SNAPSHOT") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/web/MetricController.java b/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/web/MetricController.java new file mode 100644 index 00000000000..c0629ec137c --- /dev/null +++ b/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/web/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.web; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..6da3d98577e --- /dev/null +++ b/sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080/sentry-samples-spring-0.0.1-SNAPSHOT") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java index 618895e51fe..1b804e8cb8d 100644 --- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java @@ -105,6 +105,8 @@ static class HubConfiguration { beforeSendTransactionCallback, final @NotNull ObjectProvider beforeSendLogsCallback, + final @NotNull ObjectProvider + beforeSendMetricCallback, final @NotNull ObjectProvider beforeBreadcrumbCallback, final @NotNull ObjectProvider tracesSamplerCallback, @@ -117,6 +119,8 @@ static class HubConfiguration { beforeSendCallback.ifAvailable(options::setBeforeSend); beforeSendTransactionCallback.ifAvailable(options::setBeforeSendTransaction); beforeSendLogsCallback.ifAvailable(callback -> options.getLogs().setBeforeSend(callback)); + beforeSendMetricCallback.ifAvailable( + callback -> options.getMetrics().setBeforeSend(callback)); beforeBreadcrumbCallback.ifAvailable(options::setBeforeBreadcrumb); tracesSamplerCallback.ifAvailable(options::setTracesSampler); eventProcessors.forEach(options::addEventProcessor); diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt index 14a79a761f9..51bbb8d83d1 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt @@ -21,6 +21,7 @@ import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel import io.sentry.SentryLogEvent +import io.sentry.SentryMetricsEvent import io.sentry.SentryOptions import io.sentry.asyncprofiler.profiling.JavaContinuousProfiler import io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider @@ -370,6 +371,17 @@ class SentryAutoConfigurationTest { } } + @Test + fun `registers metrics beforeSendCallback on SentryOptions`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(CustomBeforeSendMetricCallbackConfiguration::class.java) + .run { + assertThat(it.getBean(SentryOptions::class.java).metrics.beforeSend) + .isInstanceOf(CustomBeforeSendMetricCallback::class.java) + } + } + @Test fun `registers beforeBreadcrumbCallback on SentryOptions`() { contextRunner @@ -1240,6 +1252,16 @@ class SentryAutoConfigurationTest { override fun execute(event: SentryLogEvent): SentryLogEvent? = null } + @Configuration(proxyBeanMethods = false) + open class CustomBeforeSendMetricCallbackConfiguration { + + @Bean open fun beforeSendCallback() = CustomBeforeSendMetricCallback() + } + + class CustomBeforeSendMetricCallback : SentryOptions.Metrics.BeforeSendMetricCallback { + override fun execute(metric: SentryMetricsEvent, hint: Hint): SentryMetricsEvent? = null + } + @Configuration(proxyBeanMethods = false) open class CustomBeforeSendTransactionCallbackConfiguration { diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java index 9ae80432eed..8663dac8c56 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java @@ -105,6 +105,8 @@ static class HubConfiguration { beforeSendTransactionCallback, final @NotNull ObjectProvider beforeSendLogsCallback, + final @NotNull ObjectProvider + beforeSendMetricCallback, final @NotNull ObjectProvider beforeBreadcrumbCallback, final @NotNull ObjectProvider onDiscardCallback, @@ -118,6 +120,8 @@ static class HubConfiguration { beforeSendCallback.ifAvailable(options::setBeforeSend); beforeSendTransactionCallback.ifAvailable(options::setBeforeSendTransaction); beforeSendLogsCallback.ifAvailable(callback -> options.getLogs().setBeforeSend(callback)); + beforeSendMetricCallback.ifAvailable( + callback -> options.getMetrics().setBeforeSend(callback)); beforeBreadcrumbCallback.ifAvailable(options::setBeforeBreadcrumb); onDiscardCallback.ifAvailable(options::setOnDiscard); tracesSamplerCallback.ifAvailable(options::setTracesSampler); diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt index 7dadbf60249..72ed7b8d8ba 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt @@ -23,6 +23,7 @@ import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel import io.sentry.SentryLogEvent +import io.sentry.SentryMetricsEvent import io.sentry.SentryOptions import io.sentry.asyncprofiler.profiling.JavaContinuousProfiler import io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider @@ -381,6 +382,17 @@ class SentryAutoConfigurationTest { } } + @Test + fun `registers metrics beforeSendCallback on SentryOptions`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(CustomBeforeSendMetricCallbackConfiguration::class.java) + .run { + assertThat(it.getBean(SentryOptions::class.java).metrics.beforeSend) + .isInstanceOf(CustomBeforeSendMetricCallback::class.java) + } + } + @Test fun `registers beforeBreadcrumbCallback on SentryOptions`() { contextRunner @@ -1262,6 +1274,16 @@ class SentryAutoConfigurationTest { override fun execute(event: SentryLogEvent): SentryLogEvent? = null } + @Configuration(proxyBeanMethods = false) + open class CustomBeforeSendMetricCallbackConfiguration { + + @Bean open fun beforeSendCallback() = CustomBeforeSendMetricCallback() + } + + class CustomBeforeSendMetricCallback : SentryOptions.Metrics.BeforeSendMetricCallback { + override fun execute(metric: SentryMetricsEvent, hint: Hint): SentryMetricsEvent? = null + } + @Configuration(proxyBeanMethods = false) open class CustomBeforeSendTransactionCallbackConfiguration { diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java index ec7998eaa88..76424b5c55f 100644 --- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java @@ -103,6 +103,8 @@ static class HubConfiguration { beforeSendTransactionCallback, final @NotNull ObjectProvider beforeSendLogsCallback, + final @NotNull ObjectProvider + beforeSendMetricCallback, final @NotNull ObjectProvider beforeBreadcrumbCallback, final @NotNull ObjectProvider onDiscardCallback, @@ -116,6 +118,8 @@ static class HubConfiguration { beforeSendCallback.ifAvailable(options::setBeforeSend); beforeSendTransactionCallback.ifAvailable(options::setBeforeSendTransaction); beforeSendLogsCallback.ifAvailable(callback -> options.getLogs().setBeforeSend(callback)); + beforeSendMetricCallback.ifAvailable( + callback -> options.getMetrics().setBeforeSend(callback)); beforeBreadcrumbCallback.ifAvailable(options::setBeforeBreadcrumb); onDiscardCallback.ifAvailable(options::setOnDiscard); tracesSamplerCallback.ifAvailable(options::setTracesSampler); diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt index 00cd7f06b16..70cb61d088d 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt @@ -23,6 +23,7 @@ import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel import io.sentry.SentryLogEvent +import io.sentry.SentryMetricsEvent import io.sentry.SentryOptions import io.sentry.asyncprofiler.profiling.JavaContinuousProfiler import io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider @@ -369,6 +370,17 @@ class SentryAutoConfigurationTest { } } + @Test + fun `registers metrics beforeSendCallback on SentryOptions`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(CustomBeforeSendMetricCallbackConfiguration::class.java) + .run { + assertThat(it.getBean(SentryOptions::class.java).metrics.beforeSend) + .isInstanceOf(CustomBeforeSendMetricCallback::class.java) + } + } + @Test fun `registers beforeSendTransactionCallback on SentryOptions`() { contextRunner @@ -1188,6 +1200,16 @@ class SentryAutoConfigurationTest { override fun execute(event: SentryLogEvent): SentryLogEvent? = null } + @Configuration(proxyBeanMethods = false) + open class CustomBeforeSendMetricCallbackConfiguration { + + @Bean open fun beforeSendCallback() = CustomBeforeSendMetricCallback() + } + + class CustomBeforeSendMetricCallback : SentryOptions.Metrics.BeforeSendMetricCallback { + override fun execute(metric: SentryMetricsEvent, hint: Hint): SentryMetricsEvent? = null + } + @Configuration(proxyBeanMethods = false) open class CustomBeforeSendTransactionCallbackConfiguration { diff --git a/sentry-system-test-support/api/sentry-system-test-support.api b/sentry-system-test-support/api/sentry-system-test-support.api index 75ea52fc853..ff620c7d809 100644 --- a/sentry-system-test-support/api/sentry-system-test-support.api +++ b/sentry-system-test-support/api/sentry-system-test-support.api @@ -549,6 +549,9 @@ public final class io/sentry/systemtest/util/RestTestClient : io/sentry/systemte public final fun createPersonDistributedTracing (Lio/sentry/systemtest/Person;Ljava/util/Map;)Lio/sentry/systemtest/Person; public static synthetic fun createPersonDistributedTracing$default (Lio/sentry/systemtest/util/RestTestClient;Lio/sentry/systemtest/Person;Ljava/util/Map;ILjava/lang/Object;)Lio/sentry/systemtest/Person; public final fun errorWithFeatureFlag (Ljava/lang/String;)Ljava/lang/String; + public final fun getCountMetric ()Ljava/lang/String; + public final fun getDistributionMetric (J)Ljava/lang/String; + public final fun getGaugeMetric (J)Ljava/lang/String; public final fun getPerson (J)Lio/sentry/systemtest/Person; public final fun getPersonDistributedTracing (JLjava/util/Map;)Lio/sentry/systemtest/Person; public static synthetic fun getPersonDistributedTracing$default (Lio/sentry/systemtest/util/RestTestClient;JLjava/util/Map;ILjava/lang/Object;)Lio/sentry/systemtest/Person; @@ -567,6 +570,8 @@ public final class io/sentry/systemtest/util/SentryMockServerClient : io/sentry/ public final class io/sentry/systemtest/util/TestHelper { public fun (Ljava/lang/String;)V public final fun doesContainLogWithBody (Lio/sentry/SentryLogEvents;Ljava/lang/String;)Z + public final fun doesContainMetric (Lio/sentry/SentryMetricsEvents;Ljava/lang/String;Ljava/lang/String;DLjava/lang/String;)Z + public static synthetic fun doesContainMetric$default (Lio/sentry/systemtest/util/TestHelper;Lio/sentry/SentryMetricsEvents;Ljava/lang/String;Ljava/lang/String;DLjava/lang/String;ILjava/lang/Object;)Z public final fun doesEventHaveExceptionMessage (Lio/sentry/SentryEvent;Ljava/lang/String;)Z public final fun doesEventHaveFlag (Lio/sentry/SentryEvent;Ljava/lang/String;Z)Z public final fun doesTransactionContainSpanWithDescription (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;)Z @@ -584,6 +589,7 @@ public final class io/sentry/systemtest/util/TestHelper { public final fun ensureErrorCount (Lcom/apollographql/apollo3/api/ApolloResponse;I)V public final fun ensureErrorReceived (Lkotlin/jvm/functions/Function1;)V public final fun ensureLogsReceived (Lkotlin/jvm/functions/Function2;)V + public final fun ensureMetricsReceived (Lkotlin/jvm/functions/Function2;)V public final fun ensureNoEnvelopeReceived (Lkotlin/jvm/functions/Function1;)V public final fun ensureNoErrors (Lcom/apollographql/apollo3/api/ApolloResponse;)V public final fun ensureNoTransactionReceived (Lkotlin/jvm/functions/Function2;)V diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt index c93a7dd938e..8c4f27345f8 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt @@ -26,6 +26,9 @@ open class LoggingInsecureRestClient { if (response?.isSuccessful != true) { return null } + if (T::class == String::class) { + return responseBody as? T + } return responseBody?.let { objectMapper().readValue(it, T::class.java) } } diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt index cb097d4483f..bdaa2333f21 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt @@ -62,6 +62,24 @@ class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestCl val response = call(request, true) return response?.body?.string() } + + fun getCountMetric(): String? { + val request = Request.Builder().url("$backendBaseUrl/metric/count") + + return callTyped(request, true) + } + + fun getGaugeMetric(value: Long): String? { + val request = Request.Builder().url("$backendBaseUrl/metric/gauge/$value") + + return callTyped(request, true) + } + + fun getDistributionMetric(value: Long): String? { + val request = Request.Builder().url("$backendBaseUrl/metric/distribution/$value") + + return callTyped(request, true) + } } data class FeatureFlagResponse(val flagKey: String, val value: Boolean) diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt index 9a4a2c58dbe..2881460a2d0 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -8,6 +8,7 @@ import io.sentry.SentryEnvelopeHeader import io.sentry.SentryEvent import io.sentry.SentryItemType import io.sentry.SentryLogEvents +import io.sentry.SentryMetricsEvents import io.sentry.SentryOptions import io.sentry.protocol.FeatureFlag import io.sentry.protocol.SentrySpan @@ -126,6 +127,58 @@ class TestHelper(backendUrl: String) { return callback(logs, envelopeHeader) } + fun doesContainMetric( + metrics: SentryMetricsEvents, + name: String, + type: String, + value: Double, + unit: String? = null, + ): Boolean { + val metricItem = + metrics.items.firstOrNull { metricItem -> + metricItem.name == name && + metricItem.type == type && + metricItem.value == value && + (unit == null || metricItem.unit == unit) + } + if (metricItem == null) { + println("Unable to find metric item with name $name, type $type and value $value in metrics:") + logObject(metrics) + return false + } + + return true + } + + fun ensureMetricsReceived(callback: ((SentryMetricsEvents, SentryEnvelopeHeader) -> Boolean)) { + ensureEnvelopeReceived { envelopeString -> checkIfMetricsMatch(envelopeString, callback) } + } + + private fun checkIfMetricsMatch( + envelopeString: String, + callback: ((SentryMetricsEvents, SentryEnvelopeHeader) -> Boolean), + ): Boolean { + val deserializeEnvelope = jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + if (deserializeEnvelope == null) { + return false + } + + val envelopeHeader = deserializeEnvelope.header + + val metricsItem = + deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.TraceMetric } + if (metricsItem == null) { + return false + } + + val metrics = metricsItem.getMetrics(jsonSerializer) + if (metrics == null) { + return false + } + + return callback(metrics, envelopeHeader) + } + fun doesContainLogWithBody(logs: SentryLogEvents, body: String): Boolean { val logItem = logs.items.firstOrNull { logItem -> logItem.body == body } if (logItem == null) { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index f6729869eba..c8e0ec4bde1 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -461,6 +461,7 @@ public abstract interface class io/sentry/EventProcessor { public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; public fun process (Lio/sentry/SentryLogEvent;)Lio/sentry/SentryLogEvent; + public fun process (Lio/sentry/SentryMetricsEvent;Lio/sentry/Hint;)Lio/sentry/SentryMetricsEvent; public fun process (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/SentryReplayEvent; public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; } @@ -512,6 +513,7 @@ public final class io/sentry/ExternalOptions { public fun isCaptureOpenTelemetryEvents ()Ljava/lang/Boolean; public fun isEnableBackpressureHandling ()Ljava/lang/Boolean; public fun isEnableLogs ()Ljava/lang/Boolean; + public fun isEnableMetrics ()Ljava/lang/Boolean; public fun isEnablePrettySerializationOutput ()Ljava/lang/Boolean; public fun isEnableSpotlight ()Ljava/lang/Boolean; public fun isEnabled ()Ljava/lang/Boolean; @@ -527,6 +529,7 @@ public final class io/sentry/ExternalOptions { public fun setEnableBackpressureHandling (Ljava/lang/Boolean;)V public fun setEnableDeduplication (Ljava/lang/Boolean;)V public fun setEnableLogs (Ljava/lang/Boolean;)V + public fun setEnableMetrics (Ljava/lang/Boolean;)V public fun setEnablePrettySerializationOutput (Ljava/lang/Boolean;)V public fun setEnableSpotlight (Ljava/lang/Boolean;)V public fun setEnableUncaughtExceptionHandler (Ljava/lang/Boolean;)V @@ -661,6 +664,7 @@ public final class io/sentry/HubAdapter : io/sentry/IHub { public fun isHealthy ()Z public fun logger ()Lio/sentry/logger/ILoggerApi; public fun makeCurrent ()Lio/sentry/ISentryLifecycleToken; + public fun metrics ()Lio/sentry/metrics/IMetricsApi; public fun popScope ()V public fun pushIsolationScope ()Lio/sentry/ISentryLifecycleToken; public fun pushScope ()Lio/sentry/ISentryLifecycleToken; @@ -732,6 +736,7 @@ public final class io/sentry/HubScopesWrapper : io/sentry/IHub { public fun isHealthy ()Z public fun logger ()Lio/sentry/logger/ILoggerApi; public fun makeCurrent ()Lio/sentry/ISentryLifecycleToken; + public fun metrics ()Lio/sentry/metrics/IMetricsApi; public fun popScope ()V public fun pushIsolationScope ()Lio/sentry/ISentryLifecycleToken; public fun pushScope ()Lio/sentry/ISentryLifecycleToken; @@ -992,6 +997,7 @@ public abstract interface class io/sentry/IScopes { public fun isNoOp ()Z public abstract fun logger ()Lio/sentry/logger/ILoggerApi; public abstract fun makeCurrent ()Lio/sentry/ISentryLifecycleToken; + public abstract fun metrics ()Lio/sentry/metrics/IMetricsApi; public abstract fun popScope ()V public abstract fun pushIsolationScope ()Lio/sentry/ISentryLifecycleToken; public abstract fun pushScope ()Lio/sentry/ISentryLifecycleToken; @@ -1026,6 +1032,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; @@ -1041,6 +1048,7 @@ public abstract interface class io/sentry/ISentryClient { public abstract fun captureLog (Lio/sentry/SentryLogEvent;Lio/sentry/IScope;)V public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/IScope;)Lio/sentry/protocol/SentryId; + public abstract fun captureMetric (Lio/sentry/SentryMetricsEvent;Lio/sentry/IScope;Lio/sentry/Hint;)V public abstract fun captureProfileChunk (Lio/sentry/ProfileChunk;Lio/sentry/IScope;)Lio/sentry/protocol/SentryId; public abstract fun captureReplayEvent (Lio/sentry/SentryReplayEvent;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureSession (Lio/sentry/Session;)V @@ -1565,6 +1573,7 @@ public final class io/sentry/NoOpHub : io/sentry/IHub { public fun isNoOp ()Z public fun logger ()Lio/sentry/logger/ILoggerApi; public fun makeCurrent ()Lio/sentry/ISentryLifecycleToken; + public fun metrics ()Lio/sentry/metrics/IMetricsApi; public fun popScope ()V public fun pushIsolationScope ()Lio/sentry/ISentryLifecycleToken; public fun pushScope ()Lio/sentry/ISentryLifecycleToken; @@ -1741,6 +1750,7 @@ public final class io/sentry/NoOpScopes : io/sentry/IScopes { public fun isNoOp ()Z public fun logger ()Lio/sentry/logger/ILoggerApi; public fun makeCurrent ()Lio/sentry/ISentryLifecycleToken; + public fun metrics ()Lio/sentry/metrics/IMetricsApi; public fun popScope ()V public fun pushIsolationScope ()Lio/sentry/ISentryLifecycleToken; public fun pushScope ()Lio/sentry/ISentryLifecycleToken; @@ -2456,6 +2466,7 @@ public final class io/sentry/Scopes : io/sentry/IScopes { public fun isHealthy ()Z public fun logger ()Lio/sentry/logger/ILoggerApi; public fun makeCurrent ()Lio/sentry/ISentryLifecycleToken; + public fun metrics ()Lio/sentry/metrics/IMetricsApi; public fun popScope ()V public fun pushIsolationScope ()Lio/sentry/ISentryLifecycleToken; public fun pushScope ()Lio/sentry/ISentryLifecycleToken; @@ -2528,6 +2539,7 @@ public final class io/sentry/ScopesAdapter : io/sentry/IScopes { public fun isHealthy ()Z public fun logger ()Lio/sentry/logger/ILoggerApi; public fun makeCurrent ()Lio/sentry/ISentryLifecycleToken; + public fun metrics ()Lio/sentry/metrics/IMetricsApi; public fun popScope ()V public fun pushIsolationScope ()Lio/sentry/ISentryLifecycleToken; public fun pushScope ()Lio/sentry/ISentryLifecycleToken; @@ -2648,6 +2660,7 @@ public final class io/sentry/Sentry { public static fun isEnabled ()Z public static fun isHealthy ()Z public static fun logger ()Lio/sentry/logger/ILoggerApi; + public static fun metrics ()Lio/sentry/metrics/IMetricsApi; public static fun popScope ()V public static fun pushIsolationScope ()Lio/sentry/ISentryLifecycleToken; public static fun pushScope ()Lio/sentry/ISentryLifecycleToken; @@ -2844,11 +2857,13 @@ 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; public fun captureFeedback (Lio/sentry/protocol/Feedback;Lio/sentry/Hint;Lio/sentry/IScope;)Lio/sentry/protocol/SentryId; public fun captureLog (Lio/sentry/SentryLogEvent;Lio/sentry/IScope;)V + public fun captureMetric (Lio/sentry/SentryMetricsEvent;Lio/sentry/IScope;Lio/sentry/Hint;)V public fun captureProfileChunk (Lio/sentry/ProfileChunk;Lio/sentry/IScope;)Lio/sentry/protocol/SentryId; public fun captureReplayEvent (Lio/sentry/SentryReplayEvent;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureSession (Lio/sentry/Session;Lio/sentry/Hint;)V @@ -2930,6 +2945,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; @@ -2941,6 +2957,7 @@ public final class io/sentry/SentryEnvelopeItem { public fun getEvent (Lio/sentry/ISerializer;)Lio/sentry/SentryEvent; public fun getHeader ()Lio/sentry/SentryEnvelopeItemHeader; public fun getLogs (Lio/sentry/ISerializer;)Lio/sentry/SentryLogEvents; + public fun getMetrics (Lio/sentry/ISerializer;)Lio/sentry/SentryMetricsEvents; public fun getTransaction (Lio/sentry/ISerializer;)Lio/sentry/protocol/SentryTransaction; } @@ -3314,6 +3331,68 @@ 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 getTraceId ()Lio/sentry/protocol/SentryId; + 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 setTraceId (Lio/sentry/protocol/SentryId;)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 @@ -3420,6 +3499,7 @@ public class io/sentry/SentryOptions { public fun getMaxRequestBodySize ()Lio/sentry/SentryOptions$RequestSize; public fun getMaxSpans ()I public fun getMaxTraceFileSize ()J + public fun getMetrics ()Lio/sentry/SentryOptions$Metrics; public fun getModulesLoader ()Lio/sentry/internal/modules/IModulesLoader; public fun getOnDiscard ()Lio/sentry/SentryOptions$OnDiscardCallback; public fun getOnOversizedEvent ()Lio/sentry/SentryOptions$OnOversizedEventCallback; @@ -3572,6 +3652,7 @@ public class io/sentry/SentryOptions { public fun setMaxRequestBodySize (Lio/sentry/SentryOptions$RequestSize;)V public fun setMaxSpans (I)V public fun setMaxTraceFileSize (J)V + public fun setMetrics (Lio/sentry/SentryOptions$Metrics;)V public fun setModulesLoader (Lio/sentry/internal/modules/IModulesLoader;)V public fun setOnDiscard (Lio/sentry/SentryOptions$OnDiscardCallback;)V public fun setOnOversizedEvent (Lio/sentry/SentryOptions$OnOversizedEventCallback;)V @@ -3626,10 +3707,6 @@ public abstract interface class io/sentry/SentryOptions$BeforeBreadcrumbCallback public abstract fun execute (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)Lio/sentry/Breadcrumb; } -public abstract interface class io/sentry/SentryOptions$BeforeEmitMetricCallback { - public abstract fun execute (Ljava/lang/String;Ljava/util/Map;)Z -} - public abstract interface class io/sentry/SentryOptions$BeforeEnvelopeCallback { public abstract fun execute (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V } @@ -3683,6 +3760,20 @@ public abstract interface class io/sentry/SentryOptions$Logs$BeforeSendLogCallba public abstract fun execute (Lio/sentry/SentryLogEvent;)Lio/sentry/SentryLogEvent; } +public final class io/sentry/SentryOptions$Metrics { + public fun ()V + public fun getBeforeSend ()Lio/sentry/SentryOptions$Metrics$BeforeSendMetricCallback; + public fun getMetricsBatchProcessorFactory ()Lio/sentry/metrics/IMetricsBatchProcessorFactory; + public fun isEnabled ()Z + public fun setBeforeSend (Lio/sentry/SentryOptions$Metrics$BeforeSendMetricCallback;)V + public fun setEnabled (Z)V + public fun setMetricsBatchProcessorFactory (Lio/sentry/metrics/IMetricsBatchProcessorFactory;)V +} + +public abstract interface class io/sentry/SentryOptions$Metrics$BeforeSendMetricCallback { + public abstract fun execute (Lio/sentry/SentryMetricsEvent;Lio/sentry/Hint;)Lio/sentry/SentryMetricsEvent; +} + public abstract interface class io/sentry/SentryOptions$OnDiscardCallback { public abstract fun execute (Lio/sentry/clientreport/DiscardReason;Lio/sentry/DataCategory;Ljava/lang/Long;)V } @@ -5113,6 +5204,134 @@ 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 fun count (Ljava/lang/String;Ljava/lang/Double;)V + public abstract fun count (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;)V + public abstract fun count (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;Lio/sentry/metrics/SentryMetricsParameters;)V + public abstract fun count (Ljava/lang/String;Ljava/lang/String;)V + public abstract fun distribution (Ljava/lang/String;Ljava/lang/Double;)V + public abstract fun distribution (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;)V + public abstract fun distribution (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;Lio/sentry/metrics/SentryMetricsParameters;)V + public abstract fun gauge (Ljava/lang/String;Ljava/lang/Double;)V + public abstract fun gauge (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;)V + public abstract fun gauge (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;Lio/sentry/metrics/SentryMetricsParameters;)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 fun count (Ljava/lang/String;Ljava/lang/Double;)V + public fun count (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;)V + public fun count (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;Lio/sentry/metrics/SentryMetricsParameters;)V + public fun count (Ljava/lang/String;Ljava/lang/String;)V + public fun distribution (Ljava/lang/String;Ljava/lang/Double;)V + public fun distribution (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;)V + public fun distribution (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;Lio/sentry/metrics/SentryMetricsParameters;)V + public fun gauge (Ljava/lang/String;Ljava/lang/Double;)V + public fun gauge (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;)V + public fun gauge (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;Lio/sentry/metrics/SentryMetricsParameters;)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/MetricsUnit { +} + +public final class io/sentry/metrics/MetricsUnit$Duration { + public static final field DAY Ljava/lang/String; + public static final field HOUR Ljava/lang/String; + public static final field MICROSECOND Ljava/lang/String; + public static final field MILLISECOND Ljava/lang/String; + public static final field MINUTE Ljava/lang/String; + public static final field NANOSECOND Ljava/lang/String; + public static final field SECOND Ljava/lang/String; + public static final field WEEK Ljava/lang/String; +} + +public final class io/sentry/metrics/MetricsUnit$Fraction { + public static final field PERCENT Ljava/lang/String; + public static final field RATIO Ljava/lang/String; +} + +public final class io/sentry/metrics/MetricsUnit$Information { + public static final field BIT Ljava/lang/String; + public static final field BYTE Ljava/lang/String; + public static final field EXABYTE Ljava/lang/String; + public static final field EXBIBYTE Ljava/lang/String; + public static final field GIBIBYTE Ljava/lang/String; + public static final field GIGABYTE Ljava/lang/String; + public static final field KIBIBYTE Ljava/lang/String; + public static final field KILOBYTE Ljava/lang/String; + public static final field MEBIBYTE Ljava/lang/String; + public static final field MEGABYTE Ljava/lang/String; + public static final field PEBIBYTE Ljava/lang/String; + public static final field PETABYTE Ljava/lang/String; + public static final field TEBIBYTE Ljava/lang/String; + public static final field TERABYTE Ljava/lang/String; +} + +public final class io/sentry/metrics/NoOpMetricsApi : io/sentry/metrics/IMetricsApi { + public fun count (Ljava/lang/String;)V + public fun count (Ljava/lang/String;Ljava/lang/Double;)V + public fun count (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;)V + public fun count (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;Lio/sentry/metrics/SentryMetricsParameters;)V + public fun count (Ljava/lang/String;Ljava/lang/String;)V + public fun distribution (Ljava/lang/String;Ljava/lang/Double;)V + public fun distribution (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;)V + public fun distribution (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;Lio/sentry/metrics/SentryMetricsParameters;)V + public fun gauge (Ljava/lang/String;Ljava/lang/Double;)V + public fun gauge (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;)V + public fun gauge (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/String;Lio/sentry/metrics/SentryMetricsParameters;)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/metrics/SentryMetricsParameters { + public fun ()V + public static fun create (Lio/sentry/SentryAttributes;)Lio/sentry/metrics/SentryMetricsParameters; + public static fun create (Lio/sentry/SentryDate;Lio/sentry/SentryAttributes;)Lio/sentry/metrics/SentryMetricsParameters; + public static fun create (Ljava/util/Map;)Lio/sentry/metrics/SentryMetricsParameters; + public fun getAttributes ()Lio/sentry/SentryAttributes; + public fun getHint ()Lio/sentry/Hint; + public fun getOrigin ()Ljava/lang/String; + public fun getTimestamp ()Lio/sentry/SentryDate; + public fun setAttributes (Lio/sentry/SentryAttributes;)V + public fun setHint (Lio/sentry/Hint;)V + public fun setOrigin (Ljava/lang/String;)V + public fun setTimestamp (Lio/sentry/SentryDate;)V +} + 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/EventProcessor.java b/sentry/src/main/java/io/sentry/EventProcessor.java index b258132edf3..928fd022095 100644 --- a/sentry/src/main/java/io/sentry/EventProcessor.java +++ b/sentry/src/main/java/io/sentry/EventProcessor.java @@ -56,6 +56,17 @@ default SentryLogEvent process(@NotNull SentryLogEvent event) { return event; } + /** + * May mutate or drop a SentryMetricsEvent + * + * @param event the SentryMetricsEvent + * @return the event itself, a mutated SentryMetricsEvent or null + */ + @Nullable + default SentryMetricsEvent process(@NotNull SentryMetricsEvent event, @NotNull Hint hint) { + return event; + } + /** * Controls when this EventProcessor is invoked. * diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index f35e01af797..269e3729dbe 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -44,6 +44,7 @@ public final class ExternalOptions { private @Nullable Boolean enablePrettySerializationOutput; private @Nullable Boolean enableSpotlight; private @Nullable Boolean enableLogs; + private @Nullable Boolean enableMetrics; private @Nullable String spotlightConnectionUrl; private @Nullable List ignoredCheckIns; @@ -157,6 +158,8 @@ public final class ExternalOptions { options.setEnableLogs(propertiesProvider.getBooleanProperty("logs.enabled")); + options.setEnableMetrics(propertiesProvider.getBooleanProperty("metrics.enabled")); + for (final String ignoredExceptionType : propertiesProvider.getList("ignored-exceptions-for-type")) { try { @@ -539,6 +542,14 @@ public void setEnableLogs(final @Nullable Boolean enableLogs) { return enableLogs; } + public void setEnableMetrics(final @Nullable Boolean enableMetrics) { + this.enableMetrics = enableMetrics; + } + + public @Nullable Boolean isEnableMetrics() { + return enableMetrics; + } + public @Nullable Double getProfileSessionSampleRate() { return profileSessionSampleRate; } diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index 31a2e219cd0..5715d061144 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -1,6 +1,7 @@ package io.sentry; import io.sentry.logger.ILoggerApi; +import io.sentry.metrics.IMetricsApi; import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryTransaction; @@ -389,6 +390,11 @@ public void reportFullyDisplayed() { return Sentry.getCurrentScopes().logger(); } + @Override + public @NotNull IMetricsApi metrics() { + return Sentry.getCurrentScopes().metrics(); + } + @Override public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { Sentry.addFeatureFlag(flag, result); diff --git a/sentry/src/main/java/io/sentry/HubScopesWrapper.java b/sentry/src/main/java/io/sentry/HubScopesWrapper.java index d15ed72ee4b..c04aad9ed8c 100644 --- a/sentry/src/main/java/io/sentry/HubScopesWrapper.java +++ b/sentry/src/main/java/io/sentry/HubScopesWrapper.java @@ -1,6 +1,7 @@ package io.sentry; import io.sentry.logger.ILoggerApi; +import io.sentry.metrics.IMetricsApi; import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryTransaction; @@ -374,6 +375,11 @@ public void reportFullyDisplayed() { return scopes.logger(); } + @Override + public @NotNull IMetricsApi metrics() { + return scopes.metrics(); + } + @Override public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { scopes.addFeatureFlag(flag, result); diff --git a/sentry/src/main/java/io/sentry/IScopes.java b/sentry/src/main/java/io/sentry/IScopes.java index bf78d28ecd3..0a7c86fa8e6 100644 --- a/sentry/src/main/java/io/sentry/IScopes.java +++ b/sentry/src/main/java/io/sentry/IScopes.java @@ -1,6 +1,7 @@ package io.sentry; import io.sentry.logger.ILoggerApi; +import io.sentry.metrics.IMetricsApi; import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryTransaction; @@ -744,5 +745,8 @@ default boolean isNoOp() { @NotNull ILoggerApi logger(); + @NotNull + IMetricsApi metrics(); + void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result); } diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index c2bc05516f4..98b6034bb78 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -305,9 +305,15 @@ SentryId captureProfileChunk( void captureLog(@NotNull SentryLogEvent logEvent, @Nullable IScope scope); + void captureMetric( + @NotNull SentryMetricsEvent logEvent, @Nullable IScope scope, @Nullable Hint hint); + @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/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index c643fd2a696..a0fa80879aa 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -123,6 +123,7 @@ public JsonSerializer(@NotNull SentryOptions options) { deserializersByClass.put(SentryLevel.class, new SentryLevel.Deserializer()); deserializersByClass.put(SentryLockReason.class, new SentryLockReason.Deserializer()); deserializersByClass.put(SentryLogEvents.class, new SentryLogEvents.Deserializer()); + deserializersByClass.put(SentryMetricsEvents.class, new SentryMetricsEvents.Deserializer()); deserializersByClass.put(SentryPackage.class, new SentryPackage.Deserializer()); deserializersByClass.put(SentryRuntime.class, new SentryRuntime.Deserializer()); deserializersByClass.put(SentryReplayEvent.class, new SentryReplayEvent.Deserializer()); diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index 811e1d297a3..2885d8017d1 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -2,6 +2,8 @@ import io.sentry.logger.ILoggerApi; import io.sentry.logger.NoOpLoggerApi; +import io.sentry.metrics.IMetricsApi; +import io.sentry.metrics.NoOpMetricsApi; import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryTransaction; @@ -331,6 +333,11 @@ public boolean isNoOp() { return NoOpLoggerApi.getInstance(); } + @Override + public @NotNull IMetricsApi metrics() { + return NoOpMetricsApi.getInstance(); + } + @Override public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) {} } diff --git a/sentry/src/main/java/io/sentry/NoOpScopes.java b/sentry/src/main/java/io/sentry/NoOpScopes.java index 40777da892f..5abb20226ac 100644 --- a/sentry/src/main/java/io/sentry/NoOpScopes.java +++ b/sentry/src/main/java/io/sentry/NoOpScopes.java @@ -2,6 +2,8 @@ import io.sentry.logger.ILoggerApi; import io.sentry.logger.NoOpLoggerApi; +import io.sentry.metrics.IMetricsApi; +import io.sentry.metrics.NoOpMetricsApi; import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryTransaction; @@ -329,6 +331,11 @@ public boolean isNoOp() { return NoOpLoggerApi.getInstance(); } + @Override + public @NotNull IMetricsApi metrics() { + return NoOpMetricsApi.getInstance(); + } + @Override public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) {} } diff --git a/sentry/src/main/java/io/sentry/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java index 17e4becbc71..961ef9031be 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java @@ -88,12 +88,24 @@ public void captureLog(@NotNull SentryLogEvent logEvent, @Nullable IScope scope) // do nothing } + @Override + public void captureMetric( + @NotNull SentryMetricsEvent metricsEvent, @Nullable IScope scope, @Nullable Hint hint) { + // do nothing + } + @ApiStatus.Internal @Override 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/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index c8afde59cc1..374ecbfdb55 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -5,6 +5,8 @@ import io.sentry.hints.SessionStartHint; import io.sentry.logger.ILoggerApi; import io.sentry.logger.LoggerApi; +import io.sentry.metrics.IMetricsApi; +import io.sentry.metrics.MetricsApi; import io.sentry.protocol.*; import io.sentry.transport.RateLimiter; import io.sentry.util.HintUtils; @@ -31,6 +33,7 @@ public final class Scopes implements IScopes { private final @NotNull CombinedScopeView combinedScope; private final @NotNull ILoggerApi logger; + private final @NotNull IMetricsApi metrics; public Scopes( final @NotNull IScope scope, @@ -57,6 +60,7 @@ private Scopes( validateOptions(options); this.compositePerformanceCollector = options.getCompositePerformanceCollector(); this.logger = new LoggerApi(this); + this.metrics = new MetricsApi(this); } public @NotNull String getCreator() { @@ -1220,6 +1224,11 @@ public void reportFullyDisplayed() { return logger; } + @Override + public @NotNull IMetricsApi metrics() { + return metrics; + } + @Override public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { combinedScope.addFeatureFlag(flag, result); diff --git a/sentry/src/main/java/io/sentry/ScopesAdapter.java b/sentry/src/main/java/io/sentry/ScopesAdapter.java index 99e70694ee0..ba7e74d23bb 100644 --- a/sentry/src/main/java/io/sentry/ScopesAdapter.java +++ b/sentry/src/main/java/io/sentry/ScopesAdapter.java @@ -1,6 +1,7 @@ package io.sentry; import io.sentry.logger.ILoggerApi; +import io.sentry.metrics.IMetricsApi; import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryTransaction; @@ -386,6 +387,11 @@ public void reportFullyDisplayed() { return Sentry.getCurrentScopes().logger(); } + @Override + public @NotNull IMetricsApi metrics() { + return Sentry.getCurrentScopes().metrics(); + } + @Override public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { Sentry.addFeatureFlag(flag, result); diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index f726c1a602c..178f97a3b30 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -14,6 +14,7 @@ import io.sentry.internal.modules.NoOpModulesLoader; import io.sentry.internal.modules.ResourcesModulesLoader; import io.sentry.logger.ILoggerApi; +import io.sentry.metrics.IMetricsApi; import io.sentry.opentelemetry.OpenTelemetryUtil; import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; @@ -1344,6 +1345,11 @@ public static IDistributionApi distribution() { return getCurrentScopes().getScope().getOptions().getDistributionController(); } + @NotNull + public static IMetricsApi metrics() { + return getCurrentScopes().metrics(); + } + public static void showUserFeedbackDialog() { showUserFeedbackDialog(null); } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 0a767ed1b8e..26c70f365f5 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -10,6 +10,8 @@ import io.sentry.hints.TransactionEnd; import io.sentry.logger.ILoggerBatchProcessor; import io.sentry.logger.NoOpLoggerBatchProcessor; +import io.sentry.metrics.IMetricsBatchProcessor; +import io.sentry.metrics.NoOpMetricsBatchProcessor; import io.sentry.protocol.Contexts; import io.sentry.protocol.DebugMeta; import io.sentry.protocol.FeatureFlags; @@ -42,6 +44,7 @@ public final class SentryClient implements ISentryClient { private final @NotNull ITransport transport; private final @NotNull SortBreadcrumbsByDate sortBreadcrumbsByDate = new SortBreadcrumbsByDate(); private final @NotNull ILoggerBatchProcessor loggerBatchProcessor; + private final @NotNull IMetricsBatchProcessor metricsBatchProcessor; @Override public boolean isEnabled() { @@ -66,6 +69,12 @@ public SentryClient(final @NotNull SentryOptions options) { } else { loggerBatchProcessor = NoOpLoggerBatchProcessor.getInstance(); } + if (options.getMetrics().isEnabled()) { + metricsBatchProcessor = + options.getMetrics().getMetricsBatchProcessorFactory().create(options, this); + } else { + metricsBatchProcessor = NoOpMetricsBatchProcessor.getInstance(); + } } private boolean shouldApplyScopeData( @@ -506,6 +515,40 @@ private SentryLogEvent processLogEvent( return event; } + @Nullable + private SentryMetricsEvent processMetricsEvent( + @NotNull SentryMetricsEvent event, + final @NotNull List eventProcessors, + final @NotNull Hint hint) { + for (final EventProcessor processor : eventProcessors) { + try { + event = processor.process(event, hint); + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.ERROR, + e, + "An exception occurred while processing metrics event by processor: %s", + processor.getClass().getName()); + } + + if (event == null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Metrics event was dropped by a processor: %s", + processor.getClass().getName()); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.TraceMetric); + break; + } + } + return event; + } + private @Nullable SentryTransaction processTransaction( @NotNull SentryTransaction transaction, final @NotNull Hint hint, @@ -693,6 +736,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 +1274,59 @@ 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.Experimental + @Override + public void captureMetric( + @Nullable SentryMetricsEvent metricsEvent, + final @Nullable IScope scope, + @Nullable Hint hint) { + if (hint == null) { + hint = new Hint(); + } + + if (metricsEvent != null && scope != null) { + metricsEvent = processMetricsEvent(metricsEvent, scope.getEventProcessors(), hint); + if (metricsEvent == null) { + return; + } + } + + if (metricsEvent != null) { + metricsEvent = processMetricsEvent(metricsEvent, options.getEventProcessors(), hint); + if (metricsEvent == null) { + return; + } + } + + if (metricsEvent != null) { + metricsEvent = executeBeforeSendMetric(metricsEvent, hint); + + if (metricsEvent == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Metrics Event was dropped by beforeSendMetrics"); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.TraceMetric); + return; + } + + metricsBatchProcessor.add(metricsEvent); + } + } + + @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."); } } @@ -1526,6 +1634,28 @@ private void sortBreadcrumbsByDate( return event; } + private @Nullable SentryMetricsEvent executeBeforeSendMetric( + @NotNull SentryMetricsEvent event, final @NotNull Hint hint) { + final SentryOptions.Metrics.BeforeSendMetricCallback beforeSendMetric = + options.getMetrics().getBeforeSend(); + if (beforeSendMetric != null) { + try { + event = beforeSendMetric.execute(event, hint); + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.ERROR, + "The BeforeSendMetric callback threw an exception. Dropping metrics event.", + e); + + // drop event in case of an error in beforeSendMetric due to PII concerns + event = null; + } + } + return event; + } + @Override public void close() { close(false); @@ -1537,6 +1667,7 @@ public void close(final boolean isRestarting) { try { flush(isRestarting ? 0 : options.getShutdownTimeoutMillis()); loggerBatchProcessor.close(isRestarting); + metricsBatchProcessor.close(isRestarting); transport.close(isRestarting); } catch (IOException e) { options @@ -1564,6 +1695,7 @@ public void close(final boolean isRestarting) { @Override public void flush(final long timeoutMillis) { loggerBatchProcessor.flush(timeoutMillis); + metricsBatchProcessor.flush(timeoutMillis); transport.flush(timeoutMillis); } diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 04bf74fcfe8..58d150886d3 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -158,6 +158,17 @@ public final class SentryEnvelopeItem { } } + public @Nullable SentryMetricsEvents getMetrics(final @NotNull ISerializer serializer) + throws Exception { + if (header == null || header.getType() != SentryItemType.TraceMetric) { + return null; + } + try (final Reader eventReader = + new BufferedReader(new InputStreamReader(new ByteArrayInputStream(getData()), UTF_8))) { + return serializer.deserialize(eventReader, SentryMetricsEvents.class); + } + } + public static SentryEnvelopeItem fromUserFeedback( final @NotNull ISerializer serializer, final @NotNull UserFeedback userFeedback) { Objects.requireNonNull(serializer, "ISerializer is required."); @@ -546,6 +557,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/SentryLogEvent.java b/sentry/src/main/java/io/sentry/SentryLogEvent.java index 2afcf57a1df..441e1ef79a5 100644 --- a/sentry/src/main/java/io/sentry/SentryLogEvent.java +++ b/sentry/src/main/java/io/sentry/SentryLogEvent.java @@ -53,7 +53,7 @@ public void setTimestamp(final @NotNull Double timestamp) { return body; } - public void setBody(@NotNull String body) { + public void setBody(final @NotNull String body) { this.body = body; } @@ -88,7 +88,7 @@ public void setAttribute( return severityNumber; } - public void setSeverityNumber(@Nullable Integer severityNumber) { + public void setSeverityNumber(final @Nullable Integer severityNumber) { this.severityNumber = severityNumber; } @@ -120,7 +120,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (unknown != null) { for (String key : unknown.keySet()) { - Object value = unknown.get(key); + final Object value = unknown.get(key); writer.name(key).value(logger, value); } } @@ -185,29 +185,29 @@ public static final class Deserializer implements JsonDeserializer 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(final @NotNull String name) { + this.name = name; + } + + public @NotNull String getType() { + return type; + } + + public void setType(final @NotNull String type) { + this.type = type; + } + + public @Nullable String getUnit() { + return unit; + } + + public void setUnit(final @Nullable String unit) { + this.unit = unit; + } + + public @Nullable SpanId getSpanId() { + return spanId; + } + + public void setSpanId(final @Nullable SpanId spanId) { + this.spanId = spanId; + } + + public @NotNull SentryId getTraceId() { + return traceId; + } + + public void setTraceId(final @NotNull SentryId traceId) { + this.traceId = traceId; + } + + public @NotNull Double getValue() { + return value; + } + + public void setValue(final @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) { + final String message = "Missing required field \"" + JsonKeys.TRACE_ID + "\""; + final Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + if (timestamp == null) { + final String message = "Missing required field \"" + JsonKeys.TIMESTAMP + "\""; + final Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + if (type == null) { + final String message = "Missing required field \"" + JsonKeys.TYPE + "\""; + final Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + if (name == null) { + final String message = "Missing required field \"" + JsonKeys.NAME + "\""; + final Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + + if (value == null) { + final String message = "Missing required field \"" + JsonKeys.VALUE + "\""; + final 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 00000000000..50789fbd9ce --- /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) { + final String message = "Missing required field \"" + JsonKeys.ITEMS + "\""; + final 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 835411cb61a..f24f649092d 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -17,6 +17,8 @@ import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; import io.sentry.logger.DefaultLoggerBatchProcessorFactory; import io.sentry.logger.ILoggerBatchProcessorFactory; +import io.sentry.metrics.DefaultMetricsBatchProcessorFactory; +import io.sentry.metrics.IMetricsBatchProcessorFactory; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryTransaction; import io.sentry.transport.ITransport; @@ -624,6 +626,8 @@ public class SentryOptions { private @NotNull SentryOptions.Logs logs = new SentryOptions.Logs(); + private @NotNull SentryOptions.Metrics metrics = new SentryOptions.Metrics(); + private @NotNull ISocketTagger socketTagger = NoOpSocketTagger.getInstance(); /** Runtime manager to manage runtime policies, like StrictMode on Android. */ @@ -3261,20 +3265,6 @@ public interface BeforeEnvelopeCallback { void execute(@NotNull SentryEnvelope envelope, @Nullable Hint hint); } - /** The BeforeEmitMetric callback */ - @ApiStatus.Experimental - public interface BeforeEmitMetricCallback { - - /** - * A callback which gets called right before a metric is about to be emitted. - * - * @param key the metric key - * @param tags the metric tags - * @return true if the metric should be emitted, false otherwise - */ - boolean execute(@NotNull String key, @Nullable Map tags); - } - /** * Creates SentryOptions instance without initializing any of the internal parts. * @@ -3490,6 +3480,10 @@ public void merge(final @NotNull ExternalOptions options) { getLogs().setEnabled(options.isEnableLogs()); } + if (options.isEnableMetrics() != null) { + getMetrics().setEnabled(options.isEnableMetrics()); + } + if (options.getProfileSessionSampleRate() != null) { setProfileSessionSampleRate(options.getProfileSessionSampleRate()); } @@ -3537,6 +3531,14 @@ public void setLogs(@NotNull SentryOptions.Logs logs) { this.logs = logs; } + public @NotNull SentryOptions.Metrics getMetrics() { + return metrics; + } + + public void setMetrics(@NotNull SentryOptions.Metrics metrics) { + this.metrics = metrics; + } + public static final class Proxy { private @Nullable String host; private @Nullable String port; @@ -3741,6 +3743,81 @@ public interface BeforeSendLogCallback { } } + public static final class Metrics { + + /** Whether Sentry Metrics feature is enabled and metrics are sent to Sentry. */ + private boolean enable = true; + + /** + * This function is called with a metric key and tags and can return false to skip sending the + * metric + */ + private @Nullable BeforeSendMetricCallback beforeSend; + + private @NotNull IMetricsBatchProcessorFactory metricsBatchProcessorFactory = + new DefaultMetricsBatchProcessorFactory(); + + /** + * Whether Sentry Metrics feature is enabled and metrics are sent to Sentry. + * + * @return true if Sentry Metrics should be enabled + */ + public boolean isEnabled() { + return enable; + } + + /** + * Whether Sentry Metrics feature is enabled and metrics are sent to Sentry. + * + * @param enableMetrics true if Sentry Metrics should be enabled + */ + public void setEnabled(final boolean enableMetrics) { + this.enable = enableMetrics; + } + + /** + * Returns the BeforeSendMetric callback + * + * @return the beforeSend callback or null if not set + */ + public @Nullable BeforeSendMetricCallback getBeforeSend() { + return beforeSend; + } + + /** + * Sets the beforeSend callback for metrics + * + * @param beforeSend the beforeSend callback for metrics + */ + public void setBeforeSend(final @Nullable BeforeSendMetricCallback beforeSend) { + this.beforeSend = beforeSend; + } + + @ApiStatus.Internal + public @NotNull IMetricsBatchProcessorFactory getMetricsBatchProcessorFactory() { + return metricsBatchProcessorFactory; + } + + @ApiStatus.Internal + public void setMetricsBatchProcessorFactory( + final @NotNull IMetricsBatchProcessorFactory metricsBatchProcessorFactory) { + this.metricsBatchProcessorFactory = metricsBatchProcessorFactory; + } + + public interface BeforeSendMetricCallback { + + /** + * A callback which gets called right before a metric is about to be sent. + * + * @param metric the metric + * @return the original metric, mutated metric or null if metric was dropped + */ + @Nullable + SentryMetricsEvent execute( + final @NotNull SentryMetricsEvent metric, final @NotNull Hint hint); + } + } + @ApiStatus.Experimental public @NotNull DistributionOptions getDistribution() { return distribution; diff --git a/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java b/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java index eccf311cf83..0180b16977d 100644 --- a/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java +++ b/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java @@ -8,6 +8,8 @@ import io.sentry.SentryLevel; import io.sentry.SentryLogEvent; import io.sentry.SentryLogEvents; +import io.sentry.SentryMetricsEvent; +import io.sentry.SentryMetricsEvents; import io.sentry.SentryOptions; import io.sentry.protocol.SentrySpan; import io.sentry.protocol.SentryTransaction; @@ -115,6 +117,19 @@ public void recordLostEnvelopeItem( } else { options.getLogger().log(SentryLevel.ERROR, "Unable to parse lost logs envelope item."); } + } else if (itemCategory.equals(DataCategory.TraceMetric)) { + final @Nullable SentryMetricsEvents metrics = + envelopeItem.getMetrics(options.getSerializer()); + if (metrics != null) { + final @NotNull List items = metrics.getItems(); + final long count = items.size(); + recordLostEventInternal(reason.getReason(), itemCategory.getCategory(), count); + executeOnDiscard(reason, itemCategory, count); + } else { + options + .getLogger() + .log(SentryLevel.ERROR, "Unable to parse lost metrics envelope item."); + } } else { recordLostEventInternal(reason.getReason(), itemCategory.getCategory(), 1L); executeOnDiscard(reason, itemCategory, 1L); diff --git a/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java b/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java index cdea169b925..d4bb7e9afbd 100644 --- a/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java +++ b/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java @@ -36,8 +36,7 @@ public class LoggerBatchProcessor implements ILoggerBatchProcessor { private final @NotNull Queue queue; private final @NotNull ISentryExecutorService executorService; private volatile @Nullable Future scheduledFlush; - private static final @NotNull AutoClosableReentrantLock scheduleLock = - new AutoClosableReentrantLock(); + private final @NotNull AutoClosableReentrantLock scheduleLock = new AutoClosableReentrantLock(); private volatile boolean hasScheduled = false; private final @NotNull ReusableCountLatch pendingCount = new ReusableCountLatch(); 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 00000000000..1d023a7707b --- /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( + final @NotNull SentryOptions options, final @NotNull SentryClient client) { + return new MetricsBatchProcessor(options, client); + } +} diff --git a/sentry/src/main/java/io/sentry/metrics/IMetricsApi.java b/sentry/src/main/java/io/sentry/metrics/IMetricsApi.java new file mode 100644 index 00000000000..e03b41e1733 --- /dev/null +++ b/sentry/src/main/java/io/sentry/metrics/IMetricsApi.java @@ -0,0 +1,42 @@ +package io.sentry.metrics; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public interface IMetricsApi { + + void count(final @NotNull String name); + + void count(final @NotNull String name, final @Nullable Double value); + + void count(final @NotNull String name, final @Nullable String unit); + + void count(final @NotNull String name, final @Nullable Double value, final @Nullable String unit); + + void count( + final @NotNull String name, + final @Nullable Double value, + final @Nullable String unit, + final @NotNull SentryMetricsParameters params); + + void distribution(final @NotNull String name, final @Nullable Double value); + + void distribution( + final @NotNull String name, final @Nullable Double value, final @Nullable String unit); + + void distribution( + final @NotNull String name, + final @Nullable Double value, + final @Nullable String unit, + final @NotNull SentryMetricsParameters params); + + void gauge(final @NotNull String name, final @Nullable Double value); + + void gauge(final @NotNull String name, final @Nullable Double value, final @Nullable String unit); + + void gauge( + final @NotNull String name, + final @Nullable Double value, + final @Nullable String unit, + final @NotNull SentryMetricsParameters params); +} 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 00000000000..b71b5429ad3 --- /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 metrics. + * + * @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 00000000000..a909512864b --- /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/MetricsApi.java b/sentry/src/main/java/io/sentry/metrics/MetricsApi.java new file mode 100644 index 00000000000..08a15100495 --- /dev/null +++ b/sentry/src/main/java/io/sentry/metrics/MetricsApi.java @@ -0,0 +1,297 @@ +package io.sentry.metrics; + +import io.sentry.HostnameCache; +import io.sentry.IScope; +import io.sentry.ISpan; +import io.sentry.PropagationContext; +import io.sentry.Scopes; +import io.sentry.SentryAttribute; +import io.sentry.SentryAttributeType; +import io.sentry.SentryAttributes; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SentryLogEventAttributeValue; +import io.sentry.SentryMetricsEvent; +import io.sentry.SentryOptions; +import io.sentry.SpanId; +import io.sentry.protocol.SdkVersion; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.User; +import io.sentry.util.Platform; +import io.sentry.util.TracingUtils; +import java.util.HashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class MetricsApi implements IMetricsApi { + + private final @NotNull Scopes scopes; + + public MetricsApi(final @NotNull Scopes scopes) { + this.scopes = scopes; + } + + @Override + public void count(final @NotNull String name) { + captureMetrics(SentryMetricsParameters.create(null, null), name, "counter", 1.0, null); + } + + @Override + public void count(final @NotNull String name, final @Nullable Double value) { + captureMetrics(SentryMetricsParameters.create(null, null), name, "counter", value, null); + } + + @Override + public void count(final @NotNull String name, final @Nullable String unit) { + captureMetrics(SentryMetricsParameters.create(null, null), name, "counter", 1.0, unit); + } + + @Override + public void count( + final @NotNull String name, final @Nullable Double value, final @Nullable String unit) { + captureMetrics(SentryMetricsParameters.create(null, null), name, "counter", value, unit); + } + + @Override + public void count( + final @NotNull String name, + final @Nullable Double value, + final @Nullable String unit, + final @NotNull SentryMetricsParameters params) { + captureMetrics(params, name, "counter", value, unit); + } + + @Override + public void distribution(final @NotNull String name, final @Nullable Double value) { + captureMetrics(SentryMetricsParameters.create(null, null), name, "distribution", value, null); + } + + @Override + public void distribution( + final @NotNull String name, final @Nullable Double value, final @Nullable String unit) { + captureMetrics(SentryMetricsParameters.create(null, null), name, "distribution", value, unit); + } + + @Override + public void distribution( + final @NotNull String name, + final @Nullable Double value, + final @Nullable String unit, + final @NotNull SentryMetricsParameters params) { + captureMetrics(params, name, "distribution", value, unit); + } + + @Override + public void gauge(final @NotNull String name, final @Nullable Double value) { + captureMetrics(SentryMetricsParameters.create(null, null), name, "gauge", value, null); + } + + @Override + public void gauge( + final @NotNull String name, final @Nullable Double value, final @Nullable String unit) { + captureMetrics(SentryMetricsParameters.create(null, null), name, "gauge", value, unit); + } + + @Override + public void gauge( + final @NotNull String name, + final @Nullable Double value, + final @Nullable String unit, + final @NotNull SentryMetricsParameters params) { + captureMetrics(params, name, "gauge", value, unit); + } + + @SuppressWarnings("AnnotateFormatMethod") + private void captureMetrics( + final @NotNull SentryMetricsParameters params, + final @Nullable String name, + final @Nullable String type, + final @Nullable Double value, + final @Nullable String unit) { + final @NotNull SentryOptions options = scopes.getOptions(); + try { + if (!scopes.isEnabled()) { + options + .getLogger() + .log(SentryLevel.WARNING, "Instance is disabled and this 'metrics' call is a no-op."); + return; + } + + if (!options.getMetrics().isEnabled()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Sentry Metrics is disabled and this 'metrics' call is a no-op."); + return; + } + + if (name == null) { + return; + } + + if (type == null) { + return; + } + + if (value == null) { + return; + } + + final @Nullable SentryDate timestamp = params.getTimestamp(); + final @NotNull SentryDate timestampToUse = + timestamp == null ? options.getDateProvider().now() : timestamp; + + final @NotNull IScope combinedScope = scopes.getCombinedScopeView(); + final @NotNull PropagationContext propagationContext = combinedScope.getPropagationContext(); + final @Nullable ISpan span = combinedScope.getSpan(); + if (span == null) { + TracingUtils.maybeUpdateBaggage(combinedScope, options); + } + final @NotNull SentryId traceId = + span == null ? propagationContext.getTraceId() : span.getSpanContext().getTraceId(); + final @NotNull SpanId spanId = + span == null ? propagationContext.getSpanId() : span.getSpanContext().getSpanId(); + final SentryMetricsEvent metricsEvent = + new SentryMetricsEvent(traceId, timestampToUse, name, type, value); + metricsEvent.setSpanId(spanId); + metricsEvent.setUnit(unit); + metricsEvent.setAttributes(createAttributes(params)); + + scopes.getClient().captureMetric(metricsEvent, combinedScope, params.getHint()); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error while capturing metrics event", e); + } + } + + private @NotNull HashMap createAttributes( + final @NotNull SentryMetricsParameters params) { + final @NotNull HashMap attributes = new HashMap<>(); + final @NotNull String origin = params.getOrigin(); + if (!"manual".equalsIgnoreCase(origin)) { + attributes.put( + "sentry.origin", new SentryLogEventAttributeValue(SentryAttributeType.STRING, origin)); + } + + final @Nullable SentryAttributes incomingAttributes = params.getAttributes(); + + if (incomingAttributes != null) { + for (SentryAttribute attribute : incomingAttributes.getAttributes().values()) { + final @Nullable Object value = attribute.getValue(); + final @NotNull SentryAttributeType type = + attribute.getType() == null ? getType(value) : attribute.getType(); + attributes.put(attribute.getName(), new SentryLogEventAttributeValue(type, value)); + } + } + + final @Nullable SdkVersion sdkVersion = scopes.getOptions().getSdkVersion(); + if (sdkVersion != null) { + attributes.put( + "sentry.sdk.name", + new SentryLogEventAttributeValue(SentryAttributeType.STRING, sdkVersion.getName())); + attributes.put( + "sentry.sdk.version", + new SentryLogEventAttributeValue(SentryAttributeType.STRING, sdkVersion.getVersion())); + } + + final @Nullable String environment = scopes.getOptions().getEnvironment(); + if (environment != null) { + attributes.put( + "sentry.environment", + new SentryLogEventAttributeValue(SentryAttributeType.STRING, environment)); + } + + final @NotNull SentryId scopeReplayId = scopes.getCombinedScopeView().getReplayId(); + if (!SentryId.EMPTY_ID.equals(scopeReplayId)) { + attributes.put( + "sentry.replay_id", + new SentryLogEventAttributeValue(SentryAttributeType.STRING, scopeReplayId.toString())); + } else { + final @NotNull SentryId controllerReplayId = + scopes.getOptions().getReplayController().getReplayId(); + if (!SentryId.EMPTY_ID.equals(controllerReplayId)) { + attributes.put( + "sentry.replay_id", + new SentryLogEventAttributeValue( + SentryAttributeType.STRING, controllerReplayId.toString())); + attributes.put( + "sentry._internal.replay_is_buffering", + new SentryLogEventAttributeValue(SentryAttributeType.BOOLEAN, true)); + } + } + + final @Nullable String release = scopes.getOptions().getRelease(); + if (release != null) { + attributes.put( + "sentry.release", new SentryLogEventAttributeValue(SentryAttributeType.STRING, release)); + } + + if (Platform.isJvm()) { + setServerName(attributes); + } + + if (scopes.getOptions().isSendDefaultPii()) { + setUser(attributes); + } + + return attributes; + } + + private void setServerName( + final @NotNull HashMap attributes) { + final @NotNull SentryOptions options = scopes.getOptions(); + final @Nullable String optionsServerName = options.getServerName(); + if (optionsServerName != null) { + attributes.put( + "server.address", + new SentryLogEventAttributeValue(SentryAttributeType.STRING, optionsServerName)); + } else if (options.isAttachServerName()) { + final @Nullable String hostname = HostnameCache.getInstance().getHostname(); + if (hostname != null) { + attributes.put( + "server.address", + new SentryLogEventAttributeValue(SentryAttributeType.STRING, hostname)); + } + } + } + + private void setUser(final @NotNull HashMap attributes) { + final @Nullable User user = scopes.getCombinedScopeView().getUser(); + if (user == null) { + // In case no user is set, we should fallback to the distinct id, known as installation id, + // which is used on Android as default user id + final @Nullable String id = scopes.getOptions().getDistinctId(); + if (id != null) { + attributes.put("user.id", new SentryLogEventAttributeValue(SentryAttributeType.STRING, id)); + } + } else { + final @Nullable String id = user.getId(); + if (id != null) { + attributes.put("user.id", new SentryLogEventAttributeValue(SentryAttributeType.STRING, id)); + } + final @Nullable String username = user.getUsername(); + if (username != null) { + attributes.put( + "user.name", new SentryLogEventAttributeValue(SentryAttributeType.STRING, username)); + } + final @Nullable String email = user.getEmail(); + if (email != null) { + attributes.put( + "user.email", new SentryLogEventAttributeValue(SentryAttributeType.STRING, email)); + } + } + } + + private @NotNull SentryAttributeType getType(final @Nullable Object arg) { + if (arg instanceof Boolean) { + return SentryAttributeType.BOOLEAN; + } + if (arg instanceof Integer) { + return SentryAttributeType.INTEGER; + } + if (arg instanceof Number) { + return SentryAttributeType.DOUBLE; + } + return SentryAttributeType.STRING; + } +} 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 00000000000..169f36b973b --- /dev/null +++ b/sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java @@ -0,0 +1,159 @@ +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 final @NotNull AutoClosableReentrantLock scheduleLock = new AutoClosableReentrantLock(); + private volatile boolean hasScheduled = false; + private volatile boolean isShuttingDown = 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 (isShuttingDown) { + return; + } + 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) { + isShuttingDown = true; + 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/MetricsUnit.java b/sentry/src/main/java/io/sentry/metrics/MetricsUnit.java new file mode 100644 index 00000000000..4665cdf33a2 --- /dev/null +++ b/sentry/src/main/java/io/sentry/metrics/MetricsUnit.java @@ -0,0 +1,98 @@ +package io.sentry.metrics; + +/** + * String constants for metric units. + * + *

These constants represent the API names of measurement units that can be used with metrics. + */ +public final class MetricsUnit { + + /** Time duration units. */ + public static final class Duration { + /** Nanosecond, 10^-9 seconds. */ + public static final String NANOSECOND = "nanosecond"; + + /** Microsecond, 10^-6 seconds. */ + public static final String MICROSECOND = "microsecond"; + + /** Millisecond, 10^-3 seconds. */ + public static final String MILLISECOND = "millisecond"; + + /** Full second. */ + public static final String SECOND = "second"; + + /** Minute, 60 seconds. */ + public static final String MINUTE = "minute"; + + /** Hour, 3600 seconds. */ + public static final String HOUR = "hour"; + + /** Day, 86,400 seconds. */ + public static final String DAY = "day"; + + /** Week, 604,800 seconds. */ + public static final String WEEK = "week"; + + private Duration() {} + } + + /** Size of information derived from bytes. */ + public static final class Information { + /** Bit, corresponding to 1/8 of a byte. */ + public static final String BIT = "bit"; + + /** Byte. */ + public static final String BYTE = "byte"; + + /** Kilobyte, 10^3 bytes. */ + public static final String KILOBYTE = "kilobyte"; + + /** Kibibyte, 2^10 bytes. */ + public static final String KIBIBYTE = "kibibyte"; + + /** Megabyte, 10^6 bytes. */ + public static final String MEGABYTE = "megabyte"; + + /** Mebibyte, 2^20 bytes. */ + public static final String MEBIBYTE = "mebibyte"; + + /** Gigabyte, 10^9 bytes. */ + public static final String GIGABYTE = "gigabyte"; + + /** Gibibyte, 2^30 bytes. */ + public static final String GIBIBYTE = "gibibyte"; + + /** Terabyte, 10^12 bytes. */ + public static final String TERABYTE = "terabyte"; + + /** Tebibyte, 2^40 bytes. */ + public static final String TEBIBYTE = "tebibyte"; + + /** Petabyte, 10^15 bytes. */ + public static final String PETABYTE = "petabyte"; + + /** Pebibyte, 2^50 bytes. */ + public static final String PEBIBYTE = "pebibyte"; + + /** Exabyte, 10^18 bytes. */ + public static final String EXABYTE = "exabyte"; + + /** Exbibyte, 2^60 bytes. */ + public static final String EXBIBYTE = "exbibyte"; + + private Information() {} + } + + /** Fractions such as percentages. */ + public static final class Fraction { + /** Floating point fraction of `1`. */ + public static final String RATIO = "ratio"; + + /** Ratio expressed as a fraction of `100`. `100%` equals a ratio of `1.0`. */ + public static final String PERCENT = "percent"; + + private Fraction() {} + } + + private MetricsUnit() {} +} diff --git a/sentry/src/main/java/io/sentry/metrics/NoOpMetricsApi.java b/sentry/src/main/java/io/sentry/metrics/NoOpMetricsApi.java new file mode 100644 index 00000000000..7fe85145d49 --- /dev/null +++ b/sentry/src/main/java/io/sentry/metrics/NoOpMetricsApi.java @@ -0,0 +1,62 @@ +package io.sentry.metrics; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class NoOpMetricsApi implements IMetricsApi { + private static final NoOpMetricsApi instance = new NoOpMetricsApi(); + + private NoOpMetricsApi() {} + + public static NoOpMetricsApi getInstance() { + return instance; + } + + @Override + public void count(final @NotNull String name) {} + + @Override + public void count(final @NotNull String name, final @Nullable Double value) {} + + @Override + public void count(final @NotNull String name, final @Nullable String unit) {} + + @Override + public void count( + final @NotNull String name, final @Nullable Double value, final @Nullable String unit) {} + + @Override + public void count( + final @NotNull String name, + final @Nullable Double value, + final @Nullable String unit, + final @NotNull SentryMetricsParameters params) {} + + @Override + public void distribution(final @NotNull String name, final @Nullable Double value) {} + + @Override + public void distribution( + final @NotNull String name, final @Nullable Double value, final @Nullable String unit) {} + + @Override + public void distribution( + final @NotNull String name, + final @Nullable Double value, + final @Nullable String unit, + final @NotNull SentryMetricsParameters params) {} + + @Override + public void gauge(final @NotNull String name, final @Nullable Double value) {} + + @Override + public void gauge( + final @NotNull String name, final @Nullable Double value, final @Nullable String unit) {} + + @Override + public void gauge( + final @NotNull String name, + final @Nullable Double value, + final @Nullable String unit, + final @NotNull SentryMetricsParameters params) {} +} 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 00000000000..3d963fb061b --- /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 + } +} diff --git a/sentry/src/main/java/io/sentry/metrics/SentryMetricsParameters.java b/sentry/src/main/java/io/sentry/metrics/SentryMetricsParameters.java new file mode 100644 index 00000000000..b52a7494976 --- /dev/null +++ b/sentry/src/main/java/io/sentry/metrics/SentryMetricsParameters.java @@ -0,0 +1,75 @@ +package io.sentry.metrics; + +import io.sentry.Hint; +import io.sentry.SentryAttributes; +import io.sentry.SentryDate; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentryMetricsParameters { + + private @Nullable SentryDate timestamp; + private @Nullable SentryAttributes attributes; + private @NotNull String origin = "manual"; + + private @Nullable Hint hint = null; + + public @Nullable SentryDate getTimestamp() { + return timestamp; + } + + public void setTimestamp(final @Nullable SentryDate timestamp) { + this.timestamp = timestamp; + } + + public @Nullable SentryAttributes getAttributes() { + return attributes; + } + + public void setAttributes(final @Nullable SentryAttributes attributes) { + this.attributes = attributes; + } + + public @NotNull String getOrigin() { + return origin; + } + + public void setOrigin(final @NotNull String origin) { + this.origin = origin; + } + + public @Nullable Hint getHint() { + return hint; + } + + public void setHint(final @Nullable Hint hint) { + this.hint = hint; + } + + public static @NotNull SentryMetricsParameters create( + final @Nullable SentryDate timestamp, final @Nullable SentryAttributes attributes) { + final @NotNull SentryMetricsParameters params = new SentryMetricsParameters(); + + params.setTimestamp(timestamp); + params.setAttributes(attributes); + + return params; + } + + public static @NotNull SentryMetricsParameters create( + final @Nullable SentryAttributes attributes) { + return create(null, attributes); + } + + /** + * A shortcut for SentryMetricsParameters.create(SentryAttributes.fromMap()) + * + * @param attributes a map of attributes + * @return parameters + */ + public static @NotNull SentryMetricsParameters create( + final @Nullable Map attributes) { + return create(null, SentryAttributes.fromMap(attributes)); + } +} diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index 9ed3913f715..78eef96dfce 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -376,6 +376,25 @@ class ExternalOptionsTest { withPropertiesFile("logs.enabled=true") { options -> assertTrue(options.isEnableLogs == true) } } + @Test + fun `creates options with enableMetrics set to true`() { + withPropertiesFile("metrics.enabled=true") { options -> + assertTrue(options.isEnableMetrics == true) + } + } + + @Test + fun `creates options with enableMetrics set to false`() { + withPropertiesFile("metrics.enabled=false") { options -> + assertTrue(options.isEnableMetrics == false) + } + } + + @Test + fun `creates options with enableMetrics set to null when not set`() { + withPropertiesFile { assertNull(it.isEnableMetrics) } + } + @Test fun `creates options with profileSessionSampleRate set to 0_8`() { withPropertiesFile("profile-session-sample-rate=0.8") { options -> diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index 4eee9d16367..81f639e8a1e 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -8,6 +8,8 @@ import io.sentry.clientreport.DiscardedEvent import io.sentry.hints.SessionEndHint import io.sentry.hints.SessionStartHint import io.sentry.logger.SentryLogParameters +import io.sentry.metrics.MetricsUnit +import io.sentry.metrics.SentryMetricsParameters import io.sentry.protocol.Feedback import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction @@ -3125,6 +3127,924 @@ 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(), 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(), 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(), + 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(), + 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(), + 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(), + 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, + MetricsUnit.Information.BYTE, + SentryMetricsParameters().also { it.origin = "other" }, + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals( + "other", + (it.attributes!!.get("sentry.origin") as? SentryLogEventAttributeValue)?.value, + ) + }, + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creating count metric with value and unit works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().count("metric name", 1.0, MetricsUnit.Information.BYTE) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals(MetricsUnit.Information.BYTE, it.unit) + assertEquals("counter", it.type) + }, + anyOrNull(), + 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(), + anyOrNull(), + ) + } + + @Test + fun `creating count metric with unit works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().count("metric name", MetricsUnit.Information.BYTE) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals(MetricsUnit.Information.BYTE, it.unit) + assertEquals("counter", it.type) + }, + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creating count metric with attributes from map works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .count( + "metric name", + 1.0, + MetricsUnit.Information.BYTE, + SentryMetricsParameters.create(SentryAttributes.fromMap(mapOf("attrname1" to "attrval1"))), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals(MetricsUnit.Information.BYTE, it.unit) + assertEquals("counter", it.type) + + val attr1 = it.attributes?.get("attrname1")!! + assertEquals("attrval1", attr1.value) + assertEquals("string", attr1.type) + }, + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creating count metric with attributes from map and shortcut factory method works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .count( + "metric name", + 1.0, + MetricsUnit.Information.BYTE, + SentryMetricsParameters.create(mapOf("attrname1" to "attrval1")), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals(MetricsUnit.Information.BYTE, it.unit) + assertEquals("counter", it.type) + + val attr1 = it.attributes?.get("attrname1")!! + assertEquals("attrval1", attr1.value) + assertEquals("string", attr1.type) + }, + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creating count metric with attributes works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .count( + "metric name", + 1.0, + MetricsUnit.Information.BYTE, + SentryMetricsParameters.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(MetricsUnit.Information.BYTE, 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(), + anyOrNull(), + ) + } + + @Test + fun `creating count metric with attributes and timestamp works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .count( + "metric name", + 1.0, + MetricsUnit.Information.BYTE, + SentryMetricsParameters.create( + SentryLongDate(123), + SentryAttributes.of(SentryAttribute.named("attrname1", "attrval1")), + ), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals(MetricsUnit.Information.BYTE, it.unit) + assertEquals("counter", it.type) + + val attr1 = it.attributes?.get("attrname1")!! + assertEquals("attrval1", attr1.value) + assertEquals("string", attr1.type) + }, + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creating distribution metric with value and unit works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().distribution("metric name", 1.0, MetricsUnit.Duration.MILLISECOND) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(1.0, it.value) + assertEquals(MetricsUnit.Duration.MILLISECOND, it.unit) + assertEquals("distribution", it.type) + }, + anyOrNull(), + 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(), + anyOrNull(), + ) + } + + @Test + fun `creating distribution metric with attributes from map works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .distribution( + "metric name", + 3.7, + MetricsUnit.Duration.MILLISECOND, + SentryMetricsParameters.create(SentryAttributes.fromMap(mapOf("attrname1" to "attrval1"))), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(3.7, it.value) + assertEquals(MetricsUnit.Duration.MILLISECOND, it.unit) + assertEquals("distribution", it.type) + + val attr1 = it.attributes?.get("attrname1")!! + assertEquals("attrval1", attr1.value) + assertEquals("string", attr1.type) + }, + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creating distribution metric with attributes works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .distribution( + "metric name", + 3.7, + MetricsUnit.Duration.MILLISECOND, + SentryMetricsParameters.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(MetricsUnit.Duration.MILLISECOND, 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(), + anyOrNull(), + ) + } + + @Test + fun `creating distribution metric with attributes and timestamp works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .distribution( + "metric name", + 3.7, + MetricsUnit.Duration.MILLISECOND, + SentryMetricsParameters.create( + SentryLongDate(123), + SentryAttributes.of(SentryAttribute.named("attrname1", "attrval1")), + ), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(3.7, it.value) + assertEquals(MetricsUnit.Duration.MILLISECOND, it.unit) + assertEquals("distribution", it.type) + + val attr1 = it.attributes?.get("attrname1")!! + assertEquals("attrval1", attr1.value) + assertEquals("string", attr1.type) + }, + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creating gauge metric with value and unit works`() { + val (sut, mockClient) = getEnabledScopes() + + sut.metrics().gauge("metric name", 128.0, MetricsUnit.Information.BYTE) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(128.0, it.value) + assertEquals(MetricsUnit.Information.BYTE, it.unit) + assertEquals("gauge", it.type) + }, + anyOrNull(), + 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(), + anyOrNull(), + ) + } + + @Test + fun `creating gauge metric with attributes from map works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .gauge( + "metric name", + 256.0, + MetricsUnit.Information.BYTE, + SentryMetricsParameters.create(SentryAttributes.fromMap(mapOf("attrname1" to "attrval1"))), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(256.0, it.value) + assertEquals(MetricsUnit.Information.BYTE, it.unit) + assertEquals("gauge", it.type) + + val attr1 = it.attributes?.get("attrname1")!! + assertEquals("attrval1", attr1.value) + assertEquals("string", attr1.type) + }, + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creating gauge metric with attributes works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .gauge( + "metric name", + 256.0, + MetricsUnit.Information.BYTE, + SentryMetricsParameters.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(MetricsUnit.Information.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(), + anyOrNull(), + ) + } + + @Test + fun `creating gauge metric with attributes and timestamp works`() { + val (sut, mockClient) = getEnabledScopes() + + sut + .metrics() + .gauge( + "metric name", + 256.0, + MetricsUnit.Information.BYTE, + SentryMetricsParameters.create( + SentryLongDate(123), + SentryAttributes.of(SentryAttribute.named("attrname1", "attrval1")), + ), + ) + + verify(mockClient) + .captureMetric( + check { + assertEquals("metric name", it.name) + assertEquals(256.0, it.value) + assertEquals(MetricsUnit.Information.BYTE, it.unit) + assertEquals("gauge", it.type) + + val attr1 = it.attributes?.get("attrname1")!! + assertEquals("attrval1", attr1.value) + assertEquals("string", attr1.type) + }, + anyOrNull(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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..e7c4ae84b99 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -14,6 +14,9 @@ import io.sentry.hints.Cached import io.sentry.hints.DiskFlushNotification import io.sentry.hints.TransactionEnd import io.sentry.logger.ILoggerBatchProcessor +import io.sentry.logger.ILoggerBatchProcessorFactory +import io.sentry.metrics.IMetricsBatchProcessor +import io.sentry.metrics.IMetricsBatchProcessorFactory import io.sentry.protocol.Contexts import io.sentry.protocol.Feedback import io.sentry.protocol.Mechanism @@ -77,6 +80,10 @@ class SentryClientTest { class Fixture { var transport = mock() var factory = mock() + var loggerBatchProcessor = mock() + var loggerBatchProcessorFactory = mock() + var metricsBatchProcessor = mock() + var metricsBatchProcessorFactory = mock() val maxAttachmentSize: Long = (5 * 1024 * 1024).toLong() val scopes = mock() val sentryTracer: SentryTracer @@ -93,12 +100,16 @@ class SentryClientTest { setLogger(mock()) maxAttachmentSize = this@Fixture.maxAttachmentSize setTransportFactory(factory) + logs.setLoggerBatchProcessorFactory(loggerBatchProcessorFactory) + metrics.setMetricsBatchProcessorFactory(metricsBatchProcessorFactory) release = "0.0.1" isTraceSampling = true } init { whenever(factory.create(any(), any())).thenReturn(transport) + whenever(loggerBatchProcessorFactory.create(any(), any())).thenReturn(loggerBatchProcessor) + whenever(metricsBatchProcessorFactory.create(any(), any())).thenReturn(metricsBatchProcessor) whenever(scopes.options).thenReturn(sentryOptions) sentryTracer = SentryTracer( @@ -167,21 +178,29 @@ class SentryClientTest { @Test fun `when client is closed with isRestarting false, transport waits`() { - val sut = fixture.getSut() + val sut = fixture.getSut { options -> options.logs.isEnabled = true } assertTrue(sut.isEnabled) sut.close(false) assertNotEquals(0, fixture.sentryOptions.shutdownTimeoutMillis) verify(fixture.transport).flush(eq(fixture.sentryOptions.shutdownTimeoutMillis)) + verify(fixture.loggerBatchProcessor).flush(eq(fixture.sentryOptions.shutdownTimeoutMillis)) + verify(fixture.metricsBatchProcessor).flush(eq(fixture.sentryOptions.shutdownTimeoutMillis)) verify(fixture.transport).close(eq(false)) + verify(fixture.loggerBatchProcessor).close(eq(false)) + verify(fixture.metricsBatchProcessor).close(eq(false)) } @Test fun `when client is closed with isRestarting true, transport does not wait`() { - val sut = fixture.getSut() + val sut = fixture.getSut { options -> options.logs.isEnabled = true } assertTrue(sut.isEnabled) sut.close(true) verify(fixture.transport).flush(eq(0)) + verify(fixture.loggerBatchProcessor).flush(eq(0)) + verify(fixture.metricsBatchProcessor).flush(eq(0)) verify(fixture.transport).close(eq(true)) + verify(fixture.loggerBatchProcessor).close(eq(true)) + verify(fixture.metricsBatchProcessor).close(eq(true)) } @Test @@ -338,6 +357,77 @@ class SentryClientTest { verifyNoMoreInteractions(batchProcessor) } + @Test + fun `when beforeSendMetric is set, callback is invoked`() { + val scope = createScope() + var invoked = false + fixture.sentryOptions.metrics.setBeforeSend { m, hint -> + invoked = true + m + } + val sut = fixture.getSut() + sut.captureMetric( + SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "name", "gauge", 123.0), + scope, + null, + ) + assertTrue(invoked) + } + + @Test + fun `when beforeSendMetric returns null, metric is dropped`() { + val scope = createScope() + fixture.sentryOptions.metrics.setBeforeSend { _: SentryMetricsEvent, hint -> null } + val sut = fixture.getSut() + sut.captureMetric( + SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "name", "gauge", 123.0), + scope, + null, + ) + 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 { _, hint -> throw exception } + val sut = fixture.getSut() + sut.captureMetric( + SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "name", "gauge", 123.0), + scope, + null, + ) + + 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 { _, hint -> 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, null) + 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/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index e882f6fdc6f..b2f7491b029 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -411,6 +411,7 @@ class SentryOptionsTest { externalOptions.spotlightConnectionUrl = "http://local.sentry.io:1234" externalOptions.isGlobalHubMode = true externalOptions.isEnableLogs = true + externalOptions.isEnableMetrics = false externalOptions.profileSessionSampleRate = 0.8 externalOptions.profilingTracesDirPath = "/profiling-traces" externalOptions.profileLifecycle = ProfileLifecycle.TRACE @@ -469,6 +470,7 @@ class SentryOptionsTest { assertEquals("http://local.sentry.io:1234", options.spotlightConnectionUrl) assertTrue(options.isGlobalHubMode!!) assertTrue(options.logs.isEnabled!!) + assertFalse(options.metrics.isEnabled) assertEquals(0.8, options.profileSessionSampleRate) assertEquals("/profiling-traces${File.separator}${hash}", options.profilingTracesDirPath) assertEquals(ProfileLifecycle.TRACE, options.profileLifecycle) @@ -482,6 +484,14 @@ class SentryOptionsTest { assertTrue(options.isEnableUncaughtExceptionHandler) } + @Test + fun `merging options when enableMetrics is not set preserves the default value`() { + val externalOptions = ExternalOptions() + val options = SentryOptions() + options.merge(externalOptions) + assertTrue(options.metrics.isEnabled) + } + @Test fun `merging options merges and overwrites existing tag values`() { val externalOptions = ExternalOptions() @@ -660,6 +670,11 @@ class SentryOptionsTest { assertTrue(SentryOptions().isEnableBackpressureHandling) } + @Test + fun `when options are initialized, metrics is enabled by default`() { + assertTrue(SentryOptions().metrics.isEnabled) + } + @Test fun `when options are initialized, enableSpotlight is set to false by default`() { assertFalse(SentryOptions().isEnableSpotlight) diff --git a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt index ae4a5f35362..95639d8c581 100644 --- a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt +++ b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt @@ -20,6 +20,8 @@ import io.sentry.SentryLogEvent import io.sentry.SentryLogEvents import io.sentry.SentryLogLevel import io.sentry.SentryLongDate +import io.sentry.SentryMetricsEvent +import io.sentry.SentryMetricsEvents import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryTracer @@ -382,6 +384,36 @@ class ClientReportTest { assertEquals(226, logByte.quantity) } + @Test + fun `recording lost client report counts metric entries`() { + val onDiscardMock = mock() + givenClientReportRecorder { options -> options.onDiscard = onDiscardMock } + + val envelope = + testHelper.newEnvelope( + SentryEnvelopeItem.fromMetrics( + opts.serializer, + SentryMetricsEvents( + listOf( + SentryMetricsEvent(SentryId(), SentryLongDate(1), "metric1", "counter", 1.0), + SentryMetricsEvent(SentryId(), SentryLongDate(2), "metric2", "gauge", 2.0), + SentryMetricsEvent(SentryId(), SentryLongDate(3), "metric3", "distribution", 3.0), + ) + ), + ) + ) + + clientReportRecorder.recordLostEnvelopeItem(DiscardReason.NETWORK_ERROR, envelope.items.first()) + + verify(onDiscardMock, times(1)) + .execute(DiscardReason.NETWORK_ERROR, DataCategory.TraceMetric, 3) + + val clientReport = clientReportRecorder.resetCountsAndGenerateClientReport() + val metricItem = + clientReport!!.discardedEvents!!.first { it.category == DataCategory.TraceMetric.category } + assertEquals(3, metricItem.quantity) + } + private fun givenClientReportRecorder( callback: Sentry.OptionsConfiguration? = null ) { 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 + } + } + } + ] +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000000..84b75276602 --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "javasdk" +version = "0.0.0" +source = { virtual = "." }