From a399ce5d53d1d06c5d99b5edaec7e6843eba601f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 10 Aug 2026 17:32:32 +0200 Subject: [PATCH 1/4] perf(android): Read cpu time via Process.getElapsedCpuTime (JAVA-690) AndroidCpuCollector.collect() runs 10 times per second for the whole duration of every transaction, and each sample read and parsed /proc/self/stat: the file reader buffers, the intermediate strings and a regex split of all 52 fields allocated roughly 25 kB per sample, on top of five syscalls, to obtain four numbers. Process.getElapsedCpuTime() is a @CriticalNative wrapper around clock_gettime(CLOCK_PROCESS_CPUTIME_ID) and returns the same quantity with no allocation, at millisecond rather than clock-tick resolution. It excludes the cpu time of reaped child processes, which an app process does not have. This also drops the Pattern.compile() that ran on the SentryAndroid.init path whether or not performance collection started. Measured on a Pixel 3 (Android 12): collect() drops from 33623 to 16 bytes allocated per call, the remainder being the Double boxing of the result. The reported percentages are unchanged for 1, 4 and 8 of 8 busy cores. Also seed lastRealtimeNanos in setup(), so the first sample is measured against the previous sample instead of against time since boot. Under full load that sample reported 0.0% before and 92.9% after. Co-Authored-By: Claude Opus 5 --- .../android/core/AndroidCpuCollector.java | 66 ++++--------------- 1 file changed, 12 insertions(+), 54 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java index ea7a20deab1..2d9a2682059 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java @@ -1,56 +1,43 @@ package io.sentry.android.core; +import android.os.Process; import android.os.SystemClock; import android.system.Os; import android.system.OsConstants; import io.sentry.ILogger; import io.sentry.IPerformanceSnapshotCollector; import io.sentry.PerformanceCollectionData; -import io.sentry.SentryLevel; -import io.sentry.util.FileUtils; import io.sentry.util.Objects; -import java.io.File; -import java.io.IOException; -import java.util.regex.Pattern; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -// The approach to get the cpu usage info was taken from -// https://eng.lyft.com/monitoring-cpu-performance-of-lyfts-android-applications-4e36fafffe12 -// The content of the /proc/self/stat file is specified in -// https://man7.org/linux/man-pages/man5/proc.5.html +// Process.getElapsedCpuTime() is a @CriticalNative wrapper around +// clock_gettime(CLOCK_PROCESS_CPUTIME_ID), i.e. the cpu time of all threads of this process. It +// replaces reading utime/stime out of /proc/self/stat, which allocated ~25 kB per sample (file +// reader buffers plus a regex split of all 52 fields) while collect() runs 10 times per second for +// the whole duration of a transaction. It does not include the cpu time of reaped child processes, +// which an app process doesn't have. @ApiStatus.Internal public final class AndroidCpuCollector implements IPerformanceSnapshotCollector { + private static final long NANOSECONDS_PER_MILLISECOND = 1_000_000; + private long lastRealtimeNanos = 0; private long lastCpuNanos = 0; - /** Number of clock ticks per second. */ - private long clockSpeedHz = 1; - private long numCores = 1; - private final long NANOSECOND_PER_SECOND = 1_000_000_000; - - /** Number of nanoseconds per clock tick. */ - private double nanosecondsPerClockTick = NANOSECOND_PER_SECOND / (double) clockSpeedHz; - /** File containing stats about this process. */ - private final @NotNull File selfStat = new File("/proc/self/stat"); - - private final @NotNull ILogger logger; private boolean isEnabled = false; - private final @NotNull Pattern newLinePattern = Pattern.compile("[\n\t\r ]"); public AndroidCpuCollector(final @NotNull ILogger logger) { - this.logger = Objects.requireNonNull(logger, "Logger is required."); + Objects.requireNonNull(logger, "Logger is required."); } @Override public void setup() { isEnabled = true; - clockSpeedHz = Os.sysconf(OsConstants._SC_CLK_TCK); numCores = Os.sysconf(OsConstants._SC_NPROCESSORS_CONF); - nanosecondsPerClockTick = NANOSECOND_PER_SECOND / (double) clockSpeedHz; + lastRealtimeNanos = SystemClock.elapsedRealtimeNanos(); lastCpuNanos = readTotalCpuNanos(); } @@ -74,36 +61,7 @@ public void collect(final @NotNull PerformanceCollectionData performanceCollecti (cpuUsagePercentage / (double) numCores) * 100.0); } - /** Read the /proc/self/stat file and parses the result. */ private long readTotalCpuNanos() { - String stat = null; - try { - stat = FileUtils.readText(selfStat); - } catch (IOException e) { - // If an error occurs when reading the file, we avoid reading it again until the setup method - // is called again - isEnabled = false; - logger.log( - SentryLevel.WARNING, "Unable to read /proc/self/stat file. Disabling cpu collection.", e); - } - if (stat != null) { - stat = stat.trim(); - String[] stats = newLinePattern.split(stat); - try { - // Amount of clock ticks this process has been scheduled in user mode - long uTime = Long.parseLong(stats[13]); - // Amount of clock ticks this process has been scheduled in kernel mode - long sTime = Long.parseLong(stats[14]); - // Amount of clock ticks this process' waited-for children has been scheduled in user mode - long cuTime = Long.parseLong(stats[15]); - // Amount of clock ticks this process' waited-for children has been scheduled in kernel mode - long csTime = Long.parseLong(stats[16]); - return (long) ((uTime + sTime + cuTime + csTime) * nanosecondsPerClockTick); - } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - logger.log(SentryLevel.ERROR, "Error parsing /proc/self/stat file.", e); - return 0; - } - } - return 0; + return Process.getElapsedCpuTime() * NANOSECONDS_PER_MILLISECOND; } } From 9eb06bd44dd219eaa7606f3240b41c6718cb6ddd Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 10 Aug 2026 17:33:48 +0200 Subject: [PATCH 2/4] changelog Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ef91d1cc7..4b34bf90ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ - Clear contexts when calling `Scope.clear()` ([#5902](https://github.com/getsentry/sentry-java/pull/5902)) - Preserve custom `Throwable` identities when R8 optimizes Android apps ([#5881](https://github.com/getsentry/sentry-java/pull/5881)) +- Report the correct cpu usage for the first performance sample of a transaction, which was measured against the time since device boot ([#5926](https://github.com/getsentry/sentry-java/pull/5926)) + +### Performance + +- Reduce allocations while collecting cpu usage during transactions by reading the process cpu time via `Process.getElapsedCpuTime()` instead of parsing `/proc/self/stat` (33.6kB to 16 bytes per sample on a Pixel 3) ([#5926](https://github.com/getsentry/sentry-java/pull/5926)) ### Dependencies From c81962a5a8aa7ddd47d98f4632fed6d44112f413 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 10 Aug 2026 17:38:27 +0200 Subject: [PATCH 3/4] ref(android): Restore the cpu collector attribution comment The percentage calculation still follows the linked article; only the source of the process cpu time changed. Drop the measured allocation sizes from the comment - they belong in the commit history, not next to code that no longer allocates. Co-Authored-By: Claude Opus 5 --- .../io/sentry/android/core/AndroidCpuCollector.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java index 2d9a2682059..915db6fe15c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java @@ -11,11 +11,12 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -// Process.getElapsedCpuTime() is a @CriticalNative wrapper around -// clock_gettime(CLOCK_PROCESS_CPUTIME_ID), i.e. the cpu time of all threads of this process. It -// replaces reading utime/stime out of /proc/self/stat, which allocated ~25 kB per sample (file -// reader buffers plus a regex split of all 52 fields) while collect() runs 10 times per second for -// the whole duration of a transaction. It does not include the cpu time of reaped child processes, +// The approach to get the cpu usage info was taken from +// https://eng.lyft.com/monitoring-cpu-performance-of-lyfts-android-applications-4e36fafffe12 +// The process cpu time itself comes from Process.getElapsedCpuTime(), a @CriticalNative wrapper +// around clock_gettime(CLOCK_PROCESS_CPUTIME_ID), rather than from parsing /proc/self/stat: reading +// and parsing that file allocated on every sample, and collect() runs 10 times per second for the +// whole duration of a transaction. It does not include the cpu time of reaped child processes, // which an app process doesn't have. @ApiStatus.Internal public final class AndroidCpuCollector implements IPerformanceSnapshotCollector { From acf7144d7309c595283c0c80269cbd78570983e2 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 11 Aug 2026 14:50:23 +0200 Subject: [PATCH 4/4] ref(android): Drop the stale cpu collector attribution comment The linked article describes reading and parsing /proc/self/stat, which this collector no longer does. Co-Authored-By: Claude Opus 5 --- .../io/sentry/android/core/AndroidCpuCollector.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java index 915db6fe15c..cb8e148b318 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java @@ -11,13 +11,11 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -// The approach to get the cpu usage info was taken from -// https://eng.lyft.com/monitoring-cpu-performance-of-lyfts-android-applications-4e36fafffe12 -// The process cpu time itself comes from Process.getElapsedCpuTime(), a @CriticalNative wrapper -// around clock_gettime(CLOCK_PROCESS_CPUTIME_ID), rather than from parsing /proc/self/stat: reading -// and parsing that file allocated on every sample, and collect() runs 10 times per second for the -// whole duration of a transaction. It does not include the cpu time of reaped child processes, -// which an app process doesn't have. +// The process cpu time comes from Process.getElapsedCpuTime(), a @CriticalNative wrapper around +// clock_gettime(CLOCK_PROCESS_CPUTIME_ID), rather than from parsing /proc/self/stat: reading and +// parsing that file allocated on every sample, and collect() runs 10 times per second for the whole +// duration of a transaction. It does not include the cpu time of reaped child processes, which an +// app process doesn't have. @ApiStatus.Internal public final class AndroidCpuCollector implements IPerformanceSnapshotCollector {