feat(profiling): Drop profiler ids from spans no profile covers - #6015
feat(profiling): Drop profiler ids from spans no profile covers#6015markushi wants to merge 4 commits into
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
📲 Install BuildsAndroid
|
|
@sentry review |
0xadam-brown
left a comment
There was a problem hiding this comment.
Nice! Much easier to follow, IMO. A few initial comments. Haven't checked for any threading issues. But the APIs are looking clean 💯
|
|
||
| private final @NotNull ArrayDeque<ChunkRecord> chunkHistory = | ||
| new ArrayDeque<>(MAX_CHUNK_HISTORY_SIZE); | ||
| private @Nullable ChunkRecord currentChunk = null; |
There was a problem hiding this comment.
l: Can we just use chunkHistory.peekLast() instead?
| if (chunk.getProfilerId().equals(profilerId) | ||
| && chunk.overlaps(startTime, endTime) | ||
| && chunk.getRecordingState() != ProfileRecordingState.NOT_RECORDED) { | ||
| return ProfileRecordingState.RECORDED; |
There was a problem hiding this comment.
m: Thoughts about updating our approach to match the following policy?
| Scenario | Returned value |
|---|---|
| No chunks have ever run | UNKNOWN |
| No recorded chunk overlaps window but one or more overlapping chunk remain undecided | UNKNOWN |
| At least one overlapping chunk was recorded | RECORDED |
| Overlapping chunks exist, but all failed | NOT_RECORDED |
| History exists, but no chunk for that profiler/window overlaps | NOT_RECORDED |
We could implement it like this:
@Override
public @NotNull ProfileRecordingState getProfileRecordingState(
final @NotNull SentryId profilerId,
final @NotNull SentryDate startTime,
final @NotNull SentryDate endTime) {
try (final @NotNull ISentryLifecycleToken ignored = chunkHistoryLock.acquire()) {
if (chunkHistory.isEmpty()) {
return ProfileRecordingState.UNKNOWN;
}
boolean hasUnknownOverlappingChunk = false;
for (final @NotNull ChunkRecord chunk : chunkHistory) {
if (!chunk.getProfilerId().equals(profilerId) || !chunk.overlaps(startTime, endTime)) {
continue;
}
final @NotNull ProfileRecordingState state = chunk.getRecordingState();
if (state == ProfileRecordingState.RECORDED) {
return ProfileRecordingState.RECORDED;
}
if (state == ProfileRecordingState.UNKNOWN) {
hasUnknownOverlappingChunk = true;
}
}
if (hasUnknownOverlappingChunk) {
return ProfileRecordingState.UNKNOWN;
}
return ProfileRecordingState.NOT_RECORDED;
}
} That'd let us avoid returning RECORDED in situations where we don't actually know yet. (Doesn't change current behavior b/c we only act on NOT_RECORDED, but it could matter going forward.)
| final @Nullable ChunkRecord chunkRecord = | ||
| perfettoProfiler.start(startProfileChunkTimestamp, profilerId, MAX_CHUNK_DURATION_MILLIS); | ||
| if (chunkRecord == null) { | ||
| profilerId = SentryId.EMPTY_ID; |
There was a problem hiding this comment.
m: What's the thinking behind updating the profilerId?
Fwiw, I would've figured we'd keep the profilerId intact and let the ProfileRecordingState inform the outside world about the success vs failure of recording – but perhaps I'm missing something?
| if (record != null) { | ||
| final int errorCode = result.getErrorCode(); | ||
| record.setRecordingState( | ||
| errorCode == ProfilingResult.ERROR_NONE |
There was a problem hiding this comment.
m: Should this be:
if (record != null && result.getErrorCode() != ProfilingResult.ERROR_NONE) {
record.setRecordingState(ProfileRecordingState.NOT_RECORDED);
} Worried that we're claiming the profile has been recorded because we haven't seen an error.
📜 Description
Transactions and spans are tagged with the continuous profiler's
profiler_idas soon as they start, but the OS only tells us later whether a Perfetto profile really exists. This PR lets anything tagged with an id that leads nowhere drop the reference before it is sent.IContinuousProfilergets one new method:SentryTracer.finish()asks it once for the root span window and once per child span window, and removes theProfileContextplus theprofiler_idspan data only onNOT_RECORDED.RECORDEDandUNKNOWNchange nothing, so an outcome we do not know never costs a valid link.PerfettoContinuousProfileranswers from a bounded history of chunk records, guarded by a lock of its own so that a finishing transaction never waits for a chunk start, a chunk stop or an OS callback.PerfettoProfilercreates each record instart()and writes the outcome into it as soon as it knows: an OS error code, a missing or empty trace file, or the result timeout.NOT_RECORDEDis final, so a result the OS delivers late cannot revive a chunk that was already given up on.NoOpContinuousProfiler,AndroidContinuousProfilerandJavaContinuousProfileranswerUNKNOWN, which leaves every non-Perfetto path exactly as it was.This is an alternative to #5993, which solved the same problem with callbacks from the profiler into the tracer. Pulling the state at send time removes the listener registration, the unregistration on finish, and the tracers that stay registered because they never finish.
💡 Motivation and Context
Rate limiting is the common case on API 35+, and the OS reports it roughly 1 ms after the request. Without this, a rate-limited session produces transactions that link to profiles the backend never receives, which shows up in the UI as dangling profile references. There is no backend logic that removes such references.
💚 How did you test it?
Unit tests, on three levels:
ChunkRecordTest— the state machine (NOT_RECORDEDis final) and the window arithmetic, including the exact boundaries and a chunk that is still running.PerfettoProfilerTest— the record outcome for an OS error, a null result path, a missing trace file, the result timeout, and a result that arrives after the timeout.PerfettoContinuousProfilerTest— the answer for a running chunk, a collected chunk, a failed chunk, a window after the last chunk, a window that starts before the profiler did, per-chunk judgement across a failed and a recorded chunk, eviction, and close.SentryTracerTest— dropped, kept and unknown outcomes, per-span judgement, a span that never finished, and aprofiler_idthe SDK did not write.📝 Checklist
sendDefaultPIIis enabled.🔮 Next steps
Known limits, left out deliberately:
PerfettoProfiler.start(long)was public and is now package-private, since it returns the internal chunk record. The class is@ApiStatus.Internaland@RequiresApi(35), but the method was on the published surface in 8.51.0–8.53.0.SentryTracerdrops the id. OpenTelemetry spans take another path and are not covered. This has no effect today, as only the Android Perfetto profiler ever answers anything butUNKNOWN.Sentry.initreplaced reads asNOT_RECORDED. Keeping such ids alive belongs to whatever owns the profiler lifecycle across inits, not to the chunk history.