Skip to content

Commit 028f17b

Browse files
committed
feat: merge tombstone and native sdk events
1 parent 94bff8d commit 028f17b

File tree

6 files changed

+426
-1
lines changed

6 files changed

+426
-1
lines changed

sentry-android-core/api/sentry-android-core.api

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,21 @@ public final class io/sentry/android/core/LoadClass : io/sentry/util/LoadClass {
291291
public fun loadClass (Ljava/lang/String;Lio/sentry/ILogger;)Ljava/lang/Class;
292292
}
293293

294+
public final class io/sentry/android/core/NativeEventCollector {
295+
public fun <init> (Lio/sentry/android/core/SentryAndroidOptions;)V
296+
public fun collect ()V
297+
public fun deleteNativeEventFile (Lio/sentry/android/core/NativeEventCollector$NativeEventData;)Z
298+
public fun findAndRemoveMatchingNativeEvent (JLjava/lang/String;)Lio/sentry/android/core/NativeEventCollector$NativeEventData;
299+
}
300+
301+
public final class io/sentry/android/core/NativeEventCollector$NativeEventData {
302+
public fun getCorrelationId ()Ljava/lang/String;
303+
public fun getEnvelope ()Lio/sentry/SentryEnvelope;
304+
public fun getEvent ()Lio/sentry/SentryEvent;
305+
public fun getFile ()Ljava/io/File;
306+
public fun getTimestampMs ()J
307+
}
308+
294309
public final class io/sentry/android/core/NdkHandlerStrategy : java/lang/Enum {
295310
public static final field SENTRY_HANDLER_STRATEGY_CHAIN_AT_START Lio/sentry/android/core/NdkHandlerStrategy;
296311
public static final field SENTRY_HANDLER_STRATEGY_DEFAULT Lio/sentry/android/core/NdkHandlerStrategy;
@@ -339,6 +354,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
339354
public fun getBeforeViewHierarchyCaptureCallback ()Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback;
340355
public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader;
341356
public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;
357+
public fun getNativeCrashCorrelationId ()Ljava/lang/String;
342358
public fun getNativeSdkName ()Ljava/lang/String;
343359
public fun getNdkHandlerStrategy ()I
344360
public fun getStartupCrashDurationThresholdMillis ()J
@@ -392,6 +408,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
392408
public fun setEnableSystemEventBreadcrumbs (Z)V
393409
public fun setEnableSystemEventBreadcrumbsExtras (Z)V
394410
public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V
411+
public fun setNativeCrashCorrelationId (Ljava/lang/String;)V
395412
public fun setNativeHandlerStrategy (Lio/sentry/android/core/NdkHandlerStrategy;)V
396413
public fun setNativeSdkName (Ljava/lang/String;)V
397414
public fun setReportHistoricalAnrs (Z)V

sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import static io.sentry.android.core.NdkIntegration.SENTRY_NDK_CLASS_NAME;
44

5+
import android.app.ActivityManager;
56
import android.app.Application;
67
import android.content.Context;
78
import android.content.pm.PackageInfo;
@@ -57,8 +58,10 @@
5758
import io.sentry.util.Objects;
5859
import io.sentry.util.thread.NoOpThreadChecker;
5960
import java.io.File;
61+
import java.nio.charset.StandardCharsets;
6062
import java.util.ArrayList;
6163
import java.util.List;
64+
import java.util.UUID;
6265
import org.jetbrains.annotations.NotNull;
6366
import org.jetbrains.annotations.Nullable;
6467
import org.jetbrains.annotations.TestOnly;
@@ -244,6 +247,12 @@ static void initializeIntegrationsAndProcessors(
244247
if (options.getSocketTagger() instanceof NoOpSocketTagger) {
245248
options.setSocketTagger(AndroidSocketTagger.getInstance());
246249
}
250+
251+
// Set native crash correlation ID before NDK integration is registered
252+
if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.R) {
253+
setNativeCrashCorrelationId(context, options);
254+
}
255+
247256
if (options.getPerformanceCollectors().isEmpty()) {
248257
options.addPerformanceCollector(new AndroidMemoryCollector());
249258
options.addPerformanceCollector(new AndroidCpuCollector(options.getLogger()));
@@ -497,4 +506,33 @@ private static void readDefaultOptionValues(
497506
static @NotNull File getCacheDir(final @NotNull Context context) {
498507
return new File(context.getCacheDir(), "sentry");
499508
}
509+
510+
/**
511+
* Sets a native crash correlation ID that can be used to associate native crash events (from
512+
* sentry-native) with tombstone events (from ApplicationExitInfo). The ID is stored via
513+
* ActivityManager.setProcessStateSummary() and passed to the native SDK.
514+
*
515+
* @param context the Application context
516+
* @param options the SentryAndroidOptions
517+
*/
518+
private static void setNativeCrashCorrelationId(
519+
final @NotNull Context context, final @NotNull SentryAndroidOptions options) {
520+
final String correlationId = UUID.randomUUID().toString();
521+
options.setNativeCrashCorrelationId(correlationId);
522+
523+
try {
524+
final ActivityManager am =
525+
(ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
526+
if (am != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
527+
am.setProcessStateSummary(correlationId.getBytes(StandardCharsets.UTF_8));
528+
options
529+
.getLogger()
530+
.log(SentryLevel.DEBUG, "Native crash correlation ID set: %s", correlationId);
531+
}
532+
} catch (Throwable e) {
533+
options
534+
.getLogger()
535+
.log(SentryLevel.WARNING, "Failed to set process state summary for correlation ID", e);
536+
}
537+
}
500538
}
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
package io.sentry.android.core;
2+
3+
import static io.sentry.cache.EnvelopeCache.PREFIX_CURRENT_SESSION_FILE;
4+
import static io.sentry.cache.EnvelopeCache.PREFIX_PREVIOUS_SESSION_FILE;
5+
import static io.sentry.cache.EnvelopeCache.STARTUP_CRASH_MARKER_FILE;
6+
7+
import io.sentry.SentryEnvelope;
8+
import io.sentry.SentryEnvelopeItem;
9+
import io.sentry.SentryEvent;
10+
import io.sentry.SentryItemType;
11+
import io.sentry.SentryLevel;
12+
import java.io.BufferedInputStream;
13+
import java.io.BufferedReader;
14+
import java.io.ByteArrayInputStream;
15+
import java.io.File;
16+
import java.io.FileInputStream;
17+
import java.io.InputStream;
18+
import java.io.InputStreamReader;
19+
import java.io.Reader;
20+
import java.nio.charset.StandardCharsets;
21+
import java.util.ArrayList;
22+
import java.util.Date;
23+
import java.util.List;
24+
import org.jetbrains.annotations.ApiStatus;
25+
import org.jetbrains.annotations.NotNull;
26+
import org.jetbrains.annotations.Nullable;
27+
28+
/**
29+
* Collects native crash events from the outbox directory. These events can be correlated with
30+
* tombstone events from ApplicationExitInfo to avoid sending duplicate crash reports.
31+
*/
32+
@ApiStatus.Internal
33+
public final class NativeEventCollector {
34+
35+
private static final String NATIVE_PLATFORM = "native";
36+
37+
// TODO: will be replaced with the correlationId once the Native SDK supports it
38+
private static final long TIMESTAMP_TOLERANCE_MS = 5000;
39+
40+
private final @NotNull SentryAndroidOptions options;
41+
private final @NotNull List<NativeEventData> nativeEvents = new ArrayList<>();
42+
private boolean collected = false;
43+
44+
public NativeEventCollector(final @NotNull SentryAndroidOptions options) {
45+
this.options = options;
46+
}
47+
48+
/** Holds a native event along with its source file for later deletion. */
49+
public static final class NativeEventData {
50+
private final @NotNull SentryEvent event;
51+
private final @NotNull File file;
52+
private final @NotNull SentryEnvelope envelope;
53+
private final long timestampMs;
54+
55+
NativeEventData(
56+
final @NotNull SentryEvent event,
57+
final @NotNull File file,
58+
final @NotNull SentryEnvelope envelope,
59+
final long timestampMs) {
60+
this.event = event;
61+
this.file = file;
62+
this.envelope = envelope;
63+
this.timestampMs = timestampMs;
64+
}
65+
66+
public @NotNull SentryEvent getEvent() {
67+
return event;
68+
}
69+
70+
public @NotNull File getFile() {
71+
return file;
72+
}
73+
74+
public @NotNull SentryEnvelope getEnvelope() {
75+
return envelope;
76+
}
77+
78+
public long getTimestampMs() {
79+
return timestampMs;
80+
}
81+
82+
/**
83+
* Extracts the correlation ID from the event's extra data.
84+
*
85+
* @return the correlation ID, or null if not present
86+
*/
87+
public @Nullable String getCorrelationId() {
88+
final @Nullable Object correlationId = event.getExtra("sentry.native.correlation_id");
89+
if (correlationId instanceof String) {
90+
return (String) correlationId;
91+
}
92+
return null;
93+
}
94+
}
95+
96+
/**
97+
* Scans the outbox directory and collects all native crash events. This method should be called
98+
* once before processing tombstones. Subsequent calls are no-ops.
99+
*/
100+
public void collect() {
101+
if (collected) {
102+
return;
103+
}
104+
collected = true;
105+
106+
final @Nullable String outboxPath = options.getOutboxPath();
107+
if (outboxPath == null) {
108+
options
109+
.getLogger()
110+
.log(SentryLevel.DEBUG, "Outbox path is null, skipping native event collection.");
111+
return;
112+
}
113+
114+
final File outboxDir = new File(outboxPath);
115+
if (!outboxDir.isDirectory()) {
116+
options.getLogger().log(SentryLevel.DEBUG, "Outbox path is not a directory: %s", outboxPath);
117+
return;
118+
}
119+
120+
final File[] files = outboxDir.listFiles((d, name) -> isRelevantFileName(name));
121+
if (files == null || files.length == 0) {
122+
options.getLogger().log(SentryLevel.DEBUG, "No envelope files found in outbox.");
123+
return;
124+
}
125+
126+
options
127+
.getLogger()
128+
.log(SentryLevel.DEBUG, "Scanning %d files in outbox for native events.", files.length);
129+
130+
for (final File file : files) {
131+
if (!file.isFile()) {
132+
continue;
133+
}
134+
135+
final @Nullable NativeEventData nativeEventData = extractNativeEventFromFile(file);
136+
if (nativeEventData != null) {
137+
nativeEvents.add(nativeEventData);
138+
options
139+
.getLogger()
140+
.log(
141+
SentryLevel.DEBUG,
142+
"Found native event in outbox: %s (timestamp: %d)",
143+
file.getName(),
144+
nativeEventData.getTimestampMs());
145+
}
146+
}
147+
148+
options
149+
.getLogger()
150+
.log(SentryLevel.DEBUG, "Collected %d native events from outbox.", nativeEvents.size());
151+
}
152+
153+
/**
154+
* Finds a native event that matches the given tombstone timestamp or correlation ID. If a match
155+
* is found, it is removed from the internal list so it won't be matched again.
156+
*
157+
* <p>This method will lazily collect native events from the outbox on first call.
158+
*
159+
* @param tombstoneTimestampMs the timestamp from ApplicationExitInfo
160+
* @param correlationId the correlation ID from processStateSummary, or null
161+
* @return the matching native event data, or null if no match found
162+
*/
163+
public @Nullable NativeEventData findAndRemoveMatchingNativeEvent(
164+
final long tombstoneTimestampMs, final @Nullable String correlationId) {
165+
166+
// Lazily collect on first use (runs on executor thread, not main thread)
167+
collect();
168+
169+
// First, try to match by correlation ID (when sentry-native supports it)
170+
if (correlationId != null) {
171+
for (final NativeEventData nativeEvent : nativeEvents) {
172+
final @Nullable String nativeCorrelationId = nativeEvent.getCorrelationId();
173+
if (correlationId.equals(nativeCorrelationId)) {
174+
options
175+
.getLogger()
176+
.log(SentryLevel.DEBUG, "Matched native event by correlation ID: %s", correlationId);
177+
nativeEvents.remove(nativeEvent);
178+
return nativeEvent;
179+
}
180+
}
181+
}
182+
183+
// Fall back to timestamp-based matching
184+
for (final NativeEventData nativeEvent : nativeEvents) {
185+
final long timeDiff = Math.abs(tombstoneTimestampMs - nativeEvent.getTimestampMs());
186+
if (timeDiff <= TIMESTAMP_TOLERANCE_MS) {
187+
options
188+
.getLogger()
189+
.log(SentryLevel.DEBUG, "Matched native event by timestamp (diff: %d ms)", timeDiff);
190+
nativeEvents.remove(nativeEvent);
191+
return nativeEvent;
192+
}
193+
}
194+
195+
return null;
196+
}
197+
198+
/**
199+
* Deletes a native event file from the outbox.
200+
*
201+
* @param nativeEventData the native event data containing the file reference
202+
* @return true if the file was deleted successfully
203+
*/
204+
public boolean deleteNativeEventFile(final @NotNull NativeEventData nativeEventData) {
205+
final File file = nativeEventData.getFile();
206+
try {
207+
if (file.delete()) {
208+
options
209+
.getLogger()
210+
.log(SentryLevel.DEBUG, "Deleted native event file from outbox: %s", file.getName());
211+
return true;
212+
} else {
213+
options
214+
.getLogger()
215+
.log(
216+
SentryLevel.WARNING,
217+
"Failed to delete native event file: %s",
218+
file.getAbsolutePath());
219+
return false;
220+
}
221+
} catch (Throwable e) {
222+
options
223+
.getLogger()
224+
.log(
225+
SentryLevel.ERROR, e, "Error deleting native event file: %s", file.getAbsolutePath());
226+
return false;
227+
}
228+
}
229+
230+
private @Nullable NativeEventData extractNativeEventFromFile(final @NotNull File file) {
231+
try (final InputStream stream = new BufferedInputStream(new FileInputStream(file))) {
232+
final SentryEnvelope envelope = options.getEnvelopeReader().read(stream);
233+
if (envelope == null) {
234+
return null;
235+
}
236+
237+
for (final SentryEnvelopeItem item : envelope.getItems()) {
238+
if (!SentryItemType.Event.equals(item.getHeader().getType())) {
239+
continue;
240+
}
241+
242+
try (final Reader eventReader =
243+
new BufferedReader(
244+
new InputStreamReader(
245+
new ByteArrayInputStream(item.getData()), StandardCharsets.UTF_8))) {
246+
final SentryEvent event =
247+
options.getSerializer().deserialize(eventReader, SentryEvent.class);
248+
if (event != null && NATIVE_PLATFORM.equals(event.getPlatform())) {
249+
final long timestampMs = extractTimestampMs(event);
250+
return new NativeEventData(event, file, envelope, timestampMs);
251+
}
252+
}
253+
}
254+
} catch (Throwable e) {
255+
options
256+
.getLogger()
257+
.log(SentryLevel.DEBUG, e, "Error reading envelope file: %s", file.getAbsolutePath());
258+
}
259+
return null;
260+
}
261+
262+
private long extractTimestampMs(final @NotNull SentryEvent event) {
263+
final @Nullable Date timestamp = event.getTimestamp();
264+
if (timestamp != null) {
265+
return timestamp.getTime();
266+
}
267+
return 0;
268+
}
269+
270+
private boolean isRelevantFileName(final @Nullable String fileName) {
271+
return fileName != null
272+
&& !fileName.startsWith(PREFIX_CURRENT_SESSION_FILE)
273+
&& !fileName.startsWith(PREFIX_PREVIOUS_SESSION_FILE)
274+
&& !fileName.startsWith(STARTUP_CRASH_MARKER_FILE);
275+
}
276+
}

0 commit comments

Comments
 (0)