diff --git a/.agents/skills b/.agents/skills new file mode 120000 index 00000000000..454b8427cd7 --- /dev/null +++ b/.agents/skills @@ -0,0 +1 @@ +../.claude/skills \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000000..ac6b69b1435 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,34 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(ls:*)", + "Bash(git:*)", + "Bash(git status:*)", + "Bash(git log:*)", + "Bash(git diff:*)", + "Bash(git show:*)", + "Bash(git branch:*)", + "Bash(git remote:*)", + "Bash(git tag:*)", + "Bash(git stash list:*)", + "Bash(git rev-parse:*)", + "Bash(gh pr view:*)", + "Bash(gh pr list:*)", + "Bash(gh pr checks:*)", + "Bash(gh pr diff:*)", + "Bash(gh issue view:*)", + "Bash(gh issue list:*)", + "Bash(gh run view:*)", + "Bash(gh run list:*)", + "Bash(gh run logs:*)", + "Bash(gh repo view:*)", + "WebFetch(domain:github.com)", + "WebFetch(domain:docs.sentry.io)", + "WebFetch(domain:develop.sentry.dev)", + "Bash(grep:*)", + "Bash(mv:*)" + ], + "deny": [] + } +} diff --git a/.claude/skills/.gitignore b/.claude/skills/.gitignore new file mode 100644 index 00000000000..2dd55eba801 --- /dev/null +++ b/.claude/skills/.gitignore @@ -0,0 +1,12 @@ +# Ignore dotagents-managed skills (synced from agents.toml) +* +# Keep custom repo-specific skills +!.gitignore +!create-java-pr/ +!create-java-pr/** +!test/ +!test/** +!btrace-perfetto/ +!btrace-perfetto/** +!check-code-attribution/ +!check-code-attribution/** diff --git a/.claude/skills/btrace-perfetto/SKILL.md b/.claude/skills/btrace-perfetto/SKILL.md new file mode 100644 index 00000000000..8d9e5a6bca1 --- /dev/null +++ b/.claude/skills/btrace-perfetto/SKILL.md @@ -0,0 +1,303 @@ +--- +name: btrace-perfetto +description: Capture and compare Perfetto traces using btrace 3.0 on an Android device. Use when asked to "profile", "capture trace", "perfetto trace", "btrace", "compare traces", "record perfetto", "trace touch events", "measure performance on device", or benchmark Android SDK changes between branches. +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, WebFetch, AskUserQuestion +argument-hint: "[branch1] [branch2] [duration] [sql-query]" +--- + +# btrace Perfetto Trace Capture + +Capture Perfetto traces with btrace 3.0 on a connected Android device, optionally comparing two branches. Opens results in Perfetto UI with a prefilled SQL query. After capture, query traces locally with `trace_processor` to compute comparison stats. + +## Prerequisites + +Before starting, verify: + +1. **Connected device**: `adb devices` shows a device (Android 8.0+, 64-bit) +2. **btrace CLI jar**: Check if `tools/btrace/rhea-trace-shell.jar` exists. If not, download it: + ```bash + mkdir -p tools/btrace/traces + curl -sL "https://repo1.maven.org/maven2/com/bytedance/btrace/rhea-trace-processor/3.0.0/rhea-trace-processor-3.0.0.jar" \ + -o tools/btrace/rhea-trace-shell.jar + ``` +3. **Perfetto trace_processor**: Check if `/tmp/trace_processor` exists. If not, download it: + ```bash + # Download trace_processor (--fail ensures HTTP errors don't leave a file behind) + curl -sSL --fail "https://get.perfetto.dev/trace_processor" -o /tmp/trace_processor + + # Verify magic bytes directly — file(1) output is too inconsistent across + # versions/platforms to rely on for scripts or PIE binaries. + magic=$(head -c 4 /tmp/trace_processor 2>/dev/null | od -An -vtx1 -N4 | tr -d ' \n') + case "$magic" in + 2321*) ;; # #! shebang (script) + 7f454c46) ;; # ELF (Linux) + cffaedfe|cefaedfe|feedfacf|feedface) ;; # Mach-O (macOS) + cafebabe) ;; # Mach-O universal + *) + echo "Error: Downloaded file is not a valid script or executable (magic: ${magic:-empty})" + rm -f /tmp/trace_processor + exit 1 + ;; + esac + + # Make executable only after verification + chmod +x /tmp/trace_processor + ``` +4. **Device ABI**: Run `adb shell getprop ro.product.cpu.abi` — btrace only supports arm64-v8a and armeabi-v7a (no x86/x86_64) + +## Step 1: Parse Arguments + +| Argument | Default | Description | +|----------|---------|-------------| +| branch1 | current branch | First branch to trace | +| branch2 | `main` | Second branch to compare against | +| duration | `30` | Trace duration in seconds | +| sql-query | see below | SQL query to prefill in Perfetto UI | + +If no arguments are provided, ask the user what they want to trace and which branches to compare. If only one branch is given, capture only that branch (no comparison). + +## Step 2: Integrate btrace into Sample App + +The sample app is at `sentry-samples/sentry-samples-android/`. + +### 2a: Add btrace dependency + +In `sentry-samples/sentry-samples-android/build.gradle.kts`, add to the `dependencies` block: + +```kotlin +implementation("com.bytedance.btrace:rhea-inhouse:3.0.0") +``` + +### 2b: Restrict ABI to device architecture + +The btrace native library (shadowhook) does not support x86/x86_64. Replace the `ndk` abiFilters line in `defaultConfig` to match the connected device: + +```kotlin +ndk { abiFilters.addAll(listOf("arm64-v8a")) } +``` + +Adjust if the device reports a different ABI. + +### 2c: Initialize btrace in Application + +In `MyApplication.java`, add `attachBaseContext`: + +```java +import android.content.Context; +import com.bytedance.rheatrace.RheaTrace3; + +// Add before onCreate: +@Override +protected void attachBaseContext(Context base) { + super.attachBaseContext(base); + RheaTrace3.init(base); +} +``` + +**Important**: The package is `com.bytedance.rheatrace`, not `com.bytedance.btrace`. + +### 2d: Add ProGuard keep rules (release builds only) + +Only needed when building release. In `sentry-samples/sentry-samples-android/proguard-rules.pro`, add: + +``` +-keep class com.bytedance.rheatrace.** { *; } +-keepnames class io.sentry.** { *; } +``` + +The first rule prevents R8 from stripping btrace's HTTP server classes (fails with `SocketException` otherwise). The second preserves Sentry class and method names so they appear readable in the Perfetto trace instead of obfuscated single-letter names. + +## Step 3: Build and Install + +Prefer **debug builds** — they provide richer tracing instrumentation (Handler, MessageQueue, Monitor:Lock slices visible) which is essential for comparing internal SDK behavior. Use the default 1kHz btrace sampling rate for debug builds. + +```bash +./gradlew :sentry-samples:sentry-samples-android:installDebug +``` + +**Release builds** are useful when you need to measure real-world performance without StrictMode/debuggable overhead or with R8 optimizations. Require the ProGuard keep rules from step 2d. Use `-sampleInterval 333000` (333μs / 3kHz) for finer granularity since release code runs faster. + +```bash +./gradlew :sentry-samples:sentry-samples-android:installRelease +``` + +## Step 4: Capture Trace + +For each branch to trace: + +### 4a: Set btrace properties and launch app + +Clear any stale port files, set properties, and launch: + +```bash +adb shell "rm -rf /storage/emulated/0/Android/data/io.sentry.samples.android/files/rhea-port" +adb shell setprop debug.rhea3.startWhenAppLaunch 1 +adb shell setprop debug.rhea3.waitTraceTimeout 60 +adb shell am force-stop io.sentry.samples.android +sleep 2 +adb shell am start -n io.sentry.samples.android/.MainActivity +sleep 5 +``` + +The app must be started AFTER `debug.rhea3.startWhenAppLaunch` is set, otherwise the trace server won't initialize. The 5s sleep after launch gives the btrace HTTP server time to start. + +### 4b: Play a sound to signal the user, then capture + +Play a sound when tracing actually starts so the user knows to begin interacting. Pipe btrace output through a loop that triggers the sound on the "start tracing" line: + +```bash +java -jar tools/btrace/rhea-trace-shell.jar \ + -a io.sentry.samples.android \ + -t ${duration} \ + -waitTraceTimeout 60 \ + -o tools/btrace/traces/${branch_name}.pb \ + sched 2>&1 | while IFS= read -r line; do + echo "$line" + if [[ "$line" == *"start tracing"* ]]; then + afplay -v 1.5 /System/Library/Sounds/Ping.aiff & + fi + done +``` + +For release builds with finer sampling, add `-sampleInterval 333000`. + +Do NOT use the `-r` flag — it fails to resolve the launcher activity because LeakCanary registers a second one. Launch the app manually in step 4a instead. + +### 4c: Switch branches for comparison + +When capturing a second branch: + +1. Stash the btrace integration changes: + ```bash + git stash push -m "btrace integration" -- \ + sentry-samples/sentry-samples-android/build.gradle.kts \ + sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java \ + sentry-samples/sentry-samples-android/proguard-rules.pro + ``` +2. Checkout the other branch +3. Pop the stash: `git stash pop` +4. Rebuild and install (same variant — debug or release — as the first branch) +5. Repeat steps 4a and 4b with a different output filename +6. Switch back to the original branch and restore files + +## Step 5: Open in Perfetto UI + +Generate a viewer HTML and serve it locally. Use the template at `assets/viewer-template.html` as a base — copy it to `tools/btrace/traces/viewer.html` and replace the placeholder values: + +- `TRACE_FILES`: array of `{file, title}` objects for each captured trace +- `SQL_QUERY`: the SQL query to prefill + +The SQL query is passed via the URL hash parameter: `https://ui.perfetto.dev/#!/?query=...` + +The trace data is sent via the postMessage API (required for local files — URL deep-linking does not work with `file://`). + +Start a local HTTP server and open the viewer: + +```bash +cd tools/btrace/traces && python3 -m http.server 8008 & +open http://localhost:8008/viewer.html +``` + +### Default SQL Query + +If no custom query is provided, use: + +```sql +SELECT + s.name AS slice_name, + s.dur / 1e6 AS dur_ms, + s.ts, + t.name AS track_name +FROM slice s +JOIN thread_track t ON s.track_id = t.id +WHERE s.name GLOB '*SentryWindowCallback.dispatch*' +ORDER BY s.ts +``` + +## Step 6: Query and Compare Traces + +After capturing both branches, use `trace_processor` to compute comparison stats locally. + +### Basic stats query + +For each trace file, run: + +```bash +/tmp/trace_processor -Q " +WITH events AS ( + SELECT s.dur / 1e6 as dur_ms FROM slice s + WHERE s.name GLOB '*${METHOD_GLOB}*' AND s.dur > 0 + ORDER BY s.dur +) +SELECT COUNT(*) as count, + ROUND(AVG(dur_ms), 4) as avg_ms, + ROUND((SELECT dur_ms FROM events LIMIT 1 OFFSET (SELECT COUNT(*)/2 FROM events)), 4) as median_ms, + ROUND(MIN(dur_ms), 4) as min_ms, + ROUND(MAX(dur_ms), 4) as max_ms +FROM events +" tools/btrace/traces/${trace_file}.pb +``` + +Replace `${METHOD_GLOB}` with the method pattern to compare (e.g. `SentryGestureDetector.onTouchEvent`, `SentryWindowCallback.dispatchTouchEvent`). + +### Finding child calls (debug builds) + +To find what happens inside a method (e.g. Handler calls, lock acquisitions): + +```bash +/tmp/trace_processor -Q " +WITH RECURSIVE descendants(id, depth) AS ( + SELECT s.id, 0 FROM slice s WHERE s.name GLOB '*${PARENT_METHOD}*' + UNION ALL + SELECT s.id, d.depth + 1 FROM slice s JOIN descendants d ON s.parent_id = d.id WHERE d.depth < 10 +) +SELECT s.name, COUNT(*) as count, ROUND(AVG(s.dur / 1e6), 3) as avg_ms +FROM slice s JOIN descendants d ON s.id = d.id +WHERE d.depth > 0 +GROUP BY s.name ORDER BY count DESC +LIMIT 20 +" tools/btrace/traces/${trace_file}.pb +``` + +### Build the comparison table + +Run the stats query on both trace files, then present a markdown table: + +``` +| Metric | Branch A | Branch B | Delta | +|--------|----------|----------|-------| +| Count | ... | ... | | +| Average| ... | ... | -X% | +| Median | ... | ... | -X% | +| Max | ... | ... | -X% | +``` + +Compute delta as `(branchA - branchB) / branchB * 100`. Negative means branch A is faster. + +### Sampling rate reference + +| Rate | Interval | `-sampleInterval` | Use case | +|------|----------|-------------------|----------| +| 1 kHz | 1ms | `1000000` (default) | Debug builds, general profiling | +| 3 kHz | 333μs | `333000` | Release builds, finer granularity | +| 10 kHz | 100μs | `100000` | Maximum detail, higher overhead | + +Higher sampling rates capture shorter method calls but add CPU overhead which can skew results. For most comparisons, the default 1kHz is sufficient. + +## Cleanup + +After tracing is complete, remind the user that the btrace integration changes to the sample app should NOT be committed. The `tools/btrace/` directory is gitignored. + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| `No compatible library found [shadowhook]` | Restrict `ndk.abiFilters` to arm64-v8a only | +| `package com.bytedance.btrace does not exist` | Use `com.bytedance.rheatrace` (not `btrace`) | +| `ResolverActivity does not exist` with `-r` flag | Don't use `-r`; launch the app manually before capturing | +| `wait for trace ready timeout` on download | Set `debug.rhea3.startWhenAppLaunch=1` BEFORE launching the app, and use `-waitTraceTimeout 60` | +| Empty jar file (0 bytes) | Download from Maven Central (`repo1.maven.org`), not `oss.sonatype.org` | +| `FileNotFoundException` on sampling download | App was already running when properties were set; force-stop and relaunch | +| `SocketException: Unexpected end of file` in release builds | R8 stripped btrace classes; add `-keep class com.bytedance.rheatrace.** { *; }` to proguard-rules.pro | +| Stale port from previous session | Run `adb shell "rm -rf /storage/emulated/0/Android/data/io.sentry.samples.android/files/rhea-port"` before launching | +| Most `onTouchEvent` durations are 0ms | Increase sampling rate with `-sampleInterval 333000` (3kHz) | diff --git a/.claude/skills/btrace-perfetto/assets/viewer-template.html b/.claude/skills/btrace-perfetto/assets/viewer-template.html new file mode 100644 index 00000000000..4c31a24c342 --- /dev/null +++ b/.claude/skills/btrace-perfetto/assets/viewer-template.html @@ -0,0 +1,49 @@ + + +btrace Trace Viewer + +

Perfetto Trace Viewer

+
+

+ + + diff --git a/.claude/skills/check-code-attribution/SKILL.md b/.claude/skills/check-code-attribution/SKILL.md new file mode 100644 index 00000000000..ee66327c260 --- /dev/null +++ b/.claude/skills/check-code-attribution/SKILL.md @@ -0,0 +1,244 @@ +--- +name: check-code-attribution +description: Per-file check of vendored code attribution in the current branch diff, including license headers, THIRD_PARTY_NOTICES.md entries, and compatibility with Sentry's licensing policy +allowed-tools: Bash Read Grep Glob +--- + +# Check Code Attribution + +You are reviewing changed files for third-party code attribution compliance in **sentry-java**, an MIT-licensed repository. + +## Local runs + +When running locally (not via Warden), review every file changed on this branch vs the base branch. Apply the same path exclusions as `ignorePaths` in `warden.toml`, then run Quick triage and the checks below on each file. For git commands to list changed files and Warden CLI setup, see `validation-tests/README.md`. `/check-code-attribution` in the IDE does not require Warden credentials. + +When running via Warden, the changed file is already provided — skip branch-wide discovery, but follow **Warden execution** below. + +## Warden execution + +Warden analyzes one changed file per run (whole-file mode). Complete every Quick triage step — the diff alone is not sufficient. + +**Mandatory on every run (do not skip):** + +1. Read the first 50 lines of the changed file. +2. Search `THIRD_PARTY_NOTICES.md` for the class name (filename without extension, e.g. `ANRWatchDog` for `ANRWatchDog.java`). On renames, also search for the old basename and read Scope sections (see Quick triage). +3. When you can compare against the base branch version, inspect the header at that revision (first 50 lines). + +**Do not dismiss findings because:** + +- A `THIRD_PARTY_NOTICES.md` entry exists — file headers are still required; NOTICES does not replace them. +- The diff only removes a header comment block — if removed `-` lines include a **required field** (see below) or vendoring language ("adapted from", etc.), attribution was stripped. Removing boilerplate alone is not stripping. +- The header says "Adapted from …" but omits copyright holder or license name — flag missing header fields. +- The file header has all four required fields — a missing THIRD_PARTY_NOTICES.md entry is independently required and is ⚠️ medium regardless of header completeness. + +For `THIRD_PARTY_NOTICES.md` runs: for every **removed** entry in the diff, confirm whether Scope files still exist with attribution headers. If they do, the entry must not be removed. + +## Quick triage + +Sentry's own files carry **no** copyright headers — any copyright/license line indicates third-party code. Every file that reaches this skill is in scope — do not skip files based on extension. + +If this file is `THIRD_PARTY_NOTICES.md`, go to the THIRD_PARTY_NOTICES section below. + +For all other files, perform these checks **before** deciding whether to proceed: + +1. **Read the file header** — inspect the first 50 lines. Look for vendored-code signals: `Copyright`, `Licensed under`, `SPDX-License-Identifier`, or vendoring language ("adapted from", "backported from", "based on", "copied from", "derived from", "inspired by", "ported from", "translated from", "vendored"). +2. **Check THIRD_PARTY_NOTICES.md** — search for the file name without extension (e.g. `ANRWatchDog` when reviewing `ANRWatchDog.java`). A match means this is a known vendored file. **Renames:** if the diff is a rename (`similarity index` / `rename from` in the diff, or a delete of one path and add of another with the same content), also search for the **old** basename and read **Scope** sections in matching entries — NOTICES may still reference the previous class or path name. + > **A complete NOTICES entry does NOT end the check.** It confirms the file is vendored and that the NOTICES requirement is satisfied. The file header is a separate, additional requirement — continue to header verification regardless of NOTICES completeness. +3. **Scan the diff** — check for vendored-code signals on both added (`+`) and **removed (`-`)** lines. Removed lines that drop a **required field** (copyright, license name, source URL, vendoring origin) ARE signals. Removed disclaimer/boilerplate lines alone are not. + +**A signal in ANY of these three sources means this is vendored code — proceed to the vendored source file section.** + +A file referenced in THIRD_PARTY_NOTICES.md is ALWAYS vendored, even if its current header has no attribution. + +**If none of the three sources have signals, report no findings and stop.** + +--- + +## If this file is `THIRD_PARTY_NOTICES.md` + +Validate the changed entries using the diff context: + +1. For each added or modified entry, verify it has all required fields: **Source URL**, **License name**, **Copyright**, **Scope** (file paths), and **full license text** in a fenced code block. +2. For each Scope path, verify the file(s) exist. +3. Flag new license types using the same license-tier table as for source files: weak copyleft (LGPL, MPL, EPL) → 🚨 **high**, strong copyleft (GPL) → 🚨 **high**, AGPL → 🚨 **high** (absolute ban, must be removed). Do not use low or medium for copyleft or AGPL. +4. Flag orphaned entries whose Scope files no longer exist. +5. For **removed** entries (lines prefixed with `-` in the diff), check whether the Scope files still exist and still have attribution headers. If they do, the entry must not be removed. +6. Check **copyright consistency** — the Copyright field must match the copyright line inside the embedded license text. Flag mismatches. + +--- + +## If this is a vendored file + +### 1. Check attribution header + +Check each of the following by reading the file header — not NOTICES. Each is an independent yes/no; a "no" is ⚠️ medium regardless of NOTICES completeness: + +- [ ] **Vendoring origin phrase** — explicit wording such as `Adapted from …`, `Based on …`, `Vendored from …`, or a library name. +- [ ] **Copyright line** — e.g. `Copyright (c) 2016 …`, `Copyright 2010 Square, Inc.` +- [ ] **License name** — e.g. `Licensed under the Apache License, Version 2.0`, `The MIT License` +- [ ] **Source URL** — e.g. `https://github.com/…` + +Exact wording and comment style may vary. **Do not flag** missing or changed content that is not one of these four fields. + +**Each field must be physically present in the file header. A complete `THIRD_PARTY_NOTICES.md` entry does not satisfy any required field — both are independently required. Check each of the four fields by reading the file header, not by reasoning from NOTICES.** + +**Not required in the file header** (full text belongs in `THIRD_PARTY_NOTICES.md`, not in every source file): + +- Full license boilerplate (MIT permission paragraph, Apache "Unless required by applicable law…" disclaimer, ASF contributor grant preamble) +- Wording differences vs the NOTICES embedded license text (e.g. shortened Apache header vs canonical ASF phrasing) +- Comment style (`//` vs `/* */`), line wrapping, or extra Sentry modification notes + +Compare the current header against the NOTICES entry **only for the four required fields** — e.g. if NOTICES says MIT by "Salomon BRYS" but the header has no copyright or license name, flag it. If both have copyright + license name but the header omits the Apache disclaimer while NOTICES still has the full text, **do not flag**. + +When comparing against the base branch version (local runs), use the header at that revision for additional context. + +Flag these issues: +- **Header stripped** — file is in NOTICES but current header has none of the four required fields +- **Header truncated** — one or more **required** fields were removed (e.g. copyright line or `Licensed under …` removed) while the file remains vendored +- **Header inconsistent** — a **required** field contradicts NOTICES (wrong copyright holder/year, wrong license name) — not boilerplate or phrasing differences +- **Diff removes required attribution** — removed `-` lines drop a required field or vendoring origin (`Adapted from`, etc.); removing disclaimer/boilerplate lines alone is **not** this + +**Do not report** (no finding — prefer silence): + +- Apache/MIT disclaimer or permission paragraphs removed but all four required fields remain +- Header reworded to a shorter permissive-license form with the same copyright holder and license name +- Header and NOTICES differ only in full license body text (wording or boilerplate, not missing required fields) + +These exceptions apply only when an entry already exists in NOTICES and only to header-vs-NOTICES wording differences. A **missing** NOTICES entry is ⚠️ medium per section 2 — never covered by these exceptions. + +### 2. Check THIRD_PARTY_NOTICES.md entry + +**Severity: always `medium`. Do not output `severity: "low"` for a missing entry even if the attribution header is complete.** + +`THIRD_PARTY_NOTICES.md` is a mandatory legal exhibit that Sentry ships with every SDK distribution. It must enumerate all vendored code regardless of what the source file header says. A missing entry is a distribution-level compliance failure, not a nit. A complete file header does not satisfy the NOTICES requirement — both are mandatory. + +From the NOTICES search in Quick triage: if no matching entry exists, output `severity: "medium"` and flag as ⚠️ Missing THIRD_PARTY_NOTICES.md entry. A valid entry needs: Source URL, License name, Copyright, Scope, full license text. + +### 3. Check license compatibility + +Classify the license per Sentry's Open Source Legal Policy (https://open.sentry.io/licensing/): + +| Tier | Examples | Finding | +|-----------------|-------------------------------------------------|---------------------------------------------| +| Permissive | MIT, BSD, Apache 2.0, ISC, CC0, Unlicense, Zlib | None — license is compatible | +| Weak copyleft | LGPL, MPL, EPL, CDDL | 🚨 **high** — requires review | +| Strong copyleft | GPL, QPL, Sleepycat, OSL | 🚨 **high** — requires legal review | +| AGPL | — | 🚨 **high** — absolute ban, must be removed | +| No license | — | 🚨 **high** — assume no permission | + +**Permissive licenses:** do not report a finding solely because the license is MIT/BSD/Apache/etc. Only flag missing or stripped **required** header fields, or missing/inconsistent `THIRD_PARTY_NOTICES.md` entry. Do not flag disclaimer/boilerplate-only diffs. Copyleft and unlicensed code still get 🚨 findings per the table. + +--- + +## If this is a deleted vendored file + +If the diff deletes a file and the removed lines contained attribution headers, check whether `THIRD_PARTY_NOTICES.md` still references it — the entry should be updated or removed. + +--- + +## Severity guide + +| Level | Use for | +|------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **high** | 🚨 License violations: AGPL, copyleft, unlicensed, no-license code | +| **medium** | ⚠️ Missing **required** header fields, stripped required fields, missing/inconsistent NOTICES entries (even when header is complete), deleted/renamed vendored files needing NOTICES update | +| **low** | 👀 Cosmetic/style differences only (shortened license wording, comment style). **Never** use for a missing NOTICES entry or missing header field — those are always medium. | + +Warden relies on these severity levels when deciding whether to comment on PRs or require changes. Put the severity emoji **only on the finding title** (see Output) so reviewers can triage at a glance. + +## Output + +**No issues → empty response (say nothing).** + +Otherwise, report each finding ordered by severity (most severe first). + +### Emoji placement (required) + +Use the emoji from the severity guide (🚨, ⚠️, or 👀) — not the word `high`, `medium`, or `low`. + +| Field | Emoji? | Example | +|-------------------|--------------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| **Title** | Yes — once, at the start | `⚠️ Copyright line stripped from vendored file header` | +| **Description** | **No** | `**io.sentry.cache.tape.FileObjectQueue** — The Copyright (C) 2010 Square, Inc. line was removed…` (see **Description subject** below) | +| **Verification** | **No** | Evidence steps only | +| **Suggested fix** | **No** | Fix text only | + +**Good (Warden PR comment):** + +``` +Title: ⚠️ Copyright line stripped from vendored file header +Description: **io.sentry.cache.tape.FileObjectQueue** — The `Copyright (C) 2010 Square, Inc.` line was removed from this vendored file's header. Please restore the copyright line. +``` + +**Bad — emoji in the description (never do this):** + +``` +Title: ⚠️ Copyright line stripped from vendored file header +Description: ⚠️ The `Copyright (C) 2010 Square, Inc.` line was removed… +``` + +**Bad — emoji before the class name:** + +``` +Title: ⚠️ Copyright line stripped from vendored file header +Description: ⚠️ **io.sentry.cache.tape.FileObjectQueue** — The copyright line was removed… +``` + +### Description subject (required) + +Every description **must** start with `**** —` (bold subject, space, em dash, space). Pick **one** subject by file type: + +| File type | Subject format | Example | +|-------------------------------------------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------| +| Java / Kotlin source (`.java`, `.kt`) with a top-level type | Fully qualified class name (FQCN) | `**io.sentry.CircularFifoQueue** —` | +| Java / Kotlin with no single clear type (multiple top-level types, unclear which changed) | FQCN of the primary type under review, or repo-relative path if none | `**sentry/src/.../Foo.kt** —` | +| `THIRD_PARTY_NOTICES.md` | `THIRD_PARTY_NOTICES.md — ` | `**THIRD_PARTY_NOTICES.md — Square — Seismic (Apache 2.0)** —` | +| Gradle / other scripts (e.g. `.kts`, `.gradle`) | Repo-relative path from repository root | `**build.gradle.kts** —` | + +- Prefer **FQCN** for `.java` / `.kt` vendored source (derive from `package` + primary public top-level class). Do not use file paths when a FQCN is clear. +- For license-tier / policy issues, include https://open.sentry.io/licensing/ in the description body. + +### Warden runs + +For each finding, set these fields exactly: + +| Field | Value | +|------------------|-------------------------------------------------------------------------------------------------------------------| +| **severity** | `high`, `medium`, or `low` — **never** put emoji here; Warden maps severity from this field, not from the title | +| **title** | ` ` — emoji allowed **only** here (imperative, no class name) | +| **description** | `**** — ` — **plain text only**; subject per **Description subject** above | +| **verification** | Optional evidence steps — plain text only | + +**Description rules (Warden):** + +- **Must** match `**** — …` using the table in **Description subject**. +- **Must not** contain 🚨, ⚠️, 👀, or the words `high`, `medium`, or `low` as severity labels. +- **Must not** repeat the title or paraphrase it with an emoji prefix. + +**Good (NOTICES entry removed while scope files remain):** + +``` +Title: ⚠️ NOTICES entry removed for vendored code still in tree +Description: **THIRD_PARTY_NOTICES.md — Square — Seismic (Apache 2.0)** — The Seismic entry was removed but `io.sentry.android.core.SentryShakeDetector` still has an attribution header. Restore the entry or remove attribution from the scope files. +``` + +**Before submitting findings:** For every finding, confirm `description` does not match `[🚨⚠️👀]` and matches `^\*\*.+\*\* — `. If it contains any emoji, rewrite the description without it. + +### Local / IDE runs + +Use this numbered format — same title vs description split as above: + +``` +1\. **** + **** — + +2\. **** + **** — +``` + +Rules: + +- Put the severity emoji **only** on the title line (`1\. ⚠️ **…**`), never on the description line. +- The description line uses `**** —` per **Description subject** and must not contain 🚨, ⚠️, or 👀. +- **Escape the period** after the number (`1\.` not `1.`) so markdown does not collapse entries into a tight list. +- Leave an empty line between each numbered finding. diff --git a/.claude/skills/check-code-attribution/validation-tests/EXPECTED.json b/.claude/skills/check-code-attribution/validation-tests/EXPECTED.json new file mode 100644 index 00000000000..a82637b84e2 --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/EXPECTED.json @@ -0,0 +1,53 @@ +[ + { + "id": "header-complete-and-notice-present", + "file": "HeaderCompleteAndNoticePresent.java", + "expectFinding": false, + "notes": "Header matches catalog entry" + }, + { + "id": "header-complete-but-notice-missing", + "file": "HeaderCompleteButNoticeMissing.java", + "expectFinding": true, + "isolated": true, + "notes": "Full header; no catalog / root NOTICES entry. Isolated: prompt-cache priming in a concurrent batch suppresses the missing-NOTICES finding below medium." + }, + { + "id": "header-missing-but-notice-present", + "file": "HeaderMissingButNoticePresent.java", + "expectFinding": true, + "isolated": true, + "notes": "NOTICES entry claims file is vendored but file has no attribution header. Isolated: a complete NOTICES entry suppresses the missing-header finding in a concurrent batch." + }, + { + "id": "header-fully-stripped", + "file": "HeaderFullyStripped.java", + "expectFinding": true, + "notes": "Header has no required attribution fields" + }, + { + "id": "header-partially-stripped", + "file": "HeaderPartiallyStripped.java", + "expectFinding": true, + "notes": "Adapted from + URL only; no copyright or license name" + }, + { + "id": "header-missing-non-essential-info", + "file": "HeaderMissingNonEssentialInfo.java", + "expectFinding": false, + "notes": "All four required fields present; no license boilerplate — boilerplate is not required in the header" + }, + { + "id": "header-vs-notice-mismatch", + "file": "THIRD_PARTY_NOTICES.md", + "expectFinding": true, + "isolated": true, + "notes": "Copyright in metadata field does not match embedded license text. Isolated: mismatch finding needs an independent assertion free of interference from other NOTICES changes." + }, + { + "id": "new-license-type", + "file": "NewLicenseType.java", + "expectFinding": true, + "notes": "AGPL v3 license in file header — absolute ban, must be removed" + } +] diff --git a/.claude/skills/check-code-attribution/validation-tests/README.md b/.claude/skills/check-code-attribution/validation-tests/README.md new file mode 100644 index 00000000000..99fb42a6836 --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/README.md @@ -0,0 +1,86 @@ +# Attribution skill validation tests + +Self-contained samples for validating `check-code-attribution` without touching production SDK sources. + + +## Run the tests + +```bash +./check-code-attribution-tests.sh +``` + +Requires Node.js and a Warden provider (see **Warden CLI** below). + +In practice, straight command line runs tend to be a bit flakier than asking Claude Code to run the tests for you. + +## Local development + +### Discovering changed files + +When running `/check-code-attribution` outside Warden, list files changed on the current branch vs the base branch, then apply the same exclusions as `ignorePaths` in `warden.toml`: + +```bash +MB=$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main) +git diff --name-only "${MB}"..HEAD +``` + +### Warden CLI + +Warden does **not** use Cursor auth. Before running Warden locally, configure a provider (same model family as `warden.toml`, or override with `-m`): + +```bash +# Option A: Anthropic API key (matches CI model in warden.toml) +export WARDEN_ANTHROPIC_API_KEY=sk-ant-... # or: export ANTHROPIC_API_KEY=sk-ant-... + +# Option B: Pi OAuth / API key store (~/.pi/agent/auth.json) +npx pi # then run /login and pick Anthropic (or another provider) + +# Option C: Different provider for a one-off run +export WARDEN_OPENAI_API_KEY=sk-... +npx @sentry/warden origin/main..HEAD --skill check-code-attribution -m openai/gpt-5.5 -vv +``` + +```bash +npx @sentry/warden origin/main..HEAD --skill check-code-attribution -vv +``` + +## Layout + +- `EXPECTED.json` — scenario IDs and expected outcomes (single source of truth). +- `THIRD_PARTY_NOTICES.catalog.md` — NOTICES-style entries for validation class names. +- `scenarios/` — `.java` files and `THIRD_PARTY_NOTICES.mismatch-snippet.md` (copyright-mismatch fixture). +- `check-code-attribution-tests.sh` — runs Warden on a temp branch and asserts per-scenario pass/fail. +- `assert-scenarios.mjs` — validation driver (`list-isolated`, `routing-set`, `assert` subcommands); parses Warden JSONL and checks outcomes from `EXPECTED.json`. + +### assert-scenarios.mjs commands + +```bash +node assert-scenarios.mjs validate EXPECTED.json scenarios/ # pre-flight (no API); run automatically by the shell script +node assert-scenarios.mjs list-isolated EXPECTED.json # idfile per isolated scenario +node assert-scenarios.mjs list-main-java EXPECTED.json scenarios/ # .java files for the main Warden batch +node assert-scenarios.mjs routing-set routing.json # update id → Warden JSONL path +node assert-scenarios.mjs assert EXPECTED.json routing.json +``` + +Warden runs are limited to 300s. On macOS the script uses `gtimeout` (from `brew install coreutils`) when available, otherwise GNU `timeout`, otherwise `perl` with `alarm`. + +## Add a scenario + +1. Add `scenarios/.java`. +2. Add or omit a catalog entry in `THIRD_PARTY_NOTICES.catalog.md`. +3. Add an entry to `EXPECTED.json`. +4. **Isolation (if needed):** If the scenario relies on a finding that could be suppressed by Anthropic prompt-cache priming when analyzed alongside many other files (e.g. a missing-NOTICES entry, or a missing header on a file that has a complete NOTICES entry), add `"isolated": true` to its `EXPECTED.json` entry. The test script creates a dedicated worktree for each isolated scenario automatically — no changes to the script itself are needed. + +## Validation (maintainers) + +Test samples live under `validation-tests/` and are excluded from normal skill runs via `.claude/**` in `warden.toml`. + +```bash +.claude/skills/check-code-attribution/validation-tests/check-code-attribution-tests.sh +``` + +Expected outcomes are in `EXPECTED.json`. The script creates isolated git worktrees, runs Warden with `--report-on medium --json`, and asserts per-scenario pass/fail. Scenarios marked `"isolated": true` in `EXPECTED.json` each get their own worktree to avoid Anthropic prompt-cache priming that can suppress findings below medium in concurrent batches. Exit 0 = all pass. + +When manually reviewing a file under `scenarios/`, search `THIRD_PARTY_NOTICES.catalog.md` in addition to root `THIRD_PARTY_NOTICES.md` (Quick triage step 2 in `SKILL.md`). + +Non-Java fixtures required by the test script are listed in `REQUIRED_SCENARIO_FIXTURES` in `assert-scenarios.mjs`; pre-flight `validate` fails if any are missing. diff --git a/.claude/skills/check-code-attribution/validation-tests/THIRD_PARTY_NOTICES.catalog.md b/.claude/skills/check-code-attribution/validation-tests/THIRD_PARTY_NOTICES.catalog.md new file mode 100644 index 00000000000..478d0b06313 --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/THIRD_PARTY_NOTICES.catalog.md @@ -0,0 +1,130 @@ +# Test THIRD_PARTY_NOTICES catalog (not shipped) + +Used only when validating `check-code-attribution` against `validation-tests/scenarios/**`. +Grep this file in addition to the repository root `THIRD_PARTY_NOTICES.md`. + +--- + +## Example — HeaderFullyStripped (MIT) + +**Source:** https://github.com/example/attribution-fixtures
+**License:** MIT License
+**Copyright:** Copyright (c) 2016 Example Author + +### Scope + +Attribution validation sample. The code resides in `io.sentry.skills.verification.HeaderFullyStripped` (`validation-tests/scenarios/HeaderFullyStripped.java`). + +``` +MIT License + +Copyright (c) 2016 Example Author + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + +--- + +## Example — HeaderMissingButNoticePresent (Apache 2.0) + +**Source:** https://github.com/example/notices-without-header
+**License:** Apache License 2.0
+**Copyright:** Copyright 2023 Example Corp. + +### Scope + +Attribution validation sample. The code resides in `io.sentry.skills.verification.HeaderMissingButNoticePresent`. + +``` +Copyright 2023 Example Corp. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## Example — HeaderMissingNonEssentialInfo (MIT) + +**Source:** https://github.com/example/examplelib
+**License:** MIT License
+**Copyright:** Copyright 2020 Example Corp. + +### Scope + +Attribution validation sample. The code resides in `io.sentry.skills.verification.HeaderMissingNonEssentialInfo`. + +``` +MIT License + +Copyright (c) 2020 Example Corp. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + +--- + +## Example — HeaderCompleteAndNoticePresent (Apache 2.0) + +**Source:** https://github.com/example/something
+**License:** Apache License 2.0
+**Copyright:** Copyright 2020 Example Authors + +### Scope + +Attribution validation sample. The code resides in `io.sentry.skills.verification.HeaderCompleteAndNoticePresent`. + +``` +Copyright 2020 Example Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` diff --git a/.claude/skills/check-code-attribution/validation-tests/assert-scenarios.mjs b/.claude/skills/check-code-attribution/validation-tests/assert-scenarios.mjs new file mode 100755 index 00000000000..3ff4cce9980 --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/assert-scenarios.mjs @@ -0,0 +1,401 @@ +#!/usr/bin/env node +/** + * Validation driver for check-code-attribution scenario tests. + * + * Usage: + * node assert-scenarios.mjs validate + * node assert-scenarios.mjs list-isolated + * node assert-scenarios.mjs list-main-java + * node assert-scenarios.mjs routing-set + * node assert-scenarios.mjs assert + * + * routing.json maps scenario id to Warden JSONL output path, e.g. { "main": "/tmp/..." }. + * Non-isolated scenarios use the "main" entry when no dedicated id is present. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const ISOLATED_FILE_JAVA = /\.java$/i; +const ISOLATED_FILE_NOTICES = 'THIRD_PARTY_NOTICES.md'; + +/** Non-Java fixtures under scenarios/ that check-code-attribution-tests.sh requires. */ +const REQUIRED_SCENARIO_FIXTURES = [ + 'THIRD_PARTY_NOTICES.mismatch-snippet.md', +]; + +export function loadExpected(expectedPath) { + return JSON.parse(fs.readFileSync(expectedPath, 'utf8')); +} + +export function listIsolated(scenarios) { + return scenarios.filter((s) => s.isolated); +} + +/** Repo-relative path normalization for Warden JSONL matching. */ +export function normalizeRepoPath(filePath) { + if (!filePath) return filePath; + return filePath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+/g, '/'); +} + +/** True when a Warden-reported path refers to the expected scenario file. */ +export function pathMatchesWardenFile(reportedPath, wardenFile) { + const reported = normalizeRepoPath(reportedPath); + const expected = normalizeRepoPath(wardenFile); + if (reported === expected) return true; + const base = expected.split('/').pop(); + return base != null && reported.endsWith(`/${base}`); +} + +export function findingCountForFile(fileMap, wardenFile) { + const expected = normalizeRepoPath(wardenFile); + if (fileMap[expected] != null) return fileMap[expected]; + for (const [key, count] of Object.entries(fileMap)) { + if (pathMatchesWardenFile(key, wardenFile)) return count; + } + return 0; +} + +export function findingsForFile(findings, wardenFile) { + return findings.filter( + (f) => f.location && pathMatchesWardenFile(f.location.path, wardenFile), + ); +} + +export function listMainBatchJava(scenarios, scenariosDir) { + const isolatedJava = new Set( + listIsolated(scenarios) + .map((s) => s.file) + .filter((file) => ISOLATED_FILE_JAVA.test(file)), + ); + return fs + .readdirSync(scenariosDir) + .filter((name) => name.endsWith('.java') && !isolatedJava.has(name)) + .sort(); +} + +/** + * @returns {string[]} validation error messages (empty = ok) + */ +export function validateExpected(scenarios, scenariosDir) { + const errors = []; + + if (!Array.isArray(scenarios)) { + return ['EXPECTED.json must be a JSON array']; + } + + const ids = new Set(); + const expectedJava = new Set(); + + for (const [index, s] of scenarios.entries()) { + const label = `entry ${index}`; + if (!s || typeof s !== 'object') { + errors.push(`${label}: must be an object`); + continue; + } + if (typeof s.id !== 'string' || !s.id) { + errors.push(`${label}: missing or empty "id"`); + } else { + if (ids.has(s.id)) errors.push(`duplicate id "${s.id}"`); + ids.add(s.id); + if (s.id === 'main') { + errors.push(`id "main" is reserved for routing.json`); + } + } + if (typeof s.file !== 'string' || !s.file) { + errors.push(`${label}: missing or empty "file"`); + } else if (ISOLATED_FILE_JAVA.test(s.file)) { + expectedJava.add(s.file); + const onDisk = path.join(scenariosDir, s.file); + if (!fs.existsSync(onDisk)) { + errors.push(`${s.id}: scenarios/${s.file} does not exist`); + } + } else if (s.file !== ISOLATED_FILE_NOTICES) { + errors.push( + `${s.id}: unsupported file "${s.file}" (use *.java or ${ISOLATED_FILE_NOTICES})`, + ); + } + if (typeof s.expectFinding !== 'boolean') { + errors.push(`${s.id ?? label}: "expectFinding" must be a boolean`); + } + if (s.isolated) { + if ( + !ISOLATED_FILE_JAVA.test(s.file) && + s.file !== ISOLATED_FILE_NOTICES + ) { + errors.push( + `${s.id}: isolated scenarios must use *.java or ${ISOLATED_FILE_NOTICES}`, + ); + } + } + } + + let diskEntries = []; + try { + diskEntries = fs.readdirSync(scenariosDir); + } catch (e) { + errors.push(`cannot read scenarios dir ${scenariosDir}: ${e.message}`); + return errors; + } + + const diskJava = diskEntries.filter((n) => n.endsWith('.java')); + for (const name of diskJava) { + if (!expectedJava.has(name)) { + errors.push(`scenarios/${name} has no matching entry in EXPECTED.json`); + } + } + + for (const name of REQUIRED_SCENARIO_FIXTURES) { + const onDisk = path.join(scenariosDir, name); + if (!fs.existsSync(onDisk)) { + errors.push(`scenarios/${name} is required but missing`); + } + } + + const diskNonJava = diskEntries.filter( + (n) => !n.endsWith('.java') && fs.statSync(path.join(scenariosDir, n)).isFile(), + ); + for (const name of diskNonJava) { + if (!REQUIRED_SCENARIO_FIXTURES.includes(name)) { + errors.push( + `scenarios/${name} is not listed in REQUIRED_SCENARIO_FIXTURES (update assert-scenarios.mjs)`, + ); + } + } + + if (listMainBatchJava(scenarios, scenariosDir).length === 0) { + errors.push('main Warden batch needs at least one non-isolated .java scenario'); + } + + return errors; +} + +export function parseWardenJsonl(jsonlPath) { + /** @type {Record} */ + const fileMap = {}; + const allFindings = []; + try { + const raw = fs.readFileSync(jsonlPath, 'utf8').trim(); + if (!raw) return { fileMap, findings: [] }; + const records = raw + .split('\n') + .filter((l) => l.trim()) + .map((l) => JSON.parse(l)); + for (const record of records) { + const file = record.chunk && record.chunk.file; + if (!file) continue; + const normalized = normalizeRepoPath(file); + const recordFindings = record.findings || []; + fileMap[normalized] = (fileMap[normalized] || 0) + recordFindings.length; + for (const f of recordFindings) { + allFindings.push({ + ...f, + location: f.location || { path: normalized, startLine: 1 }, + }); + } + } + } catch (e) { + console.error( + 'ERROR: Could not parse Warden output from ' + jsonlPath + ':', + e.message, + ); + process.exit(2); + } + return { fileMap, findings: allFindings }; +} + +export function routingSet(routingPath, id, jsonlPath) { + const routing = JSON.parse(fs.readFileSync(routingPath, 'utf8')); + routing[id] = jsonlPath; + fs.writeFileSync(routingPath, JSON.stringify(routing)); +} + +function wardenFileForScenario(destPkg, scenario) { + return scenario.file === ISOLATED_FILE_NOTICES + ? ISOLATED_FILE_NOTICES + : `${destPkg}/${scenario.file}`; +} + +function loadRouting(routingPath) { + /** @type {Record} */ + let routing; + try { + routing = JSON.parse(fs.readFileSync(routingPath, 'utf8')); + } catch (e) { + console.error(`ERROR: Could not read routing file ${routingPath}:`, e.message); + process.exit(2); + } + + if (typeof routing.main !== 'string' || !routing.main) { + console.error('ERROR: routing.json must include a non-empty "main" JSONL path.'); + process.exit(2); + } + return routing; +} + +function cmdValidate(expectedPath, scenariosDir) { + if (!expectedPath || !scenariosDir) { + console.error( + 'Usage: node assert-scenarios.mjs validate ', + ); + process.exit(2); + } + const errors = validateExpected(loadExpected(expectedPath), scenariosDir); + if (errors.length > 0) { + console.error('EXPECTED.json validation failed:'); + for (const err of errors) console.error(` - ${err}`); + process.exit(1); + } + console.log('EXPECTED.json OK'); +} + +function cmdListIsolated(expectedPath) { + for (const s of listIsolated(loadExpected(expectedPath))) { + process.stdout.write(`${s.id}\t${s.file}\n`); + } +} + +function cmdListMainJava(expectedPath, scenariosDir) { + if (!expectedPath || !scenariosDir) { + console.error( + 'Usage: node assert-scenarios.mjs list-main-java ', + ); + process.exit(2); + } + for (const name of listMainBatchJava(loadExpected(expectedPath), scenariosDir)) { + process.stdout.write(`${name}\n`); + } +} + +function cmdRoutingSet(routingPath, id, jsonlPath) { + if (!routingPath || !id || !jsonlPath) { + console.error( + 'Usage: node assert-scenarios.mjs routing-set ', + ); + process.exit(2); + } + routingSet(routingPath, id, jsonlPath); +} + +function cmdAssert(expectedPath, destPkg, routingPath) { + if (!expectedPath || !destPkg || !routingPath) { + console.error( + 'Usage: node assert-scenarios.mjs assert ', + ); + process.exit(2); + } + + const routing = loadRouting(routingPath); + const scenarios = loadExpected(expectedPath); + + /** @type {Record>} */ + const parsed = {}; + function getSource(id) { + const jsonlPath = routing[id] ?? routing.main; + if (!parsed[jsonlPath]) parsed[jsonlPath] = parseWardenJsonl(jsonlPath); + return parsed[jsonlPath]; + } + + const GREEN = '\x1b[32m'; + const RED = '\x1b[31m'; + const RESET = '\x1b[0m'; + + const failures = []; + let pass = 0; + + for (const s of scenarios) { + if (s.isolated && !routing[s.id]) { + console.error( + `ERROR: isolated scenario "${s.id}" has no routing entry (missing Warden run?)`, + ); + process.exit(2); + } + + const wardenFile = wardenFileForScenario(destPkg, s); + const source = getSource(s.id); + const count = findingCountForFile(source.fileMap, wardenFile); + const passed = s.expectFinding ? count > 0 : count === 0; + + if (passed) { + console.log(`${GREEN}PASS${RESET} ${s.id}`); + pass++; + } else { + const reason = s.expectFinding + ? 'expected finding (>= medium), got none' + : `expected no finding (>= medium), got ${count}`; + console.log(`${RED}FAIL${RESET} ${s.id} (${reason})`); + + failures.push({ + id: s.id, + findings: findingsForFile(source.findings, wardenFile), + }); + } + } + + const total = scenarios.length; + console.log(''); + console.log(`${total} scenarios: ${pass} passed, ${total - pass} failed`); + + if (failures.length > 0) { + console.log(''); + console.log('Warden output'); + console.log('══════════════════════'); + + for (const { id, findings } of failures) { + console.log(''); + console.log(id); + console.log('-'.repeat(id.length)); + if (findings.length === 0) { + console.log('(Warden produced no findings for this file)'); + } else { + for (const f of findings) { + console.log(f.title); + if (f.description) console.log(f.description); + if (f.verification) console.log('\nVerification: ' + f.verification); + console.log(''); + } + } + } + + process.exit(1); + } +} + +function usage() { + console.error(`Usage: + node assert-scenarios.mjs validate + node assert-scenarios.mjs list-isolated + node assert-scenarios.mjs list-main-java + node assert-scenarios.mjs routing-set + node assert-scenarios.mjs assert `); + process.exit(2); +} + +function main() { + const [, , cmd, ...args] = process.argv; + switch (cmd) { + case 'validate': + cmdValidate(args[0], args[1]); + break; + case 'list-isolated': + if (!args[0]) usage(); + cmdListIsolated(args[0]); + break; + case 'list-main-java': + cmdListMainJava(args[0], args[1]); + break; + case 'routing-set': + cmdRoutingSet(args[0], args[1], args[2]); + break; + case 'assert': + cmdAssert(args[0], args[1], args[2]); + break; + default: + usage(); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/.claude/skills/check-code-attribution/validation-tests/check-code-attribution-tests.sh b/.claude/skills/check-code-attribution/validation-tests/check-code-attribution-tests.sh new file mode 100755 index 00000000000..090acbe129b --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/check-code-attribution-tests.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +# check-code-attribution-tests.sh — Validate the check-code-attribution skill against synthetic scenarios. +# +# Usage: +# ./check-code-attribution-tests.sh [--help] +# +# What it does: +# 1. Validates EXPECTED.json and scenario fixtures (no API calls). +# 2. Creates an isolated git worktree on a temp branch from HEAD. +# 3. Creates a diff (non-isolated .java files, NOTICES catalog, mismatch snippet), +# commits, and runs Warden on the main batch. +# 4. Scenarios marked "isolated" in EXPECTED.json each get their own worktree and Warden +# run to avoid prompt-cache priming that can suppress findings in concurrent batches. +# 5. Asserts per-scenario pass/fail against EXPECTED.json (>= medium findings only). +# 6. Prints Warden's actual output for each failing scenario. +# 7. Cleans up all worktrees. +# +# Requires: +# - Node.js / npx +# - One of: WARDEN_ANTHROPIC_API_KEY, ANTHROPIC_API_KEY, or Pi OAuth config +# (see validation-tests/README.md "Warden CLI" section for setup options) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +SCENARIOS_DIR="$SCRIPT_DIR/scenarios" +CATALOG="$SCRIPT_DIR/THIRD_PARTY_NOTICES.catalog.md" +EXPECTED_JSON="$SCRIPT_DIR/EXPECTED.json" +VALIDATION="$SCRIPT_DIR/assert-scenarios.mjs" +MISMATCH_SNIPPET="$SCENARIOS_DIR/THIRD_PARTY_NOTICES.mismatch-snippet.md" + +# Destination path inside the worktree — must not appear in warden.toml ignorePaths. +DEST_PACKAGE_PATH="sentry/src/test/java/io/sentry/skills/verification" + +# Warden wall-clock limit (seconds). +TIMEOUT_SEC=300 + +die() { echo "ERROR: $*" >&2; exit 1; } + +show_usage() { + cat <<'EOF' +Usage: check-code-attribution-tests.sh [--help] + +Validates the check-code-attribution skill against all scenarios in EXPECTED.json. +Runs Warden on a temporary branch and asserts per-scenario pass/fail (>= medium findings). + +Prerequisites: + - Node.js (npx) + - API key: WARDEN_ANTHROPIC_API_KEY or ANTHROPIC_API_KEY + (or Pi OAuth: npx pi && /login — see README.md "Warden CLI" section) + - Wall-clock limit: gtimeout (brew install coreutils), GNU timeout, or perl +EOF +} + +[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { show_usage; exit 0; } + +# --- prereq checks --- + +command -v node >/dev/null 2>&1 || die "node not found — install Node.js." +command -v npx >/dev/null 2>&1 || die "npx not found — install Node.js." +command -v git >/dev/null 2>&1 || die "git not found." + +# macOS: GNU timeout is `gtimeout` from coreutils; fall back to perl alarm. +TIMEOUT_CMD=() +if command -v gtimeout >/dev/null 2>&1; then + TIMEOUT_CMD=(gtimeout "$TIMEOUT_SEC") +elif command -v timeout >/dev/null 2>&1; then + TIMEOUT_CMD=(timeout "$TIMEOUT_SEC") +elif command -v perl >/dev/null 2>&1; then + TIMEOUT_CMD=(perl -e 'alarm shift; exec @ARGV' "$TIMEOUT_SEC") +else + die "Need gtimeout (brew install coreutils), GNU timeout, or perl for Warden wall-clock limit" +fi + +if [[ -z "${WARDEN_ANTHROPIC_API_KEY:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then + if [[ ! -f "$HOME/.pi/agent/auth.json" ]]; then + die "No API key found. Set WARDEN_ANTHROPIC_API_KEY, ANTHROPIC_API_KEY, or run: npx pi && /login" + fi +fi + +node "$VALIDATION" validate "$EXPECTED_JSON" "$SCENARIOS_DIR" + +# --- cleanup tracking --- + +declare -a WORKTREES=() +declare -a BRANCHES=() +declare -a JSON_FILES=() + +cleanup() { + for wt in "${WORKTREES[@]+"${WORKTREES[@]}"}"; do + git -C "$REPO_ROOT" worktree remove --force "$wt" 2>/dev/null || true + done + for b in "${BRANCHES[@]+"${BRANCHES[@]}"}"; do + git -C "$REPO_ROOT" branch -D "$b" 2>/dev/null || true + done + (( ${#JSON_FILES[@]} )) && rm -f "${JSON_FILES[@]}" +} +trap cleanup EXIT + +# --- resolve base commit --- +# Branch from HEAD so the worktree includes the current skill definition. + +BASE=$(git -C "$REPO_ROOT" rev-parse HEAD || die "Cannot resolve HEAD.") +TS=$(date +%s) + +# --- helpers --- + +# Commits paths in a validation worktree with consistent author metadata. +# Usage: git_commit_in_worktree [path...] +git_commit_in_worktree() { + local worktree="$1" message="$2" + shift 2 + if (($# > 0)); then + git -C "$worktree" add "$@" + fi + git -C "$worktree" \ + -c user.email="ci@sentry.io" \ + -c user.name="Validation Test" \ + commit --quiet -m "$message" +} + +# Creates a git worktree from $BASE and commits the NOTICES catalog as the Warden +# analysis base — so only fixture changes appear in the diff Warden analyzes. +# Prints the catalog-commit SHA to stdout. +setup_catalog_base() { + local worktree="$1" branch="$2" + git -C "$REPO_ROOT" worktree add --quiet "$worktree" "$BASE" -b "$branch" + printf '\n' >> "$worktree/THIRD_PARTY_NOTICES.md" + sed "s|validation-tests/scenarios/|${DEST_PACKAGE_PATH}/|g" \ + "$CATALOG" >> "$worktree/THIRD_PARTY_NOTICES.md" + git_commit_in_worktree "$worktree" "test: apply NOTICES catalog [skip ci]" \ + THIRD_PARTY_NOTICES.md + git -C "$worktree" rev-parse HEAD +} + +# Appends the mismatch snippet to THIRD_PARTY_NOTICES.md, stripping the fixture's +# prose header so only the NOTICES entry itself lands in the file. +append_mismatch_snippet() { + local worktree="$1" + printf '\n' >> "$worktree/THIRD_PARTY_NOTICES.md" + sed '1,/^---$/d' "$MISMATCH_SNIPPET" >> "$worktree/THIRD_PARTY_NOTICES.md" +} + +# Runs Warden and writes JSON output to the given file. +run_warden() { + local base="$1" worktree="$2" json_out="$3" label="$4" + echo "Running Warden on ${base:0:7}..HEAD ($label)..." + : > "$json_out" + if ! "${TIMEOUT_CMD[@]}" npx @sentry/warden "${base}..HEAD" \ + --skill check-code-attribution \ + --fail-on off \ + --report-on medium \ + --json \ + -C "$worktree" \ + > "$json_out"; then + if [[ ! -s "$json_out" ]]; then + die "Warden failed for $label with no JSON output (check API key, network, and Warden logs)." + fi + die "Warden exited with an error for $label but left partial JSON in $json_out." + fi + [[ -s "$json_out" ]] || die "Warden succeeded but produced no JSON output for $label." +} + +# --- main worktree: non-isolated scenarios --- +# Isolated .java files are omitted here; they get dedicated worktrees below. + +echo "Creating worktrees from $(git -C "$REPO_ROOT" rev-parse --short "$BASE")..." +echo "" + +MAIN_WORKTREE=$(mktemp -d) +MAIN_BRANCH="validation-main-${TS}" +MAIN_JSON=$(mktemp) +ROUTING_JSON_FILE=$(mktemp) +echo '{}' > "$ROUTING_JSON_FILE" +WORKTREES+=("$MAIN_WORKTREE") +BRANCHES+=("$MAIN_BRANCH") +JSON_FILES+=("$MAIN_JSON" "$ROUTING_JSON_FILE") + +MAIN_BASE=$(setup_catalog_base "$MAIN_WORKTREE" "$MAIN_BRANCH") + +DEST_DIR="$MAIN_WORKTREE/$DEST_PACKAGE_PATH" +mkdir -p "$DEST_DIR" + +shopt -s nullglob +copied=0 +while IFS= read -r java_file; do + cp "$SCENARIOS_DIR/$java_file" "$DEST_DIR/" + copied=$((copied + 1)) +done < <(node "$VALIDATION" list-main-java "$EXPECTED_JSON" "$SCENARIOS_DIR") +echo "Copied ${copied} scenario files → $DEST_PACKAGE_PATH/ (non-isolated batch)" +append_mismatch_snippet "$MAIN_WORKTREE" +git_commit_in_worktree "$MAIN_WORKTREE" \ + "test: add check-code-attribution validation fixtures [skip ci]" \ + "$DEST_PACKAGE_PATH" THIRD_PARTY_NOTICES.md + +run_warden "$MAIN_BASE" "$MAIN_WORKTREE" "$MAIN_JSON" "main" +node "$VALIDATION" routing-set "$ROUTING_JSON_FILE" main "$MAIN_JSON" + +# --- isolated worktrees: one per scenario marked "isolated" in EXPECTED.json --- +# +# Scenarios where Anthropic prompt-cache priming can suppress findings in a concurrent +# batch get their own worktree and Warden run. EXPECTED.json is the single source of +# truth for which scenarios need isolation — add "isolated": true there, not here. +# Java isolates omit the mismatch snippet; the NOTICES mismatch scenario adds it alone. + +while IFS=$'\t' read -r id file; do + worktree=$(mktemp -d) + branch="validation-isolated-${TS}-${id//[^a-zA-Z0-9]/-}" + json=$(mktemp) + WORKTREES+=("$worktree") + BRANCHES+=("$branch") + JSON_FILES+=("$json") + + base=$(setup_catalog_base "$worktree" "$branch") + + commit_paths=() + if [[ "$file" == *.java ]]; then + dest_dir="$worktree/$DEST_PACKAGE_PATH" + mkdir -p "$dest_dir" + cp "$SCENARIOS_DIR/$file" "$dest_dir/" + commit_paths=("$DEST_PACKAGE_PATH") + elif [[ "$file" == "THIRD_PARTY_NOTICES.md" ]]; then + append_mismatch_snippet "$worktree" + commit_paths=(THIRD_PARTY_NOTICES.md) + else + die "Unsupported isolated scenario file: $file (id: $id)" + fi + + git_commit_in_worktree "$worktree" "test: isolated fixture for $id [skip ci]" \ + "${commit_paths[@]}" + + echo "" + run_warden "$base" "$worktree" "$json" "$id" + node "$VALIDATION" routing-set "$ROUTING_JSON_FILE" "$id" "$json" + +done < <(node "$VALIDATION" list-isolated "$EXPECTED_JSON") + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# --- assert per-scenario --- +# +# ROUTING_JSON_FILE maps scenario id → Warden JSONL path; non-isolated scenarios use "main". + +node "$VALIDATION" assert "$EXPECTED_JSON" "$DEST_PACKAGE_PATH" "$ROUTING_JSON_FILE" diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteAndNoticePresent.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteAndNoticePresent.java new file mode 100644 index 00000000000..63727be1d5c --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteAndNoticePresent.java @@ -0,0 +1,19 @@ +/* + * Adapted from https://github.com/example/something + * + * Copyright 2020 Example Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package io.sentry.skills.verification; + +public final class HeaderCompleteAndNoticePresent { + + public int sum(int a, int b) { + return a + b; + } +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteButNoticeMissing.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteButNoticeMissing.java new file mode 100644 index 00000000000..081d1848300 --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderCompleteButNoticeMissing.java @@ -0,0 +1,17 @@ +/* + * Adapted from https://github.com/example + * + * Copyright 2024 Example Authors + * + * Licensed under the MIT License + * + * https://github.com/example/something + */ +package io.sentry.skills.verification; + +public final class HeaderCompleteButNoticeMissing { + + public boolean ok() { + return true; + } +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderFullyStripped.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderFullyStripped.java new file mode 100644 index 00000000000..6973848c61e --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderFullyStripped.java @@ -0,0 +1,7 @@ +/* Attribution stripped — fixture for check-code-attribution validation only. */ +package io.sentry.skills.verification; + +public final class HeaderFullyStripped { + + public void run() {} +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingButNoticePresent.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingButNoticePresent.java new file mode 100644 index 00000000000..5c4953ea3ad --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingButNoticePresent.java @@ -0,0 +1,8 @@ +package io.sentry.skills.verification; + +public final class HeaderMissingButNoticePresent { + + public int compute(int x) { + return x * 2; + } +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingNonEssentialInfo.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingNonEssentialInfo.java new file mode 100644 index 00000000000..c524a2593a4 --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderMissingNonEssentialInfo.java @@ -0,0 +1,12 @@ +// Adapted from ExampleLib. +// Copyright 2020 Example Corp. +// Licensed under the MIT License. +// https://github.com/example/examplelib +package io.sentry.skills.verification; + +public final class HeaderMissingNonEssentialInfo { + + public int compute(int x) { + return x + 1; + } +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderPartiallyStripped.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderPartiallyStripped.java new file mode 100644 index 00000000000..0389934d94a --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/HeaderPartiallyStripped.java @@ -0,0 +1,10 @@ +// Adapted from Example RateLimiter. +// https://github.com/example +package io.sentry.skills.verification; + +public final class HeaderPartiallyStripped { + + public synchronized boolean tryAcquire() { + return true; + } +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/NewLicenseType.java b/.claude/skills/check-code-attribution/validation-tests/scenarios/NewLicenseType.java new file mode 100644 index 00000000000..e148f5a1a4f --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/NewLicenseType.java @@ -0,0 +1,10 @@ +// Adapted from ExampleLib. +// Copyright 2020 Example Corp. +// Licensed under the GNU Affero General Public License v3.0. +// https://github.com/example/agpl-lib +package io.sentry.skills.verification; + +public final class NewLicenseType { + + public void run() {} +} diff --git a/.claude/skills/check-code-attribution/validation-tests/scenarios/THIRD_PARTY_NOTICES.mismatch-snippet.md b/.claude/skills/check-code-attribution/validation-tests/scenarios/THIRD_PARTY_NOTICES.mismatch-snippet.md new file mode 100644 index 00000000000..5a9b87285df --- /dev/null +++ b/.claude/skills/check-code-attribution/validation-tests/scenarios/THIRD_PARTY_NOTICES.mismatch-snippet.md @@ -0,0 +1,37 @@ +# Snippet fixture — MismatchLib entry for the isolated mismatch worktree. +# header-vs-notice-mismatch: copyright in metadata field does not match embedded license text. + +--- + +## Example — MismatchLib (MIT) + +**Source:** https://github.com/example/mismatch
+**License:** MIT License
+**Copyright:** Copyright (c) 2020 Wrong Holder + +### Scope + +Validation sample only. The code resides in `io.sentry.skills.verification.MismatchLib`. + +``` +MIT License + +Copyright (c) 2016 Correct Holder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` diff --git a/.claude/skills/create-java-pr/SKILL.md b/.claude/skills/create-java-pr/SKILL.md new file mode 100644 index 00000000000..17b5839d88a --- /dev/null +++ b/.claude/skills/create-java-pr/SKILL.md @@ -0,0 +1,217 @@ +--- +name: create-java-pr +description: Create a pull request in sentry-java. Use when asked to "create pr", "prepare pr", "prep pr", "open pr", "ready for pr", "prepare for review", "finalize changes". Handles branch creation, code formatting, API dump, committing, pushing, PR creation, changelog, and stacked PRs. +--- + +# Create Pull Request (sentry-java) + +Prepare local changes and create a pull request for the sentry-java repo. + +**For stacked PRs:** read `references/stacked-prs.md` before proceeding. It is the source of truth for +stack structure, title naming, stack list format, and merge strategy. + +## Step 0: Determine PR Type From Git Branch Context + +Infer PR type from the current branch before asking the user. + +1. Get current branch: + +```bash +git branch --show-current +``` + +2. Apply these rules: + +- **If branch is `main` or `master`**: default to a **standalone PR**. + - Do **not** assume stack mode from `main`. + - Only use stack mode if the user explicitly asks for a stacked PR. +- **If branch is not `main`/`master`**: + - Check whether that branch already has a PR and what its base is: + ```bash + gh pr list --head "$(git branch --show-current)" --json number,baseRefName,title --jq '.[0]' + ``` + - If that branch PR exists and `baseRefName` is **not** `main`/`master`, treat the work as a **stacked PR context**. + - If that branch PR exists and `baseRefName` **is** `main`/`master`, also check whether other PRs target the current branch: + ```bash + gh pr list --base "$(git branch --show-current)" --json number,headRefName,title + ``` + - If there are downstream PRs, treat this as **next PR in an existing stack** with the current branch as the stack base (collection branch). + - If there are no downstream PRs, treat it as **standalone PR context**. + - If no PR exists for the current branch, check whether other PRs target it: + ```bash + gh pr list --base "$(git branch --show-current)" --json number,headRefName,title + ``` + - If there are downstream PRs, treat this as **next PR in an existing stack** with the current branch as the stack base (collection branch). + - If there are no downstream PRs either, treat it as **standalone PR context** (fresh feature branch). + +3. If signals are mixed or ambiguous, ask one focused question to confirm. + +PR types: +- **Standalone PR** — regular PR targeting `main`. +- **First PR of a new stack** — create collection branch from `main`, then first PR off it. +- **Next PR in an existing stack** — target the current stack base branch (usually the previous stack PR branch, or the collection branch if creating the first follow-up PR from the collection branch). + +If the user explicitly says "stack", "stacked PR", or provides numbered stack titles (e.g. `[Topic 2]`), honor that even if branch heuristics are inconclusive. + +## Step 1: Ensure Feature Branch + +```bash +git branch --show-current +``` + +If on `main` or `master`, create and switch to a new branch: + +```bash +git checkout -b / +``` + +Derive the branch name from the changes being made. Use `feat/`, `fix/`, `ref/`, etc. matching the commit type conventions. + +**For stacked PRs:** For the first PR in a new stack, first create and push the collection branch (see `references/stacked-prs.md` § "Why a Collection Branch"), then branch the PR off it. For subsequent PRs, branch off the previous stack branch. Give every branch in the stack a shared prefix naming the feature, with a descriptive suffix per PR. + +**CRITICAL: Never merge, fast-forward, or push commits into the collection branch.** It stays at its initial position until the user merges stack PRs through GitHub. Updating it will auto-merge and destroy the entire PR stack. + +## Step 2: Format Code and Regenerate API Files + +```bash +./gradlew spotlessApply apiDump +``` + +This is **required** before every PR in this repo. It formats all Java/Kotlin code via Spotless and regenerates the `.api` binary compatibility files. + +If the command fails, diagnose and fix the issue before continuing. + +## Step 3: Commit Changes + +Check for uncommitted changes: + +```bash +git status --porcelain +``` + +If there are uncommitted changes, invoke the `sentry-skills:commit` skill to stage and commit them following [Sentry commit message conventions](https://develop.sentry.dev/engineering-practices/commit-messages/): + +``` +(): +``` + +Allowed types: `feat`, `fix`, `ref`, `chore`, `docs`, `test`, `perf`, `build`, `ci`, `style`, `meta`, `license` + +**Important:** When staging, ignore changes that are only relevant for local testing and should not be part of the PR. Common examples: + +| Ignore Pattern | Reason | +|---|---| +| Hardcoded booleans flipped for testing | Local debug toggles | +| Sample app config changes (`sentry-samples/`) | Local testing configuration | +| `.env` or credentials files | Secrets | + +Restore these files before committing: + +```bash +git checkout -- +``` + +## Step 4: Push the Branch + +```bash +git push -u origin HEAD +``` + +If the push fails due to diverged history, ask the user how to proceed rather than force-pushing. + +## Step 5: Create PR + +Invoke the `sentry-skills:create-pr` skill to create a draft PR. + +Read `.github/pull_request_template.md` and use it as the PR body structure — it is the single source +of truth for the sections and checklist, so never reproduce it from memory. Fill in each section based +on the changes being PR'd, drop the HTML comment hints, and check any checklist items that apply. + +**PR title format** — same as the commit subject (Step 3): + +``` +(): +``` + +Examples: +- `feat(core): Add structured logging support` +- `fix(android): Prevent crash on API 21 when registering receiver` + +**For stacked PRs:** + +- Pass `--base ` so the PR targets the previous branch (first PR in a stack targets the collection branch). +- Use the stacked PR title format: `(): [ ] ` (see `references/stacked-prs.md` § "PR Title Naming"). +- Include the stack list at the top of the PR body, before the `## :scroll: Description` section (see `references/stacked-prs.md` § "Stack List in PR Description" for the format). +- Add a merge method reminder at the very end of the PR body (see `references/stacked-prs.md` § "Stack List in PR Description" for the exact text). This only applies to stack PRs, not the collection branch PR. + +Then continue to Step 5.5 (stacked PRs only) or Step 6. + +## Step 5.5: Update Stack List on All PRs (stacked PRs only) + +Skip this step for standalone PRs. + +After creating the PR, update the PR description on **every other PR in the stack — including the collection branch PR** — so all PRs have the same up-to-date stack list. Follow the format and commands in `references/stacked-prs.md` § "Stack List in PR Description". + +Edit each body using the procedure in § "Editing PR Descriptions" below. + +## Step 6: Update Changelog + +First, determine whether a changelog entry is needed. **Skip this step** (and go straight to "No changelog needed" below) if the changes are not user-facing, for example: + +- Test-only changes (new tests, test refactors, test fixtures) +- CI/CD or build configuration changes +- Documentation-only changes +- Code comments or formatting-only changes +- Internal refactors with no behavior change visible to SDK users +- Sample app changes + +If unsure, ask the user. + +### If changelog is needed + +Add an entry to `CHANGELOG.md` under the `## Unreleased` section. + +#### Determine the subsection + +| Change Type | Subsection | +|---|---| +| New feature | `### Features` | +| Bug fix | `### Fixes` | +| Refactoring, internal cleanup | `### Internal` | +| Dependency update | `### Dependencies` | + +Create the subsection under `## Unreleased` if it does not already exist. + +**When rebasing:** A rebase onto `main` can land your branch after a release was cut, where the `## Unreleased` heading your entry lived under has since been renamed to that version number. If that happens, move your new entry into an `## Unreleased` section at the top of `CHANGELOG.md` (create the section if it no longer exists) so it is not left under an already-released version. + +#### Entry format + +```markdown +- ([#](https://github.com/getsentry/sentry-java/pull/)) +``` + +Use the PR number returned by `sentry-skills:create-pr`. Match the style of existing entries — sentence case, ending with the PR link, no trailing period. + +#### Commit and push + +Stage `CHANGELOG.md`, commit with message `changelog`, and push: + +```bash +git add CHANGELOG.md +git commit -m "changelog" +git push +``` + +### No changelog needed + +If no changelog entry is needed, append `#skip-changelog` to the end of the PR description to disable +the changelog CI check, using the procedure in § "Editing PR Descriptions" below. + +## Editing PR Descriptions + +Do not use shell redirects (`>`, `>>`), pipes (`|`), or compound commands (`&&`, `||`). These create +compound shell expressions that won't match permission patterns. Instead: + +1. Read the body with `gh pr view --json body --jq '.body'` (output is returned directly) +2. Use the `Write` tool to save it to `/tmp/pr-body.md`, and the `Edit` tool to modify it +3. Update with `gh pr edit --body-file /tmp/pr-body.md` diff --git a/.claude/skills/create-java-pr/references/stacked-prs.md b/.claude/skills/create-java-pr/references/stacked-prs.md new file mode 100644 index 00000000000..56221fe4912 --- /dev/null +++ b/.claude/skills/create-java-pr/references/stacked-prs.md @@ -0,0 +1,86 @@ +# Stacked PRs + +Stacked PRs split a large feature into small, easy-to-review PRs where each builds on the previous +one. The general mechanics are the standard [Graphite](https://graphite.dev/) stacking workflow — +this file covers only what is specific to sentry-java. + +## Why a Collection Branch + +``` +main ← collection-branch ← stack-pr-1 ← stack-pr-2 ← stack-pr-3 ← ... +``` + +A **collection branch** is created from `main` and targets `main`. The first stack PR targets it +rather than `main`, and each later PR targets the previous stack PR's branch. + +It exists because PRs targeting `main` are **squash**-merged, which causes repeated merge conflicts +when syncing a stack. Stack PRs are therefore **merge-committed** into the collection branch, and +only the collection branch is squash-merged into `main` at the end — giving `main` one clean commit +for the whole feature. + +Create it with an empty commit, so GitHub allows opening a PR: + +```bash +git commit --allow-empty -m "collection: " +``` + +Push it and open its PR against `main` right away — it is the PR the whole stack is eventually +squash-merged through, and it carries the stack list like every other PR. Give it a plain title +(`(): `, no `[ ]` bracket) and no merge method reminder. + +## Rules That Will Destroy a Stack If Broken + +**Never update the collection branch yourself.** Never merge, fast-forward, or push stack branch +commits into it. It stays at its initial position (the empty commit on `main`) until the user merges +stack PRs through GitHub one by one. Fast-forwarding it makes GitHub auto-merge and delete every +stack PR branch, destroying the entire stack. + +**Never amend or force-push a stack branch.** No `git commit --amend`, `--force`, or +`--force-with-lease` on a branch that is part of a stack — a force-push can cause GitHub to +auto-merge or auto-close the other PRs in the stack. If a commit needs fixing, add a fixup commit. + +**Sync only between adjacent stack branches**, by merging forward — never into the collection branch. +Prefer merge over rebase; only rebase if explicitly requested. + +**Do not merge PRs.** Only the user merges them, bottom to top. + +## PR Title Naming + +Include the topic name and a sequential number in brackets: + +``` +(): [ ] +``` + +Examples: +- `feat(core): [Global Attributes 1] Add scope-level attributes API` +- `feat(core): [Global Attributes 2] Wire scope attributes into LoggerApi and MetricsApi` + +## Stack List in PR Description + +Every PR in the stack — **including the collection branch PR** — must have a stack list **at the top +of its description**, before the `## :scroll: Description` section. When a PR is added, update the +description on **all** PRs in the stack. The stack list is also how you enumerate a stack: read it +off any PR body rather than guessing from branch names, which may use different prefixes. + +```markdown +## PR Stack () + +- #5118 +- #5120 +- #5121 + +--- +``` + +No status column — GitHub already shows that. The `---` separates the stack list from the rest of +the description. + +**Merge method reminder:** on stack PRs (not the collection branch PR), end the description with: + +```markdown +> ⚠️ **Merge this PR using a merge commit** (not squash). Only the collection branch is squash-merged into main. +``` + +Updating every PR's stack list means editing several descriptions — follow the procedure in +`SKILL.md` § "Editing PR Descriptions". diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md new file mode 100644 index 00000000000..7e6ddd37294 --- /dev/null +++ b/.claude/skills/test/SKILL.md @@ -0,0 +1,85 @@ +--- +name: test +description: Run tests for a specific SDK module. Use when asked to "run tests", "test module", "run unit tests", "run system tests", "run e2e tests", or test a specific class. Auto-detects unit vs system tests. Supports interactive mode. +allowed-tools: Bash, Read, Glob, AskUserQuestion +argument-hint: [interactive] [test-class-filter] +--- + +# Run Tests + +Run tests for a specific module. Auto-detects whether to run unit tests or system tests. + +## Step 0: Check for Interactive Mode + +If `$ARGUMENTS` starts with `interactive` (e.g., `/test interactive sentry ScopesTest`), enable interactive mode. Strip the `interactive` keyword from the arguments before proceeding. + +In interactive mode, use AskUserQuestion at decision points as described in the steps below. + +## Step 1: Parse the Argument + +The argument can be either: +- A **file path** (e.g., `@sentry/src/test/java/io/sentry/ScopesTest.kt`) +- A **module name** (e.g., `sentry-android-core`, `sentry-samples-spring-boot-4`) +- A **module name + test filter** (e.g., `sentry ScopesTest`) + +Extract the module name and optional test class filter from the argument. + +**Interactive mode:** If the test filter is ambiguous (e.g., matches multiple test classes across modules), use AskUserQuestion to let the user pick which test class(es) to run. + +## Step 2: Detect Test Type + +| Signal | Test Type | +|--------|-----------| +| Path contains `sentry-samples/` | System test | +| Module name starts with `sentry-samples-` | System test | +| Everything else | Unit test | + +## Step 3a: Run Unit Tests + +Determine the Gradle test task: + +| Module Pattern | Test Task | +|---------------|-----------| +| `sentry-android-*` | `testReleaseUnitTest` | +| `sentry-compose*` | `testReleaseUnitTest` | +| `*-android` | `testReleaseUnitTest` | +| Everything else | `test` | + +**Interactive mode:** Before running, read the test class file and use AskUserQuestion to ask: +- "Run all tests in this class, or a specific method?" — list the test method names as options. + +If the user picks a specific method, use `--tests="*ClassName.methodName"` as the filter. + +With a test class filter: +```bash +./gradlew '::' --tests="**" --info +``` + +Without a filter: +```bash +./gradlew '::' --info +``` + +## Step 3b: Run System Tests + +System tests require the Python-based test runner which manages a mock Sentry server and sample app lifecycle. + +1. Ensure the Python venv exists: +```bash +test -d .venv || make setupPython +``` + +2. Extract the sample module name. For file paths like `sentry-samples//src/...`, the sample module is the directory name (e.g., `sentry-samples-spring`). + +3. Run the system test: +```bash +.venv/bin/python test/system-test-runner.py test --module +``` + +This starts the mock Sentry server, starts the sample app (Spring Boot/Tomcat/CLI), runs tests via `./gradlew :sentry-samples::systemTest`, and cleans up afterwards. + +## Step 4: Report Results + +Summarize the test outcome: +- Total tests run, passed, failed, skipped +- For failures: show the failing test name and the assertion/error message diff --git a/.craft.yml b/.craft.yml index 7dbf0382589..bee668917c1 100644 --- a/.craft.yml +++ b/.craft.yml @@ -34,20 +34,30 @@ targets: maven:io.sentry:sentry-apache-http-client-5: maven:io.sentry:sentry-android: maven:io.sentry:sentry-android-core: + maven:io.sentry:sentry-android-distribution: maven:io.sentry:sentry-android-ndk: maven:io.sentry:sentry-android-timber: maven:io.sentry:sentry-kotlin-extensions: maven:io.sentry:sentry-android-fragment: maven:io.sentry:sentry-bom: maven:io.sentry:sentry-openfeign: + maven:io.sentry:sentry-openfeature: + maven:io.sentry:sentry-launchdarkly-android: + maven:io.sentry:sentry-launchdarkly-server: maven:io.sentry:sentry-opentelemetry-agent: + # TODO: Add after first release of the artifact. + # maven:io.sentry:sentry-opentelemetry-bom: maven:io.sentry:sentry-opentelemetry-agentcustomization: maven:io.sentry:sentry-opentelemetry-agentless: maven:io.sentry:sentry-opentelemetry-agentless-spring: maven:io.sentry:sentry-opentelemetry-bootstrap: maven:io.sentry:sentry-opentelemetry-core: + maven:io.sentry:sentry-opentelemetry-otlp: + maven:io.sentry:sentry-opentelemetry-otlp-spring: + maven:io.sentry:sentry-kafka: maven:io.sentry:sentry-apollo: maven:io.sentry:sentry-jdbc: + maven:io.sentry:sentry-jcache: maven:io.sentry:sentry-graphql: maven:io.sentry:sentry-graphql-22: maven:io.sentry:sentry-graphql-core: @@ -63,3 +73,5 @@ targets: maven:io.sentry:sentry-apollo-4: maven:io.sentry:sentry-reactor: maven:io.sentry:sentry-ktor-client: + maven:io.sentry:sentry-async-profiler: + maven:io.sentry:sentry-spotlight: diff --git a/.cursor/rules/api.mdc b/.cursor/rules/api.mdc new file mode 100644 index 00000000000..c5a793d9240 --- /dev/null +++ b/.cursor/rules/api.mdc @@ -0,0 +1,89 @@ +--- +alwaysApply: false +description: Public API surface, binary compatibility, and common classes to modify +--- +# Java SDK Public API + +## API Compatibility + +Public API is tracked via `.api` files generated by the [Binary Compatibility Validator](https://github.com/Kotlin/binary-compatibility-validator) Gradle plugin. Each module has its own file at `/api/.api`. + +- **Never edit `.api` files manually.** Run `./gradlew apiDump` to regenerate them. +- `./gradlew check` validates current code against `.api` files and fails on unintended changes. +- `@ApiStatus.Internal` marks classes/methods as internal — they still appear in `.api` files but are not part of the public contract. +- `@ApiStatus.Experimental` marks API that may change in future versions. + +## Key Public API Classes + +### Entry Point + +`Sentry` (`sentry` module) is the static entry point. Most public API methods on `Sentry` delegate to `getCurrentScopes()`. When adding a new method to `Sentry`, it typically calls through to `IScopes`. + +### Interfaces + +| Interface | Description | +|-----------|-------------| +| `IScope` | Single scope — holds data (tags, extras, breadcrumbs, attributes, user, contexts, etc.) | +| `IScopes` | Multi-scope container — manages global, isolation, and current scope; delegates capture calls to `SentryClient` | +| `ISpan` | Performance span — timing, tags, data, measurements | +| `ITransaction` | Top-level transaction — extends `ISpan` | + +### Configuration + +`SentryOptions` is the base configuration class. Platform-specific subclasses: +- `SentryAndroidOptions` — Android-specific options +- Integration modules may add their own (e.g. `SentrySpringProperties`) + +New features must be **opt-in by default** — add a getter/setter pair to the appropriate options class. + +### Internal Classes (Not Public API) + +| Class | Description | +|-------|-------------| +| `SentryClient` | Sends events/envelopes to Sentry — receives captured data from `Scopes` | +| `SentryEnvelope` / `SentryEnvelopeItem` | Low-level envelope serialization | +| `Scope` | Concrete implementation of `IScope` | +| `Scopes` | Concrete implementation of `IScopes` | + +## Adding New Public API + +When adding a new method that users can call (e.g. a new scope operation), these classes typically need changes: + +### Interfaces and Static API +1. `IScope` — add the method signature +2. `IScopes` — add the method signature (usually delegates to a scope) +3. `Sentry` — add static method that calls `getCurrentScopes()` + +### Implementations +4. `Scope` — actual implementation with data storage +5. `Scopes` — delegates to the appropriate scope (global, isolation, or current based on `defaultScopeType`) +6. `CombinedScopeView` — defines how the three scope types combine for reads (merge, first-wins, or specific scope) + +### No-Op and Adapter Classes +7. `NoOpScope` — no-op stub for `IScope` +8. `NoOpScopes` — no-op stub for `IScopes` +9. `ScopesAdapter` — delegates to `Sentry` static API +10. `HubAdapter` — deprecated bridge from old `IHub` API +11. `HubScopesWrapper` — wraps `IScopes` as `IHub` + +### Serialization (if the data is sent to Sentry) +12. Add serialization/deserialization in the relevant data class or create a new one implementing `JsonSerializable` and `JsonDeserializer` + +### Tests +13. Write tests for all implementations, especially `Scope`, `Scopes`, `SentryTest`, and any new data classes +14. No-op classes typically don't need separate tests unless they have non-trivial logic + +## Protocol / Data Model Classes + +Classes in the `io.sentry.protocol` package represent the Sentry event protocol. They implement `JsonSerializable` for serialization and have a companion `Deserializer` class implementing `JsonDeserializer`. When adding new fields to protocol classes, update both serialization and deserialization. + +## Namespaced APIs + +Newer features are namespaced under `Sentry.()` rather than added directly to `Sentry`. Each namespaced API has an interface, implementation, and no-op. Examples: + +- `Sentry.logger()` → `ILoggerApi` / `LoggerApi` / `NoOpLoggerApi` (structured logging, `io.sentry.logger` package) +- `Sentry.metrics()` → `IMetricsApi` / `MetricsApi` / `NoOpMetricsApi` (metrics) + +Options for namespaced features are similarly nested under `SentryOptions`, e.g. `SentryOptions.getMetrics()`, `SentryOptions.getLogs()`. + +These APIs may share infrastructure like the type system (`SentryAttributeType.inferFrom()`) — changes to shared components (e.g. attribute types) may require updates across multiple namespaced APIs. diff --git a/.cursor/rules/coding.mdc b/.cursor/rules/coding.mdc deleted file mode 100644 index e7af7273f15..00000000000 --- a/.cursor/rules/coding.mdc +++ /dev/null @@ -1,53 +0,0 @@ ---- -alwaysApply: true -description: Cursor Coding Rules ---- - -# Contributing Rules for Agents - -## Overview - -sentry-java is the Java and Android SDK for Sentry. This repository contains the source code and examples for SDK usage. - -## Tech Stack - -- **Language**: Java and Kotlin -- **Build Framework**: Gradle - -## Key Commands - -```bash -# Format code and regenerate .api files -./gradlew spotlessApply apiDump - -# Run all tests and linter -./gradlew check - -# Run unit tests for a specific file -./gradle '::testDebugUnitTest' --tests="**" --info -``` - -## Contributing Guidelines - -1. Follow existing code style and language -2. Do not modify the API files (e.g. sentry.api) manually, instead run `./gradlew apiDump` to regenerate them -3. Write comprehensive tests -4. New features should always be opt-in by default, extend `SentryOptions` or similar Option classes with getters and setters to enable/disable a new feature -5. Consider backwards compatibility - -## Coding rules - -1. First think through the problem, read the codebase for relevant files, and propose a plan -2. Before you begin working, check in with me and I will verify the plan -3. Then, begin working on the todo items, marking them as complete as you go -4. Please do not describe every step of the way and just give me a high level explanation of what changes you made -5. Make every task and code change you do as simple as possible. We want to avoid making any massive or complex changes. Every change should impact as little code as possible. Everything is about simplicity. -6. Once you're done, format the code and regenerate the .api files using the following command `./gradlew spotlessApply apiDump` -7. As a last step, git stage the relevant files and propose (but not execute) a single git commit command (e.g. `git commit -m ""`) - - -## Useful Resources - -- Main SDK documentation: https://develop.sentry.dev/sdk/overview/ -- Internal contributing guide: https://docs.sentry.io/internal/contributing/ -- Git commit messages conventions: https://develop.sentry.dev/engineering-practices/commit-messages/ diff --git a/.cursor/rules/continuous_profiling_jvm.mdc b/.cursor/rules/continuous_profiling_jvm.mdc new file mode 100644 index 00000000000..d9a911de25e --- /dev/null +++ b/.cursor/rules/continuous_profiling_jvm.mdc @@ -0,0 +1,174 @@ +--- +alwaysApply: false +description: JVM Continuous Profiling (sentry-async-profiler) +--- +# JVM Continuous Profiling + +Use this rule when working on JVM continuous profiling in `sentry-async-profiler` and the related core profiling abstractions in `sentry`. + +This area is suitable for LLM work, but do not rely on this rule alone for behavior changes. Always read the implementation and nearby tests first, especially for sampling, lifecycle, rate limiting, and file cleanup behavior. + +## Module Structure + +- **`sentry-async-profiler`**: standalone module containing the async-profiler integration + - Uses Java `ServiceLoader` discovery + - No direct dependency from core `sentry` module + - Enabled by adding the module as a dependency + +- **`sentry` core abstractions**: + - `IContinuousProfiler`: profiler lifecycle interface + - `ProfileChunk`: profile chunk payload sent to Sentry + - `IProfileConverter`: converts JVM JFR files into `SentryProfile` + - `ProfileLifecycle`: controls MANUAL vs TRACE lifecycle + - `ProfilingServiceLoader`: loads profiler and converter implementations via `ServiceLoader` + +## Key Classes + +### `JavaContinuousProfiler` (`sentry-async-profiler`) +- Wraps the native async-profiler library +- Writes JFR files to `profilingTracesDirPath` +- Rotates chunks periodically via `MAX_CHUNK_DURATION_MILLIS` (currently 10s) +- Implements `RateLimiter.IRateLimitObserver` +- Maintains `rootSpanCounter` for TRACE lifecycle +- Keeps a session-level `profilerId` across chunks until the profiling session ends +- `getChunkId()` currently returns `SentryId.EMPTY_ID`, but emitted `ProfileChunk`s get a fresh chunk id when built in `stop(...)` + +### `ProfileChunk` +- Carries `profilerId`, `chunkId`, timestamp, platform, measurements, and a JFR file reference +- Built via `ProfileChunk.Builder` +- For JVM, the JFR file is converted later during envelope item creation, not inside `JavaContinuousProfiler` + +### `ProfileLifecycle` +- `MANUAL`: explicit `Sentry.startProfiler()` / `Sentry.stopProfiler()` +- `TRACE`: profiler lifecycle follows active sampled root spans + +## Configuration + +Continuous profiling is **not** controlled by `profilesSampleRate`. + +Key options: +- **`profileSessionSampleRate`**: session-level sample rate for continuous profiling +- **`profileLifecycle`**: `ProfileLifecycle.MANUAL` (default) or `ProfileLifecycle.TRACE` +- **`cacheDirPath`**: base SDK cache directory; profiling traces are written under the derived `profilingTracesDirPath` +- **`profilingTracesHz`**: sampling frequency in Hz (default: 101) + +Continuous profiling is enabled when: +- `profilesSampleRate == null` +- `profilesSampler == null` +- `profileSessionSampleRate != null && profileSessionSampleRate > 0` + +Example: + +```java +options.setProfileSessionSampleRate(1.0); +options.setCacheDirPath("/tmp/sentry-cache"); +options.setProfileLifecycle(ProfileLifecycle.MANUAL); +options.setProfilingTracesHz(101); +``` + +## How It Works + +### Initialization +- `InitUtil.initializeProfiler(...)` resolves or creates the profiling traces directory +- `ProfilingServiceLoader.loadContinuousProfiler(...)` uses `ServiceLoader` to find `JavaContinuousProfilerProvider` +- `AsyncProfilerContinuousProfilerProvider` instantiates `JavaContinuousProfiler` +- `ProfilingServiceLoader.loadProfileConverter()` separately loads the `JavaProfileConverterProvider` + +### Profiling Flow + +**Start** +- Sampling decision is made via `TracesSampler.sampleSessionProfile(...)` +- Sampling is session-based and cached until `reevaluateSampling()` +- Scopes and rate limiter are initialized lazily via `initScopes()` +- Rate limits for `All` or `ProfileChunk` abort startup +- JFR filename is generated under `profilingTracesDirPath` +- async-profiler is started with a command like: + - `start,jfr,event=wall,nobatch,interval=,file=` +- Automatic chunk stop is scheduled after `MAX_CHUNK_DURATION_MILLIS` + +**Chunk Rotation** +- `stop(true)` stops async-profiler and validates the JFR file +- A `ProfileChunk.Builder` is created with: + - current `profilerId` + - a fresh `chunkId` + - trace file + - chunk timestamp + - platform `java` +- Builder is buffered in `payloadBuilders` +- Chunks are sent if scopes are available +- Profiling is restarted for the next chunk + +**Stop** +- `MANUAL`: stop immediately, do not restart, reset `profilerId` +- `TRACE`: decrement `rootSpanCounter`; stop only when it reaches 0 +- `close(...)` also forces shutdown and resets TRACE state + +### Sending and Conversion +- `JavaContinuousProfiler` buffers `ProfileChunk.Builder` instances +- `sendChunks(...)` builds `ProfileChunk` objects and calls `scopes.captureProfileChunk(...)` +- `SentryClient.captureProfileChunk(...)` creates an envelope item +- JVM JFR-to-`SentryProfile` conversion happens in `SentryEnvelopeItem.fromProfileChunk(...)` using the loaded `IProfileConverter` +- Trace files are deleted in the envelope item path after serialization attempts + +## TRACE Mode Lifecycle +- `rootSpanCounter` increments when sampled root spans start +- `rootSpanCounter` decrements when root spans finish +- Profiler runs while `rootSpanCounter > 0` +- Multiple concurrent sampled transactions can share the same profiling session +- Be careful when changing lifecycle logic: this area is lock-protected and concurrency-sensitive + +## Rate Limiting and Buffering + +### Rate Limiting +- Registers as a `RateLimiter.IRateLimitObserver` +- If rate limited for `ProfileChunk` or `All`: + - profiler stops immediately + - it does not auto-restart when the limit expires +- Startup also checks rate limiting before profiling begins + +### Buffering / pre-init behavior +- JFR files are written to `profilingTracesDirPath` and marked `deleteOnExit()` when a chunk is accepted +- If scopes are not yet available, `ProfileChunk.Builder`s remain buffered in memory in `payloadBuilders` +- This commonly matters for profiling that starts before SDK scopes are ready +- This is not a dedicated durable offline queue owned by the profiler itself; conversion and final send happen later in the normal client/envelope path + +## Extending + +To add or replace JVM profiler implementations: +- implement `IContinuousProfiler` +- implement `JavaContinuousProfilerProvider` +- register provider in: + - `META-INF/services/io.sentry.profiling.JavaContinuousProfilerProvider` + +To add or replace JVM profile conversion: +- implement `IProfileConverter` +- implement `JavaProfileConverterProvider` +- register provider in: + - `META-INF/services/io.sentry.profiling.JavaProfileConverterProvider` + +## Code Locations + +Primary implementation: +- `sentry/src/main/java/io/sentry/IContinuousProfiler.java` +- `sentry/src/main/java/io/sentry/ProfileChunk.java` +- `sentry/src/main/java/io/sentry/profiling/ProfilingServiceLoader.java` +- `sentry/src/main/java/io/sentry/util/InitUtil.java` +- `sentry/src/main/java/io/sentry/SentryEnvelopeItem.java` +- `sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java` +- `sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java` +- `sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider.java` +- `sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java` + +Tests to read first: +- `sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfilerTest.kt` +- `sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/JavaContinuousProfilingServiceLoaderTest.kt` +- `sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt` + +## LLM Guidance + +This rule is good enough for orientation, but for actual code changes always verify: +- the sampling path in `TracesSampler` +- continuous profiling enablement in `SentryOptions` +- lifecycle entry points in `Scopes` and `SentryTracer` +- conversion and file deletion behavior in `SentryEnvelopeItem` +- existing tests before changing concurrency or lifecycle semantics diff --git a/.cursor/rules/feature_flags.mdc b/.cursor/rules/feature_flags.mdc new file mode 100644 index 00000000000..f2a78bc71cf --- /dev/null +++ b/.cursor/rules/feature_flags.mdc @@ -0,0 +1,44 @@ +--- +alwaysApply: false +description: Feature Flags +--- +# Java SDK Feature Flags + +There is a scope based and a span based API for tracking feature flag evaluations. + +## Scope Based API + +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 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, choosing the newest {maxFeatureFlag} entries across all scope types. Feature flags are sent as part of the `flags` context. + +## Span Based API + +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/opentelemetry.mdc b/.cursor/rules/opentelemetry.mdc index 7a94dcf58f4..4e773233f04 100644 --- a/.cursor/rules/opentelemetry.mdc +++ b/.cursor/rules/opentelemetry.mdc @@ -14,6 +14,8 @@ The Sentry Java SDK provides comprehensive OpenTelemetry integration through mul - `sentry-opentelemetry-agentless-spring`: Spring-specific agentless integration - `sentry-opentelemetry-bootstrap`: Classes that go into the bootstrap classloader when the agent is used. For agentless they are simply used in the applications classloader. - `sentry-opentelemetry-agentcustomization`: Classes that help wire up Sentry in OpenTelemetry. These land in the agent classloader when the agent is used. For agentless they are simply used in the application classloader. +- `sentry-opentelemetry-otlp`: Classes for using OpenTelemetry to send spans to Sentry using the OTLP endpoint and have Sentry use OpenTelemetry trace and span id. +- `sentry-opentelemetry-otlp-spring`: Spring Boot convenience module that includes `sentry-opentelemetry-otlp` and the OpenTelemetry Spring Boot starter as transitive dependencies. ## Advantages over using Sentry without OpenTelemetry @@ -86,3 +88,10 @@ After creating the transaction with child spans `SentrySpanExporter` uses Sentry ## Troubleshooting To debug forking of `Scopes`, we added a reference to `parent` `Scopes` and a `creator` String to store the reason why `Scopes` were created or forked. + +# OTLP +When using `sentry-opentelemetry-otlp`, Sentry only loads trace ID and span ID from OpenTelemetry `Context` (via `OpenTelemetryOtlpEventProcessor`). Sentry does not rely on OpenTelemetry `Context` for scope storage and propagation, instead relying on its `DefaultScopesStorage`. +It is common to keep Performance in Sentry SDK disabled since that part is taken over by OpenTelemetry. +The `sentry-opentelemetry-otlp` module is not connected to the other `sentry-opentelemetry-*` modules but instead intended only when the goal is to run OpenTelemetry for creating spans and Sentry for other products like errors, logs, metrics etc. +The `sentry-opentelemetry-otlp-spring` module wraps `sentry-opentelemetry-otlp` and includes the OpenTelemetry Spring Boot starter for easier setup in Spring Boot applications. +The OTLP module does not easily work with the OpenTelemetry agent as it would require customizing the agent.JAR in order to get the propagator loaded. diff --git a/.cursor/rules/options.mdc b/.cursor/rules/options.mdc new file mode 100644 index 00000000000..2d239da7813 --- /dev/null +++ b/.cursor/rules/options.mdc @@ -0,0 +1,115 @@ +--- +alwaysApply: false +description: Adding and modifying SDK options +--- +# Adding Options to the SDK + +New features must be **opt-in by default**. Options control whether a feature is enabled and how it behaves. + +## Namespaced Options + +Newer features use namespaced option classes nested inside `SentryOptions`, e.g.: +- `SentryOptions.getLogs()` → `SentryOptions.Logs` +- `SentryOptions.getMetrics()` → `SentryOptions.Metrics` + +Each namespaced options class is a `public static final class` inside `SentryOptions` with its own fields, getters/setters, and callbacks (e.g. `BeforeSendLogCallback`, `BeforeSendMetricCallback`). + +A typical namespaced options class contains: +- `enabled` boolean (default `false` for opt-in) +- `sampleRate` double (if the feature supports sampling) +- `beforeSend` callback interface (nested inside the options class) + +To add a new namespaced options class: +1. Create the `public static final class` inside `SentryOptions` with fields, getters/setters, and any callback interfaces +2. Add a private field on `SentryOptions` initialized with `new SentryOptions.MyFeature()` +3. Add getter/setter on `SentryOptions` annotated with `@ApiStatus.Experimental` + +## Direct (Non-Namespaced) Options + +Options that apply globally across the SDK (e.g. `dsn`, `environment`, `release`, `sampleRate`, `maxBreadcrumbs`) live as direct fields on `SentryOptions` with getter/setter pairs. Use this pattern for options that aren't tied to a specific feature namespace. + +## Configuration Layers + +Options can be set through multiple layers. When adding a new option, consider which layers apply: + +### 1. SentryOptions (always required) + +The core options class. Add the field (or nested class) with getter/setter here. + +**File:** `sentry/src/main/java/io/sentry/SentryOptions.java` + +**Tests:** `sentry/src/test/java/io/sentry/SentryOptionsTest.kt` +- Test the default value +- Test merge behavior (see layer 2) + +### 2. ExternalOptions (sentry.properties / environment variables) + +Allows setting options via `sentry.properties` file or system properties. Fields use nullable wrapper types (`@Nullable Boolean`, `@Nullable Double`) since unset means "don't override the default." + +**File:** `sentry/src/main/java/io/sentry/ExternalOptions.java` +- Add `@Nullable` fields with getter/setter for each externally configurable option (e.g. `enableMetrics`, `logsSampleRate`) +- Wire them in the static `from(PropertiesProvider)` method: + - Boolean: `propertiesProvider.getBooleanProperty("metrics.enabled")` + - Double: `propertiesProvider.getDoubleProperty("logs.sample-rate")` + +**File:** `sentry/src/main/java/io/sentry/SentryOptions.java` — `merge()` method +- Add null-check blocks to apply each external option onto the namespaced options class: + ```java + if (options.isEnableMetrics() != null) { + getMetrics().setEnabled(options.isEnableMetrics()); + } + if (options.getLogsSampleRate() != null) { + getLogs().setSampleRate(options.getLogsSampleRate()); + } + ``` + +**Tests:** +- `sentry/src/test/java/io/sentry/ExternalOptionsTest.kt` — test true/false/null for booleans, valid values and null for doubles +- `sentry/src/test/java/io/sentry/SentryOptionsTest.kt` — test merge applies values and test merge preserves defaults when unset + +### 3. Android Manifest Metadata (Android only) + +Allows setting options via `AndroidManifest.xml` `` tags. + +**File:** `sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java` +- Add a `static final String` constant for the key (e.g. `"io.sentry.metrics.enabled"`) +- Read it in `applyMetadata()` using `readBool(metadata, logger, CONSTANT, defaultValue)` +- Apply to the namespaced options, e.g. `options.getMetrics().setEnabled(...)` + +**Tests:** `sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt` +- Test default value preserved when not in manifest +- Test explicit true +- Test explicit false + +### 4. Spring Boot Properties (Spring Boot only) + +`SentryProperties` extends `SentryOptions`, so namespaced options (nested classes) are automatically available as Spring Boot properties without extra code. For example, `SentryOptions.Logs` is automatically mapped to `sentry.logs.enabled` in `application.properties`. + +No additional code is needed for namespaced options — Spring Boot auto-configuration handles this via property binding on the `SentryOptions` class hierarchy. + +**Tests:** `sentry-spring-boot*/src/test/kotlin/.../SentryAutoConfigurationTest.kt` +- Add the property (e.g. `"sentry.logs.enabled=true"`) to the existing `resolves all properties` test +- Assert the value is set on the resolved `SentryProperties` bean +- There are three Spring Boot modules with separate test files: `sentry-spring-boot`, `sentry-spring-boot-jakarta`, `sentry-spring-boot-4` + +### 5. Reading Options at Runtime + +Features check their options at usage time. For namespaced features the check typically happens in the feature's API class (e.g. `LoggerApi`, `MetricsApi`): +- Check `options.getLogs().isEnabled()` early and return if disabled +- Apply sampling via `options.getLogs().getSampleRate()` if applicable +- Apply `beforeSend` callback in `SentryClient` before sending + +When a feature has its own capture path (e.g. `captureLog`), the relevant classes are: +- `ISentryClient` — add the capture method signature +- `SentryClient` — implement capture, including `beforeSend` callback execution +- `NoOpSentryClient` — add no-op stub + +## Checklist for Adding a New Namespaced Option + +1. `SentryOptions.java` — nested options class + getter/setter on `SentryOptions` +2. `ExternalOptions.java` — `@Nullable` fields + wiring in `from()` +3. `SentryOptions.java` `merge()` — apply external options to namespaced class +4. `ManifestMetadataReader.java` — Android manifest support (if Android-relevant) +5. `SentryAutoConfigurationTest.kt` — Spring Boot property binding tests (all three Spring Boot modules) +6. Tests for all of the above (`SentryOptionsTest`, `ExternalOptionsTest`, `ManifestMetadataReaderTest`) +7. Run `./gradlew apiDump` — the nested class and its methods appear in `sentry.api` diff --git a/.cursor/rules/overview_dev.mdc b/.cursor/rules/overview_dev.mdc deleted file mode 100644 index 89c70e2c158..00000000000 --- a/.cursor/rules/overview_dev.mdc +++ /dev/null @@ -1,65 +0,0 @@ ---- -alwaysApply: true -description: Sentry Java SDK - Development Rules Overview ---- - -# Sentry Java SDK Development Rules - -## Always Applied Rules - -These rules are automatically included in every conversation: -- **coding.mdc**: General contributing guidelines, build commands, and workflow rules - -## Domain-Specific Rules (Fetch Only When Needed) - -Use the `fetch_rules` tool to include these rules when working on specific areas: - -### Core SDK Functionality -- **`scopes`**: Use when working with: - - Hub/Scope management, forking, or lifecycle - - `Sentry.getCurrentScopes()`, `pushScope()`, `withScope()` - - `ScopeType` (GLOBAL, ISOLATION, CURRENT) - - Thread-local storage, scope bleeding issues - - Migration from Hub API (v7 → v8) - -- **`deduplication`**: Use when working with: - - Duplicate event detection/prevention - - `DuplicateEventDetectionEventProcessor` - - `enableDeduplication` option - -- **`offline`**: Use when working with: - - Caching, envelope storage/retrieval - - Network failure handling, retry logic - - `AsyncHttpTransport`, `EnvelopeCache` - - Rate limiting, cache rotation - - Android vs JVM caching differences - -### Integration & Infrastructure -- **`opentelemetry`**: Use when working with: - - OpenTelemetry modules (`sentry-opentelemetry-*`) - - Agent vs agentless configurations - - Span processing, sampling, context propagation - - `OtelSpanFactory`, `SentrySpanExporter` - - Tracing, distributed tracing - -- **`new_module`**: Use when adding a new integration or sample module - -### Testing -- **`e2e_tests`**: Use when working with: - - System tests, sample applications - - `system-test-runner.py`, mock Sentry server - - End-to-end test infrastructure - - CI system test workflows - -## Usage Guidelines - -1. **Start minimal**: Only include `coding.mdc` (auto-applied) for general tasks -2. **Fetch on-demand**: Use `fetch_rules ["rule_name"]` when you identify specific domain work -3. **Multiple rules**: Fetch multiple rules if task spans domains (e.g., `["scopes", "opentelemetry"]` for tracing scope issues) -4. **Context clues**: Look for these keywords in requests to determine relevant rules: - - Scope/Hub/forking → `scopes` - - Duplicate/dedup → `deduplication` - - OpenTelemetry/tracing/spans → `opentelemetry` - - new module/integration/sample → `new_module` - - Cache/offline/network → `offline` - - System test/e2e/sample → `e2e_tests` diff --git a/.cursor/rules/queues.mdc b/.cursor/rules/queues.mdc new file mode 100644 index 00000000000..fe082c3b854 --- /dev/null +++ b/.cursor/rules/queues.mdc @@ -0,0 +1,82 @@ +--- +alwaysApply: false +description: Sentry Queues module and Java SDK queue tracing +--- +# Sentry Queues and Java SDK Queue Tracing + +## Product model + +Sentry Queues is built from tracing data. SDKs mark queue work with queue-specific span operations and messaging span data so Sentry can identify producers, consumers, destinations, latency, and failures. + +The important concepts are: +- `queue.publish`: a span for enqueueing/publishing a message to a queue or topic. +- `queue.process`: a transaction for processing a dequeued message. +- Messaging span data, especially: + - `messaging.system` (for example `kafka`) + - `messaging.destination.name` (queue/topic name) + - `messaging.message.id` + - `messaging.message.retry.count` + - `messaging.message.body.size` + - `messaging.message.envelope.size` + - `messaging.message.receive.latency` +- Distributed tracing headers (`sentry-trace` and `baggage`) link producer-side work to consumer-side processing. +- Queue receive latency is the time a message spent waiting between publish/enqueue and processing. For Java Kafka, this comes from the `sentry-task-enqueued-time` header that the producer writes and the consumer reads. + +The Queues UI is not backed by a separate Java event type. The Java SDK contributes data through spans/transactions with the expected operations, trace context, statuses, and messaging attributes. + +## Java SDK implementation + +Queue tracing is opt-in. `SentryOptions.isEnableQueueTracing()` defaults to `false` and can be enabled with `setEnableQueueTracing(true)` or external config key `enable-queue-tracing` (`sentry.enable-queue-tracing` in Spring Boot). Captured queue spans/transactions still depend on tracing being enabled and sampled. + +Kafka support lives in `sentry-kafka`: +- `SentryKafkaProducer.wrap(Producer)` wraps Kafka `Producer.send(...)` calls. + - Creates a `queue.publish` child span when there is an active span. + - Sets `messaging.system=kafka` and `messaging.destination.name=`. + - Injects `sentry-trace`, `baggage`, and `sentry-task-enqueued-time` headers. + - Still injects tracing/enqueued-time headers when queue tracing is enabled but there is no active span, so background producers can link to consumers. + - Finishes the span from the Kafka callback with `OK` or `INTERNAL_ERROR`. +- `SentryKafkaConsumerTracing.withTracing(record, callback)` is the manual raw-Kafka consumer helper. + - Forks root scopes for the processing lifecycle and makes them current. + - Continues the trace from Kafka headers. + - Starts a `queue.process` transaction bound to scope when tracing is enabled. + - Sets Kafka messaging data, body size, retry count, and receive latency when available. + - Finishes with `OK` or `INTERNAL_ERROR` and never lets instrumentation failures break customer processing. + +Spring Kafka support lives in `sentry-spring`, `sentry-spring-jakarta`, and `sentry-spring-7`: +- `SentryKafkaProducerBeanPostProcessor` installs a producer post-processor on `DefaultKafkaProducerFactory` and wraps created producers with `SentryKafkaProducer.wrap(...)`. +- `SentryKafkaConsumerBeanPostProcessor` installs `SentryKafkaRecordInterceptor` on listener container factories. +- `SentryKafkaRecordInterceptor` starts/finishes `queue.process` transactions around listener processing, continues traces from headers, forks scopes for the record lifecycle, and preserves any existing delegate interceptor. +- Spring Boot auto-configuration registers both post-processors only when Spring Kafka and `sentry-kafka` are present and `sentry.enable-queue-tracing=true`. +- Spring Boot queue auto-configuration is disabled when Sentry OpenTelemetry integration classes are present to avoid duplicate Kafka instrumentation. + +## Trace origins and suppression + +Queue instrumentation sets span origins so it can be identified and suppressed with `ignoredSpanOrigins`: +- Raw Kafka producer: `auto.queue.kafka.producer` +- Raw Kafka consumer helper: `manual.queue.kafka.consumer` +- Spring Kafka producer: `auto.queue.spring.kafka.producer`, `auto.queue.spring_jakarta.kafka.producer`, `auto.queue.spring7.kafka.producer` +- Spring Kafka consumer: `auto.queue.spring.kafka.consumer`, `auto.queue.spring_jakarta.kafka.consumer`, `auto.queue.spring7.kafka.consumer` + +## Files to inspect when changing queue tracing + +- Core option and conventions: + - `sentry/src/main/java/io/sentry/SentryOptions.java` + - `sentry/src/main/java/io/sentry/ExternalOptions.java` + - `sentry/src/main/java/io/sentry/SpanDataConvention.java` +- Raw Kafka: + - `sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaProducer.java` + - `sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaConsumerTracing.java` + - `sentry-kafka/src/test/kotlin/io/sentry/kafka/*Test.kt` +- Spring Kafka: + - `sentry-spring*/src/main/java/io/sentry/**/kafka/*` + - `sentry-spring*/src/test/kotlin/io/sentry/**/kafka/*Test.kt` + - `sentry-spring-boot*/src/main/java/io/sentry/**/SentryAutoConfiguration.java` + - `sentry-spring-boot*/src/test/kotlin/io/sentry/**/SentryKafkaAutoConfigurationTest.kt` + +## Related rules + +Also fetch: +- `options` when changing `enableQueueTracing` or configuration surfaces. +- `scopes` when changing consumer scope forking/lifecycle. +- `opentelemetry` when changing coexistence with OTel auto-instrumentation. +- `api` when changing public Kafka APIs or option methods. 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/.gitattributes b/.gitattributes index d952371fb7e..f444fd5957d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,12 @@ * text eol=lf *.png binary *.jpg binary +*.pb binary +*.gz binary +*.bin binary +*.zip binary +*.jar binary +*.gpg binary # These are explicitly windows files and should use crlf *.bat text eol=crlf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a19e12c1c1b..6e1f71a7677 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @adinauer @romtsn @stefanosiano @markushi @lcian +* @adinauer @romtsn @markushi @runningcode @0xadam-brown diff --git a/.github/ISSUE_TEMPLATE/bug_report_android.yml b/.github/ISSUE_TEMPLATE/bug_report_android.yml index f76c38dbe75..5dff43579c6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_android.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_android.yml @@ -16,6 +16,7 @@ body: - sentry-apollo - sentry-apollo-3 - sentry-compose + - sentry-launchdarkly-android - sentry-okhttp - other validations: @@ -54,6 +55,22 @@ body: validations: required: true + - type: dropdown + id: other_error_monitoring_solution + attributes: + description: Are you using any other error monitoring solution alongside Sentry? + label: Other Error Monitoring Solution + options: + - "No" + - "Bugsnag" + - "Datadog" + - "Firebase Crashlytics" + - "Instabug/Luciq" + - "NewRelic" + - "Other (please mention in issue description)" + validations: + required: true + - type: input id: version attributes: diff --git a/.github/ISSUE_TEMPLATE/bug_report_java.yml b/.github/ISSUE_TEMPLATE/bug_report_java.yml index a7ca3cbb770..8355d75a43b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_java.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_java.yml @@ -35,6 +35,8 @@ body: - sentry-graphql-22 - sentry-quartz - sentry-openfeign + - sentry-openfeature + - sentry-launchdarkly-server - sentry-apache-http-client-5 - sentry-okhttp - sentry-reactor @@ -51,6 +53,25 @@ body: validations: required: true + - type: dropdown + id: other_error_monitoring + attributes: + description: Are you using any other error monitoring solution alongside Sentry? + label: Other Error Monitoring Solution + options: + - "Yes" + - "No" + validations: + required: true + + - type: input + id: other_error_monitoring_name + attributes: + label: Other Error Monitoring Solution Name + description: If you're using another error monitoring solution side-by-side, please enter the name of the other solution. + validations: + required: false + - type: input id: version attributes: diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b88a67a7f0c..2824699563c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,28 @@ version: 2 +registries: + gradle-plugin-portal: + type: maven-repository + url: https://plugins.gradle.org/m2 + username: dummy # Required by dependabot + password: dummy # Required by dependabot updates: + - package-ecosystem: "gradle" + directory: "/" + registries: + - gradle-plugin-portal + schedule: + interval: "daily" + ignore: + - dependency-name: "org.springframework.boot*" + commit-message: + prefix: "chore(deps)" - package-ecosystem: "github-actions" directory: "/" schedule: - interval: weekly + interval: "daily" + commit-message: + prefix: "chore(deps)" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b337ac9ea4e..baa2dad44a2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,5 @@ ## :scroll: Description - + ## :bulb: Motivation and Context @@ -12,6 +12,10 @@ --> ## :green_heart: How did you test it? + ## :pencil: Checklist @@ -25,6 +29,7 @@ - [ ] Review from the native team if needed. - [ ] No breaking change or entry added to the changelog. - [ ] No breaking change for hybrid SDKs or communicated to hybrid SDKs. +- [ ] Public API changes reviewed by another Mobile SDK team member or implemented according to the [develop docs](https://develop.sentry.dev/) spec. ## :crystal_ball: Next steps diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index cb0b528cf55..4dc779ce812 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - agp: [ '8.7.0','8.8.0','8.9.0' ] + agp: [ '9.0.0', '9.1.1', '9.2.1' ] integrations: [ true, false ] name: AGP Matrix Release - AGP ${{ matrix.agp }} - Integrations ${{ matrix.integrations }} @@ -28,18 +28,18 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -50,7 +50,7 @@ jobs: sudo udevadm trigger --name-match=kvm - name: AVD cache - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: avd-cache with: path: | @@ -60,7 +60,7 @@ jobs: - name: Create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # pin@v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 with: api-level: 30 target: aosp_atd @@ -79,7 +79,7 @@ jobs: # We tried to use the cache action to cache gradle stuff, but it made tests slower and timeout - name: Run instrumentation tests - uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # pin@v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 with: api-level: 30 target: aosp_atd @@ -90,11 +90,11 @@ jobs: disable-spellchecker: true emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disk-size: 4096M - script: ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest -DtestBuildType=release -Denvironment=github --daemon + script: ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest -Denvironment=github --daemon - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-results-AGP${{ matrix.agp }}-Integrations${{ matrix.integrations }} path: | @@ -103,7 +103,7 @@ jobs: **/build/outputs/mapping/release/* - name: Test Report - uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 if: always() with: name: JUnit AGP ${{ matrix.agp }} - Integrations ${{ matrix.integrations }} @@ -112,10 +112,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: build/outputs/androidTest-results/**/*.xml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c851c853724..ef9aa7cfc36 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,48 +19,56 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Run Tests with coverage and Lint + - name: Run Tests and Lint run: make preMerge - - name: Upload coverage to Codecov - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # pin@v4 - with: - name: sentry-java - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} + - name: Install Sentry CLI + uses: getsentry/action-setup-cli@70d7e587b84c2e78cf4d37cd33d7b74fb3729c1b # v1 + + - name: Upload Snapshots to Sentry + # Skip on PRs from forks, which don't have access to the upload secret + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + run: | + sentry-cli snapshots upload ./sentry-android-core/build/test-snapshots \ + --app-id sentry-android-core + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: sentry-sdks + SENTRY_PROJECT: sentry-android - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-results-build path: | **/build/reports/* - name: Test Report - uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 if: always() with: name: JUnit Build diff --git a/.github/workflows/changes-in-high-risk-code.yml b/.github/workflows/changes-in-high-risk-code.yml index e9c436ea253..44d65924209 100644 --- a/.github/workflows/changes-in-high-risk-code.yml +++ b/.github/workflows/changes-in-high-risk-code.yml @@ -16,10 +16,10 @@ jobs: high_risk_code: ${{ steps.changes.outputs.high_risk_code }} high_risk_code_files: ${{ steps.changes.outputs.high_risk_code_files }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get changed files id: changes - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 with: token: ${{ github.token }} filters: .github/file-filters.yml @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Comment on PR to notify of changes in high risk files - uses: actions/github-script@v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: high_risk_code: ${{ needs.files-changed.outputs.high_risk_code_files }} with: diff --git a/.github/workflows/check-tombstone-proto-schema.yml b/.github/workflows/check-tombstone-proto-schema.yml new file mode 100644 index 00000000000..0190865250e --- /dev/null +++ b/.github/workflows/check-tombstone-proto-schema.yml @@ -0,0 +1,16 @@ +name: Check Tombstone Proto Schema + +on: + schedule: + - cron: '0 9 * * *' + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check for newer Tombstone proto schema + run: ./scripts/check-tombstone-proto-schema.sh diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 21ba09907b0..57e2e4a1073 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,23 +20,23 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Initialize CodeQL - uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # pin@v2 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # pin@v2 with: languages: 'java' @@ -45,4 +45,4 @@ jobs: ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # pin@v2 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # pin@v2 diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 09d4bcb0338..e40b4563b00 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -2,10 +2,10 @@ name: Danger on: pull_request: - types: [opened, synchronize, reopened, edited, ready_for_review] + types: [opened, synchronize, reopened, edited, ready_for_review, labeled, unlabeled] jobs: danger: runs-on: ubuntu-latest steps: - - uses: getsentry/github-workflows/danger@v3 + - uses: getsentry/github-workflows/danger@607fed74f812e69201531a5185b6c3c57caa4e89 # v3 diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 2cfde5e7fe8..a7be2bdb001 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,23 +11,23 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - name: Set up Java - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # TODO: remove this when upstream is fixed - name: Disable Gradle configuration cache (see https://github.com/fossas/fossa-cli/issues/872) run: sed -i 's/^org.gradle.configuration-cache=.*/org.gradle.configuration-cache=false/' gradle.properties - name: 'Enforce License Compliance' - uses: getsentry/action-enforce-license-compliance@main + uses: getsentry/action-enforce-license-compliance@48236a773346cb6552a7bda1ee370d2797365d87 # main with: skip_checkout: 'true' fossa_test_timeout_seconds: 3600 diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index f8be10ac566..7f638963fc0 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -8,18 +8,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 2c5295beddf..ad33bb93e7a 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -9,24 +9,24 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - name: Generate Aggregate Javadocs run: | ./gradlew aggregateJavadocs - name: Deploy - uses: JamesIves/github-pages-deploy-action@6c2d9db40f9296374acc17b90404b6e8864128c8 # pin@4.7.3 + uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # pin@4.8.0 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 979671dec70..66e4498dcb5 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -27,18 +27,18 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -48,7 +48,7 @@ jobs: run: make assembleBenchmarks - name: Run All Tests in SauceLab - uses: saucelabs/saucectl-run-action@39e4f0666ca8ecb4b60847213c6e0fbd6a0c2bd8 # pin@v3 + uses: saucelabs/saucectl-run-action@283660aa934c02723c497efa151d582a3acc5801 # pin@v3 if: github.event_name != 'pull_request' && env.SAUCE_USERNAME != null env: GITHUB_TOKEN: ${{ github.token }} @@ -58,7 +58,7 @@ jobs: config-file: .sauce/sentry-uitest-android-benchmark.yml - name: Run one test in SauceLab - uses: saucelabs/saucectl-run-action@39e4f0666ca8ecb4b60847213c6e0fbd6a0c2bd8 # pin@v3 + uses: saucelabs/saucectl-run-action@283660aa934c02723c497efa151d582a3acc5801 # pin@v3 if: github.event_name == 'pull_request' && env.SAUCE_USERNAME != null env: GITHUB_TOKEN: ${{ github.token }} @@ -77,22 +77,22 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - uses: actions/cache@v4 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: app-plain-cache with: path: sentry-android-integration-tests/test-app-plain/build/outputs/apk/release/test-app-plain-release.apk @@ -106,7 +106,7 @@ jobs: run: ./gradlew :sentry-android-integration-tests:test-app-sentry:assembleRelease - name: Collect app metrics - uses: getsentry/action-app-sdk-overhead-metrics@v1 + uses: getsentry/action-app-sdk-overhead-metrics@44fb5489ac4ac252c87d84811972dc93a1e490b8 with: config: sentry-android-integration-tests/metrics-test.yml sauce-user: ${{ secrets.SAUCE_USERNAME }} diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml new file mode 100644 index 00000000000..3e76c951f86 --- /dev/null +++ b/.github/workflows/integration-tests-size.yml @@ -0,0 +1,46 @@ +name: SDK Size Analysis + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build and Analyze SDK Size + runs-on: ubuntu-latest + + env: + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Java Version + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: "temurin" + java-version: "17" + + # Workaround for https://github.com/gradle/actions/issues/21 to use config cache + - name: Cache buildSrc + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: buildSrc/build + key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: Size Analysis + run: ./gradlew :sentry-android-integration-tests:test-app-size:bundleRelease + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index c1fb7169757..dd99f8c6b7c 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -15,7 +15,7 @@ env: BUILD_PATH: "build/outputs/apk/release" APK_NAME: "sentry-uitest-android-critical-release.apk" APK_ARTIFACT_NAME: "sentry-uitest-android-critical-release" - MAESTRO_VERSION: "1.39.0" + MAESTRO_VERSION: "2.7.0" jobs: build: @@ -27,16 +27,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -44,7 +44,7 @@ jobs: run: make assembleUiTestCriticalRelease - name: Upload APK artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{env.APK_ARTIFACT_NAME}} path: "${{env.BASE_PATH}}/${{env.BUILD_PATH}}/${{env.APK_NAME}}" @@ -60,24 +60,33 @@ jobs: matrix: include: - api-level: 31 # Android 12 - target: aosp_atd + target: google_apis channel: canary # Necessary for ATDs arch: x86_64 + memory: 4096 - api-level: 33 # Android 13 - target: aosp_atd + target: google_apis channel: canary # Necessary for ATDs arch: x86_64 - - api-level: 34 # Android 14 - target: aosp_atd + memory: 4096 + - api-level: 35 # Android 15 + target: google_apis channel: canary # Necessary for ATDs arch: x86_64 - - api-level: 35 # Android 15 - target: aosp_atd + memory: 4096 + - api-level: 36 # Android 16 + target: google_apis + channel: canary # Necessary for ATDs + arch: x86_64 + memory: 4096 + - api-level: "37.0" # Android 17; API 37 ships only as a minor-versioned image + target: google_apis_ps16k # API 37 has no plain google_apis image channel: canary # Necessary for ATDs arch: x86_64 + memory: 8192 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Enable KVM run: | @@ -85,18 +94,36 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + # The runner ships an outdated avdmanager that writes target=android-0 into the + # AVD config for minor-versioned packages (android-37.x), so the emulator clamps + # to API 3 and boots misconfigured. Update cmdline-tools so avdmanager parses it. + # See https://github.com/ReactiveCircus/android-emulator-runner/issues/482 + - name: Update SDK cmdline-tools + id: cmdline-tools + run: | + SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" + yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --install "cmdline-tools;latest" > /dev/null + # sdkmanager won't overwrite the preinstalled dir, so it installs to latest-2. + if [ -d "$SDK/cmdline-tools/latest-2" ]; then + rm -rf "$SDK/cmdline-tools/latest" + mv "$SDK/cmdline-tools/latest-2" "$SDK/cmdline-tools/latest" + fi + echo "version=$("$SDK/cmdline-tools/latest/bin/sdkmanager" --version 2>/dev/null | grep -Eo '^[0-9][0-9.]*' | head -1)" >> "$GITHUB_OUTPUT" + - name: AVD cache - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: avd-cache with: path: | ~/.android/avd/* ~/.android/adb* - key: avd-api-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }} + # Keyed on memory and the cmdline-tools version so incompatible snapshots + # and AVDs created by the old, broken avdmanager are invalidated automatically. + key: avd-api-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }}-memory${{ matrix.memory }}-tools${{ steps.cmdline-tools.outputs.version }} - name: Create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # pin@v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 with: api-level: ${{ matrix.api-level }} target: ${{ matrix.target }} @@ -105,12 +132,12 @@ jobs: force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + emulator-options: -memory ${{ matrix.memory }} -no-window -gpu auto -noaudio -no-boot-anim -camera-back none disk-size: 4096M script: echo "Generated AVD snapshot for caching." - name: Download APK artifact - uses: actions/download-artifact@v5 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{env.APK_ARTIFACT_NAME}} @@ -120,7 +147,7 @@ jobs: version: ${{env.MAESTRO_VERSION}} - name: Run tests - uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # pin@v2.34.0 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2.38.0 with: api-level: ${{ matrix.api-level }} target: ${{ matrix.target }} @@ -129,16 +156,16 @@ jobs: force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-snapshot-save + emulator-options: -memory ${{ matrix.memory }} -no-window -gpu auto -noaudio -no-boot-anim -camera-back none -no-snapshot-save script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" - maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" + mkdir "${{env.BASE_PATH}}/maestro-logs/" || true; adb emu screenrecord start --time-limit 360 "${{env.BASE_PATH}}/maestro-logs/recording.webm" || true; maestro test "${{env.BASE_PATH}}/maestro" --test-output-dir="${{env.BASE_PATH}}/maestro-logs/test-output" || MAESTRO_EXIT_CODE=$?; adb emu screenrecord stop || true; adb logcat -d > "${{env.BASE_PATH}}/maestro-logs/logcat.txt" || true; exit ${MAESTRO_EXIT_CODE:-0} - name: Upload Maestro test results - if: failure() - uses: actions/upload-artifact@v4 + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: maestro-logs + name: maestro-logs-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }} path: "${{env.BASE_PATH}}/maestro-logs" retention-days: 1 diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index e91344e7b9f..043c4730f32 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -22,18 +22,18 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -43,7 +43,7 @@ jobs: run: make assembleUiTests - name: Install SauceLabs CLI - uses: saucelabs/saucectl-run-action@39e4f0666ca8ecb4b60847213c6e0fbd6a0c2bd8 # pin@v4.3.0 + uses: saucelabs/saucectl-run-action@283660aa934c02723c497efa151d582a3acc5801 # pin@v4.5.0 env: GITHUB_TOKEN: ${{ github.token }} with: @@ -73,9 +73,25 @@ jobs: if: env.SAUCE_USERNAME != null - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: ./artifacts/*.xml + - name: Install Sentry CLI + if: ${{ !cancelled() && env.SAUCE_USERNAME != null }} + uses: getsentry/action-setup-cli@70d7e587b84c2e78cf4d37cd33d7b74fb3729c1b # v1 + + - name: Upload Replay Snapshots to Sentry + # Skip on PRs from forks, which don't have access to the upload secret + if: ${{ !cancelled() && env.SAUCE_USERNAME != null && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + run: | + shopt -s globstar nullglob + pngs=(artifacts/**/*.png) + if [ ${#pngs[@]} -gt 0 ]; then + mkdir -p replay-snapshots + cp "${pngs[@]}" replay-snapshots/ + sentry-cli snapshots upload ./replay-snapshots \ + --app-id sentry-android-replay + else + echo "No replay snapshot files found, skipping upload" + fi + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: sentry-sdks + SENTRY_PROJECT: sentry-android diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 3c2e6c2adad..a1f47577c5f 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -15,24 +15,24 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - name: Build artifacts run: make publish - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ github.sha }} if-no-files-found: error diff --git a/.github/workflows/release-comment-issues.yml b/.github/workflows/release-comment-issues.yml new file mode 100644 index 00000000000..0eeff26b9d8 --- /dev/null +++ b/.github/workflows/release-comment-issues.yml @@ -0,0 +1,39 @@ +name: 'Automation: Notify issues for release' +on: + release: + types: + - published + workflow_dispatch: + inputs: + version: + description: Which version to notify issues for + required: true + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + release-comment-issues: + runs-on: ubuntu-24.04 + name: 'Notify issues' + steps: + - name: Get version + id: get_version + env: + INPUTS_VERSION: ${{ github.event.inputs.version }} + RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} + run: echo "version=${INPUTS_VERSION:-$RELEASE_TAG_NAME}" >> "$GITHUB_OUTPUT" + + - name: Comment on linked issues that are mentioned in release + if: | + steps.get_version.outputs.version != '' + && !contains(steps.get_version.outputs.version, '-beta.') + && !contains(steps.get_version.outputs.version, '-alpha.') + && !contains(steps.get_version.outputs.version, '-rc.') + + uses: getsentry/release-comment-issues-gh-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + version: ${{ steps.get_version.outputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1dc43dc1b13..ce4bdb23b9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,8 @@ on: workflow_dispatch: inputs: version: - description: Version to release - required: true + description: Version to release (or "auto") + required: false force: description: Force a release even when there are release-blockers (optional) required: false @@ -12,6 +12,10 @@ on: description: Target branch to merge into. Uses the default branch as a fallback (optional) required: false +permissions: + contents: write + pull-requests: write + jobs: release: runs-on: ubuntu-latest @@ -19,18 +23,18 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ steps.token.outputs.token }} # Needs to be set, otherwise git describe --tags will fail with: No names found, cannot describe anything fetch-depth: 0 submodules: 'recursive' - name: Prepare release - uses: getsentry/action-prepare-release@v1 + uses: getsentry/craft@aeb16753a1764f3ef0768c03c499e3d2e4b7227c # v2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index ad6062bbf9d..66847c8c792 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -4,9 +4,11 @@ on: push: branches: - main - paths-ignore: - - '**/sentry-android/**' pull_request: + paths-ignore: + - '*android*/**' + - 'sentry-compose/**' + - 'sentry-samples/sentry-samples-android/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -19,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - springboot-version: [ '2.1.0', '2.2.5', '2.4.13', '2.5.15', '2.6.15', '2.7.0', '2.7.18' ] + springboot-version: [ '2.4.13', '2.5.15', '2.6.15', '2.7.0', '2.7.18' ] name: Spring Boot ${{ matrix.springboot-version }} env: @@ -28,12 +30,12 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' @@ -43,112 +45,93 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Update Spring Boot 2.x version run: | - sed -i 's/^springboot2=.*/springboot2=${{ matrix.springboot-version }}/' gradle/libs.versions.toml - echo "Updated Spring Boot 2.x version to ${{ matrix.springboot-version }}" - - - name: Exclude android modules from build - run: | - sed -i \ - -e '/.*"sentry-android-ndk",/d' \ - -e '/.*"sentry-android",/d' \ - -e '/.*"sentry-compose",/d' \ - -e '/.*"sentry-android-core",/d' \ - -e '/.*"sentry-android-fragment",/d' \ - -e '/.*"sentry-android-navigation",/d' \ - -e '/.*"sentry-android-sqlite",/d' \ - -e '/.*"sentry-android-timber",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ - -e '/.*"sentry-samples:sentry-samples-android",/d' \ - -e '/.*"sentry-android-replay",/d' \ - settings.gradle.kts - - - name: Exclude android modules from ignore list + springboot_version="${{ matrix.springboot-version }}" + if [[ ! "$springboot_version" =~ ^2\.7\. ]]; then + echo "ORG_GRADLE_PROJECT_excludeGraphql=true" >> "$GITHUB_ENV" + echo "ORG_GRADLE_PROJECT_excludeKafka=true" >> "$GITHUB_ENV" + fi + perl -0pi -e 'BEGIN { $v = shift } s/^springboot2[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot2 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml + echo "Updated Spring Boot 2.x version to $springboot_version" + + - name: Build sample artifacts run: | - sed -i \ - -e '/.*"sentry-uitest-android",/d' \ - -e '/.*"sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-uitest-android-critical",/d' \ - -e '/.*"test-app-sentry",/d' \ - -e '/.*"sentry-samples-android",/d' \ - build.gradle.kts - - - name: Build SDK - run: | - ./gradlew assemble --parallel + ./gradlew \ + :sentry-samples:sentry-samples-spring-boot:shadowJar \ + :sentry-samples:sentry-samples-spring-boot:testClasses \ + :sentry-samples:sentry-samples-spring-boot-webflux:shadowJar \ + :sentry-samples:sentry-samples-spring-boot-webflux:testClasses \ + :sentry-samples:sentry-samples-spring-boot-opentelemetry:shadowJar \ + :sentry-samples:sentry-samples-spring-boot-opentelemetry:testClasses \ + :sentry-samples:sentry-samples-spring-boot-opentelemetry-noagent:shadowJar \ + :sentry-samples:sentry-samples-spring-boot-opentelemetry-noagent:testClasses \ + :sentry-samples:sentry-samples-spring:war \ + :sentry-samples:sentry-samples-spring:testClasses \ + :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - name: Test sentry-samples-spring-boot run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-webflux run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-webflux" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-opentelemetry agent init true run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-opentelemetry" \ --agent true \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-opentelemetry agent init false run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-opentelemetry" \ --agent true \ - --auto-init "false" \ - --build "true" + --auto-init "false" - name: Test sentry-samples-spring-boot-opentelemetry-noagent run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-opentelemetry-noagent" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-results-springboot-2-${{ matrix.springboot-version }} path: | @@ -158,7 +141,7 @@ jobs: spring-server.txt - name: Test Report - uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 if: always() with: name: JUnit Spring Boot 2.x ${{ matrix.springboot-version }} @@ -167,10 +150,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 268f0f92129..3ccfba65c4c 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -4,9 +4,11 @@ on: push: branches: - main - paths-ignore: - - '**/sentry-android/**' pull_request: + paths-ignore: + - '*android*/**' + - 'sentry-compose/**' + - 'sentry-samples/sentry-samples-android/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -19,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - springboot-version: [ '3.0.0', '3.2.10', '3.3.5', '3.4.5', '3.5.6' ] + springboot-version: [ '3.2.12', '3.3.13', '3.4.13', '3.5.13' ] name: Spring Boot ${{ matrix.springboot-version }} env: @@ -28,12 +30,12 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' @@ -43,112 +45,89 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Update Spring Boot 3.x version run: | - sed -i 's/^springboot3=.*/springboot3=${{ matrix.springboot-version }}/' gradle/libs.versions.toml - echo "Updated Spring Boot 3.x version to ${{ matrix.springboot-version }}" + springboot_version="${{ matrix.springboot-version }}" + perl -0pi -e 'BEGIN { $v = shift } s/^springboot3[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot3 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml + echo "Updated Spring Boot 3.x version to $springboot_version" - - name: Exclude android modules from build - run: | - sed -i \ - -e '/.*"sentry-android-ndk",/d' \ - -e '/.*"sentry-android",/d' \ - -e '/.*"sentry-compose",/d' \ - -e '/.*"sentry-android-core",/d' \ - -e '/.*"sentry-android-fragment",/d' \ - -e '/.*"sentry-android-navigation",/d' \ - -e '/.*"sentry-android-sqlite",/d' \ - -e '/.*"sentry-android-timber",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ - -e '/.*"sentry-samples:sentry-samples-android",/d' \ - -e '/.*"sentry-android-replay",/d' \ - settings.gradle.kts - - - name: Exclude android modules from ignore list + - name: Build sample artifacts run: | - sed -i \ - -e '/.*"sentry-uitest-android",/d' \ - -e '/.*"sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-uitest-android-critical",/d' \ - -e '/.*"test-app-sentry",/d' \ - -e '/.*"sentry-samples-android",/d' \ - build.gradle.kts - - - name: Build SDK - run: | - ./gradlew assemble --parallel + ./gradlew \ + :sentry-samples:sentry-samples-spring-boot-jakarta:bootJar \ + :sentry-samples:sentry-samples-spring-boot-jakarta:testClasses \ + :sentry-samples:sentry-samples-spring-boot-webflux-jakarta:bootJar \ + :sentry-samples:sentry-samples-spring-boot-webflux-jakarta:testClasses \ + :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:bootJar \ + :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:testClasses \ + :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent:bootJar \ + :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent:testClasses \ + :sentry-samples:sentry-samples-spring-jakarta:war \ + :sentry-samples:sentry-samples-spring-jakarta:testClasses \ + :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - name: Test sentry-samples-spring-boot-jakarta run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-jakarta" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-webflux-jakarta run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-webflux-jakarta" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init true run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-jakarta-opentelemetry" \ --agent true \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init false run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-jakarta-opentelemetry" \ --agent true \ - --auto-init "false" \ - --build "true" + --auto-init "false" - name: Test sentry-samples-spring-boot-jakarta-opentelemetry-noagent run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-jakarta-opentelemetry-noagent" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-jakarta run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-jakarta" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-results-springboot-3-${{ matrix.springboot-version }} path: | @@ -158,7 +137,7 @@ jobs: spring-server.txt - name: Test Report - uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 if: always() with: name: JUnit Spring Boot 3.x ${{ matrix.springboot-version }} @@ -167,10 +146,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 6d0b9264b28..f75f31e38ef 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -4,9 +4,11 @@ on: push: branches: - main - paths-ignore: - - '**/sentry-android/**' pull_request: + paths-ignore: + - '*android*/**' + - 'sentry-compose/**' + - 'sentry-samples/sentry-samples-android/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -19,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - springboot-version: [ '4.0.0-M1', '4.0.0-M2', '4.0.0-M3' ] + springboot-version: [ '4.0.0', '4.0.5', '4.1.0' ] name: Spring Boot ${{ matrix.springboot-version }} env: @@ -28,12 +30,12 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' @@ -43,113 +45,89 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Update Spring Boot 4.x version run: | - sed -i 's/^springboot4=.*/springboot4=${{ matrix.springboot-version }}/' gradle/libs.versions.toml - echo "Updated Spring Boot 4.x version to ${{ matrix.springboot-version }}" + springboot_version="${{ matrix.springboot-version }}" + perl -0pi -e 'BEGIN { $v = shift } s/^springboot4[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot4 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml + echo "Updated Spring Boot 4.x version to $springboot_version" - - name: Exclude android modules from build - run: | - sed -i \ - -e '/.*"sentry-android-ndk",/d' \ - -e '/.*"sentry-android",/d' \ - -e '/.*"sentry-compose",/d' \ - -e '/.*"sentry-android-core",/d' \ - -e '/.*"sentry-android-fragment",/d' \ - -e '/.*"sentry-android-navigation",/d' \ - -e '/.*"sentry-android-sqlite",/d' \ - -e '/.*"sentry-android-timber",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ - -e '/.*"sentry-samples:sentry-samples-android",/d' \ - -e '/.*"sentry-android-replay",/d' \ - settings.gradle.kts - - - name: Exclude android modules from ignore list + - name: Build sample artifacts run: | - sed -i \ - -e '/.*"sentry-uitest-android",/d' \ - -e '/.*"sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-uitest-android-critical",/d' \ - -e '/.*"test-app-sentry",/d' \ - -e '/.*"sentry-samples-android",/d' \ - build.gradle.kts - - - name: Build SDK - run: | - ./gradlew assemble --parallel + ./gradlew \ + :sentry-samples:sentry-samples-spring-boot-4:bootJar \ + :sentry-samples:sentry-samples-spring-boot-4:testClasses \ + :sentry-samples:sentry-samples-spring-boot-4-webflux:bootJar \ + :sentry-samples:sentry-samples-spring-boot-4-webflux:testClasses \ + :sentry-samples:sentry-samples-spring-boot-4-opentelemetry:bootJar \ + :sentry-samples:sentry-samples-spring-boot-4-opentelemetry:testClasses \ + :sentry-samples:sentry-samples-spring-boot-4-opentelemetry-noagent:bootJar \ + :sentry-samples:sentry-samples-spring-boot-4-opentelemetry-noagent:testClasses \ + :sentry-samples:sentry-samples-spring-7:war \ + :sentry-samples:sentry-samples-spring-7:testClasses \ + :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - name: Run sentry-samples-spring-boot-4 run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Run sentry-samples-spring-boot-4-webflux run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4-webflux" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Run sentry-samples-spring-boot-4-opentelemetry agent init true run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4-opentelemetry" \ --agent true \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Run sentry-samples-spring-boot-4-opentelemetry agent init false run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4-opentelemetry" \ --agent true \ - --auto-init "false" \ - --build "true" - -# needs a fix in opentelemetry-spring-boot-starter -# - name: Run sentry-samples-spring-boot-4-opentelemetry-noagent -# run: | -# python3 test/system-test-runner.py test \ -# --module "sentry-samples-spring-boot-4-opentelemetry-noagent" \ -# --agent false \ -# --auto-init "true" \ -# --build "true" + --auto-init "false" + + - name: Run sentry-samples-spring-boot-4-opentelemetry-noagent + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-4-opentelemetry-noagent" \ + --agent false \ + --auto-init "true" - name: Run sentry-samples-spring-7 run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-7" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-results-springboot-4-${{ matrix.springboot-version }} path: | @@ -159,7 +137,7 @@ jobs: spring-server.txt - name: Test Report - uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 if: always() with: name: JUnit Spring Boot 4.x ${{ matrix.springboot-version }} @@ -168,10 +146,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 38671763169..12d84c0ef99 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -5,6 +5,10 @@ on: branches: - main pull_request: + paths-ignore: + - '*android*/**' + - 'sentry-compose/**' + - 'sentry-samples/sentry-samples-android/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -54,6 +58,9 @@ jobs: - sample: "sentry-samples-console" agent: "false" agent-auto-init: "true" + - sample: "sentry-samples-console-otlp" + agent: "false" + agent-auto-init: "true" - sample: "sentry-samples-logback" agent: "false" agent-auto-init: "true" @@ -69,15 +76,18 @@ jobs: - sample: "sentry-samples-spring-boot-4-webflux" agent: "false" agent-auto-init: "true" -# - sample: "sentry-samples-spring-boot-4-opentelemetry-noagent" -# agent: "false" -# agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-4-opentelemetry-noagent" + agent: "false" + agent-auto-init: "true" - sample: "sentry-samples-spring-boot-4-opentelemetry" agent: "true" agent-auto-init: "true" - sample: "sentry-samples-spring-boot-4-opentelemetry" agent: "true" agent-auto-init: "false" + - sample: "sentry-samples-spring-boot-4-otlp" + agent: "false" + agent-auto-init: "true" - sample: "sentry-samples-spring-7" agent: "false" agent-auto-init: "true" @@ -88,11 +98,11 @@ jobs: agent: "false" agent-auto-init: "true" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - - uses: actions/setup-python@v6 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' @@ -102,52 +112,23 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Exclude android modules from build - run: | - sed -i \ - -e '/.*"sentry-android-ndk",/d' \ - -e '/.*"sentry-android",/d' \ - -e '/.*"sentry-compose",/d' \ - -e '/.*"sentry-android-core",/d' \ - -e '/.*"sentry-android-fragment",/d' \ - -e '/.*"sentry-android-navigation",/d' \ - -e '/.*"sentry-android-sqlite",/d' \ - -e '/.*"sentry-android-timber",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ - -e '/.*"sentry-samples:sentry-samples-android",/d' \ - -e '/.*"sentry-android-replay",/d' \ - settings.gradle.kts - - - name: Exclude android modules from ignore list - run: | - sed -i \ - -e '/.*"sentry-uitest-android",/d' \ - -e '/.*"sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-uitest-android-critical",/d' \ - -e '/.*"test-app-sentry",/d' \ - -e '/.*"sentry-samples-android",/d' \ - build.gradle.kts - - name: Build and run system tests run: | python3 test/system-test-runner.py test --module "${{ matrix.sample }}" --agent "${{ matrix.agent }}" --auto-init "${{ matrix.agent-auto-init }}" --build "true" - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-results-${{ matrix.sample }}-${{ matrix.agent }}-${{ matrix.agent-auto-init }}-system-test path: | @@ -156,7 +137,7 @@ jobs: spring-server.txt - name: Test Report - uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 if: always() with: name: JUnit System Tests ${{ matrix.sample }} diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml index 83d90bb9199..5b8d3d11628 100644 --- a/.github/workflows/update-deps.yml +++ b/.github/workflows/update-deps.yml @@ -9,21 +9,17 @@ on: branches: - main +permissions: + contents: write + pull-requests: write + actions: write + jobs: native: - uses: getsentry/github-workflows/.github/workflows/updater.yml@v2 - with: - path: scripts/update-sentry-native-ndk.sh - name: Native SDK - secrets: - # If a custom token is used instead, a CI would be triggered on a created PR. - api-token: ${{ secrets.CI_DEPLOY_KEY }} - - gradle-wrapper: - uses: getsentry/github-workflows/.github/workflows/updater.yml@v2 - with: - path: scripts/update-gradle.sh - name: Gradle - pattern: '^v[0-9.]+$' # only match non-preview versions - secrets: - api-token: ${{ secrets.CI_DEPLOY_KEY }} + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@607fed74f812e69201531a5185b6c3c57caa4e89 # v3 + with: + path: scripts/update-sentry-native-ndk.sh + name: Native SDK + ssh-key: ${{ secrets.CI_DEPLOY_KEY }} diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml new file mode 100644 index 00000000000..ca5108943de --- /dev/null +++ b/.github/workflows/validate-pr.yml @@ -0,0 +1,16 @@ +name: Validate PR + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + validate-pr: + runs-on: ubuntu-24.04 + permissions: + pull-requests: write + steps: + - uses: getsentry/github-workflows/validate-pr@607fed74f812e69201531a5185b6c3c57caa4e89 # v3 + with: + app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} + private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} diff --git a/.gitignore b/.gitignore index be4f11ce3d2..f252087a5ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.java-version .idea/ .gradle/ .run/ @@ -12,6 +13,7 @@ local.properties **/sentry-native-local target/ .classpath +.factorypath .project .settings/ bin/ @@ -27,3 +29,14 @@ spring-server.txt spy.log .kotlin **/tomcat.8080/webapps/ +**/__pycache__ + +# Local Claude Code settings/state that should not be committed +.claude/settings.local.json +.claude/worktrees/ +# Auto-generated by dotagents — do not commit these files. +agents.lock +.agents/.gitignore + +# Warden local run logs +.warden/logs/ diff --git a/.pi/settings.json b/.pi/settings.json new file mode 100644 index 00000000000..e614d527837 --- /dev/null +++ b/.pi/settings.json @@ -0,0 +1,6 @@ +{ + "skills": [ + "../.claude/skills" + ], + "enableSkillCommands": true +} 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/.sauce/sentry-uitest-android-ui.yml b/.sauce/sentry-uitest-android-ui.yml index 8d84f865c95..a00ee10614b 100644 --- a/.sauce/sentry-uitest-android-ui.yml +++ b/.sauce/sentry-uitest-android-ui.yml @@ -32,4 +32,5 @@ artifacts: when: always match: - junit.xml + - "*.png" directory: ./artifacts/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..42bc6677d17 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,237 @@ +# AGENTS.md + +This file provides guidance to AI coding agents when working with code in this repository. + +## Domain-Specific Rules + +This file covers the whole repository. Before working on a specific area, read the matching +rule file in `.cursor/rules/`: + +| Rule | Read it when working on | +|---|---| +| `api` | Public API surface, binary compatibility, `.api` files, `apiDump`, `IScope`/`IScopes`/`Sentry` static API, protocol classes | +| `options` | `SentryOptions`, namespaced options, `ExternalOptions`, `sentry.properties`, `ManifestMetadataReader`, Spring Boot properties | +| `scopes` | Scope management, forking, lifecycle, `ScopeType`, thread-local storage, scope bleeding, Hub → Scopes migration | +| `deduplication` | Duplicate event detection, `DuplicateEventDetectionEventProcessor`, `enableDeduplication` | +| `offline` | Caching, envelope storage, network failure handling, retries, `AsyncHttpTransport`, `EnvelopeCache`, rate limiting | +| `feature_flags` | `addFeatureFlag`, `FeatureFlagBuffer`, `maxFeatureFlags`, LaunchDarkly and OpenFeature integrations | +| `metrics` | `Sentry.metrics()`, `IMetricsApi`, count/distribution/gauge, `MetricsBatchProcessor` | +| `queues` | Queue tracing, `queue.publish`/`queue.process`, `enableQueueTracing`, Kafka instrumentation, messaging span data | +| `continuous_profiling_jvm` | `sentry-async-profiler`, `IContinuousProfiler`, `ProfileChunk`, JFR files, `ProfileLifecycle` | +| `opentelemetry` | `sentry-opentelemetry-*`, agent vs agentless, span processing, sampling, context propagation | +| `new_module` | Adding a new integration or sample module | +| `e2e_tests` | System tests, sample applications, `system-test-runner.py`, mock Sentry server | + +Rules can be combined — a tracing scope issue may need both `scopes` and `opentelemetry`. +There is no rule for Android profiling yet; read the `sentry-android-core` profiling code +directly and fetch related rules such as `options`, `offline`, or `api` as needed. + +## Project Overview + +This is the Sentry Java/Android SDK - a comprehensive error monitoring and performance tracking SDK for Java and Android applications. The repository contains multiple modules for different integrations and platforms. + +## Build System + +The project uses **Gradle** with Kotlin DSL. Key build files: +- `build.gradle.kts` - Root build configuration +- `settings.gradle.kts` - Multi-module project structure +- `buildSrc/` and `build-logic/` - Custom build logic and plugins +- `Makefile` - High-level build commands + +## Essential Commands + +### Development Workflow +```bash +# Format code and regenerate .api files (REQUIRED before committing) +./gradlew spotlessApply apiDump + +# Run all tests and linter +./gradlew check + +# Generate documentation +./gradlew aggregateJavadocs +``` + +### Testing +```bash +# Run unit tests for a specific file +./gradlew '::testReleaseUnitTest' --tests="**" --info + +# Run system tests (requires Python virtual env) +make systemTest + +# Run specific test suites +./gradlew :sentry-android-core:testReleaseUnitTest +./gradlew :sentry:test +``` + +### Code Quality +```bash +# Check code formatting +./gradlew spotlessJavaCheck spotlessKotlinCheck + +# Apply code formatting +./gradlew spotlessApply + +# Update API dump files (after API changes) +./gradlew apiDump + +# Dependency updates check +./gradlew dependencyUpdates -Drevision=release +``` + +### Android-Specific Commands +```bash +# Assemble Android test APKs +./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest + +# Run critical UI tests +./scripts/test-ui-critical.sh +``` + +## Development Workflow Rules + +### Planning and Implementation Process +1. **First think through the problem**: Read the codebase for relevant files and propose a plan +2. **Check in before beginning**: Verify the plan before starting implementation +3. **Use todo tracking**: Work through todo items, marking them as complete as you go +4. **High-level communication**: Give high-level explanations of changes made, not step-by-step descriptions +5. **Simplicity first**: Make every task and code change as simple as possible. Avoid massive or complex changes. Impact as little code as possible. +6. **Format and regenerate**: Once done, format code and regenerate .api files: `./gradlew spotlessApply apiDump` +7. **Propose commit**: As final step, git stage relevant files and propose (but not execute) a single git commit command. This applies to implementation work; when the task is to open a PR, the `create-java-pr` skill takes over from here and does commit, push, and open it. + +## Repository Skills + +This repo ships task-specific skills (declared in `agents.toml`, sources under `.agents/skills`). Prefer them over performing the steps manually: +- **`create-java-pr`**: Branch, format, `apiDump`, commit, push, open PR, and add the changelog entry (automates the PR workflow above) +- **`test`**: Run unit or system tests for a module or a specific class +- **`check-code-attribution`**: Verify third-party code attribution on the current branch (see Third-Party Code Attribution below) +- **`btrace-perfetto`**: Capture and compare Perfetto traces for Android performance work + +## Module Architecture + +The repository is organized into multiple modules: + +### Core Modules +- **`sentry`** - Core Java SDK implementation +- **`sentry-android-core`** - Core Android SDK implementation +- **`sentry-android`** - High-level Android SDK +- **`sentry-android-ndk`** - Native (NDK) crash handling + +### Integration Modules +- **Spring Framework**: `sentry-spring*`, `sentry-spring-boot*` +- **Logging**: `sentry-logback`, `sentry-log4j2`, `sentry-jul`, `sentry-android-timber` +- **Web**: `sentry-servlet*`, `sentry-okhttp`, `sentry-openfeign`, `sentry-apache-http-client-5` +- **GraphQL**: `sentry-graphql*`, `sentry-apollo*` +- **Android UI**: `sentry-android-fragment`, `sentry-android-navigation`, `sentry-compose` +- **Session Replay**: `sentry-android-replay` +- **Database**: `sentry-jdbc`, `sentry-android-sqlite`, `sentry-jcache` +- **Reactive**: `sentry-reactor`, `sentry-ktor-client` +- **Feature Flags**: `sentry-launchdarkly-android`, `sentry-launchdarkly-server`, `sentry-openfeature` +- **Queues**: `sentry-kafka` +- **Profiling**: `sentry-async-profiler` (JVM continuous profiling) +- **Monitoring**: `sentry-opentelemetry*`, `sentry-quartz` +- **Other**: `sentry-spotlight`, `sentry-kotlin-extensions`, `sentry-android-distribution` + +### Utility Modules +- **`sentry-test-support`** - Shared test utilities +- **`sentry-system-test-support`** - System testing infrastructure +- **`sentry-samples`** - Example applications +- **`sentry-bom`** - Bill of Materials for dependency management + +### Key Architectural Patterns +- **Multi-platform**: Supports JVM, Android, and Kotlin Multiplatform (Compose modules) +- **Modular Design**: Each integration is a separate module with minimal dependencies +- **Options Pattern**: Features are opt-in via `SentryOptions` and similar configuration classes +- **Transport Layer**: Pluggable transport implementations for different environments +- **Scope Management**: Thread-safe scope/context management for error tracking + +## Development Guidelines + +### Code Style +- **Languages**: Java 8+ and Kotlin +- **Formatting**: Enforced via Spotless - always run `./gradlew spotlessApply` before committing +- **API Compatibility**: Binary compatibility is enforced - run `./gradlew apiDump` after API changes + +### Exception Handling + +**Never introduce a new `catch (Throwable)`.** Catch the narrowest type the guarded code can +actually throw. The repository still contains many pre-existing broad catches; they are legacy, +not a precedent to follow. + +A broad catch swallows `OutOfMemoryError`, `StackOverflowError`, `ThreadDeath` and `LinkageError` — +conditions the JVM/ART cannot recover from and that leave the process in an undefined state — and +it hides real bugs in our own code behind a log line. + +"The SDK must never crash the host application" is not a reason to catch `Throwable`. That goal is +served by `io.sentry.util.ExceptionUtils.rethrowIfFatal`, which lets the non-recoverable throwables +through while leaving everything else for the caller to log or ignore: + +```java +try { + doSomethingRisky(); +} catch (Throwable t) { + ExceptionUtils.rethrowIfFatal(t); + options.getLogger().log(SentryLevel.ERROR, "Failed to do something risky", t); +} +``` + +Apply that pattern only where a broad catch is genuinely unavoidable — an entry point that runs +arbitrary user code or third-party callbacks. Everywhere else, name the exception types. Say in the +PR description why the broad catch is necessary. + +### Testing Requirements +- Write comprehensive unit tests for new features +- Android modules require both unit tests and instrumented tests where applicable +- System tests validate end-to-end functionality with sample applications +- **Assertions**: For new unit tests, prefer [Google Truth](https://truth.dev/) (`com.google.common.truth.Truth.assertThat`) over `kotlin.test`/JUnit assertions for its readable, fluent API. Keep using `kotlin.test` for test structure (`@Test`, `assertFailsWith`). See `sentry/src/test/java/io/sentry/DsnTest.kt` for the style. Don't rewrite existing `kotlin.test` assertions solely to switch libraries. +- Truth is wired into the `sentry` module. When adding Truth-based tests to another module, add `testImplementation(libs.google.truth)` to that module's `build.gradle.kts`. + +### Contributing Guidelines +1. Follow existing code style and language +2. Do not modify API files (e.g. sentry.api) manually - run `./gradlew apiDump` to regenerate them +3. Write comprehensive tests +4. New features must be **opt-in by default** - extend `SentryOptions` or similar Option classes with getters/setters +5. Consider backwards compatibility + +### Third-Party Code Attribution +When adapting code from third-party libraries: +1. Add a license header at the top of the adapted file (before the `package` statement): + ```java + // Adapted from . + // Copyright . + // Licensed under the . + // + ``` +2. Add a full attribution entry to `THIRD_PARTY_NOTICES.md` following the existing format (Source, License, Copyright, Scope, full license text) + +3. Run the `check-code-attribution` skill locally or wait for it to be auto-run against your PR to check for required fields and verify new licenses against [Sentry's Open Source Legal Policy](https://open.sentry.io/licensing/). + +### Getting PR Information + +Use `gh pr view` to get PR details from the current branch. This is needed when adding changelog entries, which require the PR number. + +```bash +# Get PR number for current branch +gh pr view --json number -q '.number' + +# Get PR number for a specific branch +gh pr view --json number -q '.number' + +# Get PR URL +gh pr view --json url -q '.url' +``` + +### Changelog + +User-facing changes get an entry under the `## Unreleased` section of `CHANGELOG.md`. The +`create-java-pr` skill is the source of truth for the full changelog and PR workflow, including +subsection selection and the rebase caveat when a release renames `## Unreleased`. + +## Useful Resources + +- Main SDK documentation: https://develop.sentry.dev/sdk/overview/ +- Internal contributing guide: https://docs.sentry.io/internal/contributing/ +- Git commit message conventions: https://develop.sentry.dev/engineering-practices/commit-messages/ + +This SDK is production-ready and used by thousands of applications. Changes should be thoroughly tested and maintain backwards compatibility. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cb73a5552f..0a45ec3fe37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,1049 @@ ## Unreleased +### Dependencies + +- Bump Native SDK from v0.16.2 to v0.16.3 ([#5962](https://github.com/getsentry/sentry-java/pull/5962)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0163) + - [diff](https://github.com/getsentry/sentry-native/compare/0.16.2...0.16.3) + +## 8.53.0 + +### Features + +- Allow child spans to use explicit start timestamps through `ISpan` ([#5929](https://github.com/getsentry/sentry-java/pull/5929)) +- Make `ISpan.startChild` overloads with `SpanOptions` public ([#5927](https://github.com/getsentry/sentry-java/pull/5927)) +- Add `Sentry.feedback().enableOnShake()`, `Sentry.feedback().disableOnShake()`, and `Sentry.feedback().isOnShakeEnabled()` to toggle and query shake-to-report at runtime ([#5827](https://github.com/getsentry/sentry-java/pull/5827)) + +### Improvements + +- Remove `ApiStatus.Experimental` annotation from `SentrySQLiteDriver` ([#5938](https://github.com/getsentry/sentry-java/pull/5938)) + +### Fixes + +- 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)) +- Prevent an ANR when the Session Replay video encoder gets stuck ([#5842](https://github.com/getsentry/sentry-java/pull/5842)) + - Some hardware encoders never signal end-of-stream, which made the replay worker spin forever while holding the encoder lock. The app's lifecycle callbacks then blocked on that lock and the app froze until the system killed it. The encoder now gives up instead of spinning, and closing the replay cache no longer waits indefinitely for a wedged encoder. + +### Performance + +- Read the clock once per performance collection round instead of once per in-flight transaction ([#5934](https://github.com/getsentry/sentry-java/pull/5934)) +- 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)) +- Store performance measurements as primitives, removing a boxed allocation per measurement per performance sample ([#5935](https://github.com/getsentry/sentry-java/pull/5935)) + +### Dependencies + +- Bump Native SDK from v0.16.1 to v0.16.2 ([#5910](https://github.com/getsentry/sentry-java/pull/5910)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0162) + - [diff](https://github.com/getsentry/sentry-native/compare/0.16.1...0.16.2) + +## 8.52.0 + +### Fixes + +- Restore the interrupt flag when cached envelope processing is interrupted between files ([#5884](https://github.com/getsentry/sentry-java/pull/5884)) +- Reduce false-positive SDK crash attribution for host app SQLite cursor crashes ([#5883](https://github.com/getsentry/sentry-java/pull/5883)) +- Prevent inflated cold app start when the OS spawns the process in the background (e.g. FCM push) on API 35+ ([#5841](https://github.com/getsentry/sentry-java/pull/5841), [#5880](https://github.com/getsentry/sentry-java/pull/5880)) +- Preserve single-sample ANR profile chunks so profiles remain available on ANR events ([#5872](https://github.com/getsentry/sentry-java/pull/5872)) +- Avoid a CPU busy-loop when recording discarded log or metric envelopes under rate limiting ([#5835](https://github.com/getsentry/sentry-java/pull/5835)) + - `ClientReportRecorder` now reads the item count from the envelope item header instead of deserializing the payload, which under sustained rate limiting could pin CPU cores while repeatedly throwing exceptions +- Report tasks handed to a no-op `ISentryExecutorService` as cancelled ([#5874](https://github.com/getsentry/sentry-java/pull/5874)) + - `NoOpSentryExecutorService` previously returned a `Future` that was never run and never cancelled, so callers could not tell a dropped task from a queued one and `get()` would block until its timeout + +### Performance + +- Defer use of reflection by `SentryFrameMetricsCollector` during `Sentry.init` ([#5886](https://github.com/getsentry/sentry-java/pull/5886)) +- Avoid waiting up to `shutdownTimeoutMillis` when closing the SDK with a pending transaction timeout or session-end task ([#5851](https://github.com/getsentry/sentry-java/pull/5851)) +- Use `RGB_565` instead of `ARGB_8888` for screenshot and replay capture bitmaps, halving per-frame memory usage ([#5821](https://github.com/getsentry/sentry-java/pull/5821)) +- Remove an unused lock from `SentryPerformanceProvider`, which was allocated on every cold start in `ContentProvider.onCreate` without ever being acquired ([#5871](https://github.com/getsentry/sentry-java/pull/5871)) +- Reduce main-thread allocations when parsing the app start profiling config ([#5867](https://github.com/getsentry/sentry-java/pull/5867)) +- Batch and coalesce scope-persistence disk writes to reduce startup cost ([#5791](https://github.com/getsentry/sentry-java/pull/5791)) + - Scope mutations are now coalesced (latest value per field) and breadcrumbs are appended in batches behind a single fsync, instead of one synchronous disk write per mutation. +- Reduce the number of SDK threads: the `HostnameCache` worker thread now times out while idle instead of staying alive for the whole process lifetime ([#5817](https://github.com/getsentry/sentry-java/pull/5817)) + +### Dependencies + +- Bump Native SDK from v0.16.0 to v0.16.1 ([#5879](https://github.com/getsentry/sentry-java/pull/5879)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0161) + - [diff](https://github.com/getsentry/sentry-native/compare/0.16.0...0.16.1) + +## 8.51.0 + +### Features + +- Use Android's `ProfilingManager` (Perfetto) for continuous profiling on API 35+ devices ([#5251](https://github.com/getsentry/sentry-java/pull/5251)) + - On API 35+ devices, continuous profiling now automatically uses Android's system `ProfilingManager` with Perfetto-based stack sampling, providing lower-overhead and more accurate profiles. No configuration change is required. + - Devices below API 35 keep using the legacy `Debug`-based profiler. + - Added an `enableLegacyProfiling` option (default `true`) to disable the legacy `Debug`-based profiler. Setting it to `false` disables continuous profiling on API < 35 devices as well as transaction-based profiling (`profilesSampleRate`/`profilesSampler`) on all devices, since transaction-based profiling is not supported by Perfetto. + - It can also be configured via the `io.sentry.profiling.enable-legacy-profiling` manifest flag. + - See the [Android profiling docs](https://docs.sentry.io/platforms/android/profiling/) for details. + +### Behavioral Changes + +- The outbox and cache directories are no longer created by `Sentry.init` ([#5792](https://github.com/getsentry/sentry-java/pull/5792)) + - They are now created lazily by whichever component first writes into them, off the init thread. As a result, the directories at `SentryOptions.getOutboxPath()` and `SentryOptions.getCacheDirPath()` are not guaranteed to exist once `Sentry.init` returns. + - If you write envelopes into the outbox path yourself instead of going through the SDK — as hybrid SDKs do for `captureEnvelope` — create the directory first, e.g. `new File(outboxPath).mkdirs()`. + +### Improvements + +- Skip building Android manifest metadata debug log messages when debug logging is disabled, reducing allocations during SDK init ([#5790](https://github.com/getsentry/sentry-java/pull/5790)) + +### Fixes + +- Use the original app build's ProGuard UUID for ANR profile chunks ([#5852](https://github.com/getsentry/sentry-java/pull/5852)) +- Fix potential ANR/deadlock in Session Replay when `checkCanRecord` runs on the replay executor thread ([#5837](https://github.com/getsentry/sentry-java/pull/5837)) +- Prevent concurrent PixelCopy access during Session Replay masking and bitmap cleanup ([#5808](https://github.com/getsentry/sentry-java/pull/5808)) +- Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607)) +- Set the correct platform (`android` instead of `java`) on ANR profile chunks so they are billed as UI Profile Hours rather than Continuous Profile Hours ([#5836](https://github.com/getsentry/sentry-java/pull/5836)) +- Skip encoding and capturing buffered session replay segments while rate-limited, so we don't waste resources on envelopes the transport will drop ([#5813](https://github.com/getsentry/sentry-java/pull/5813)) + - These skipped replays are now reported as `ratelimit_backoff` discarded events in client reports, so they no longer disappear from drop statistics. One event is recorded per buffer flush rather than per segment. + - Buffer mode is also kept while rate-limited instead of switching to session mode, so the rolling buffer stays warm and the next error after the rate limit expires can send a complete replay. + +### Performance + +- Create the outbox and cache directories lazily in their consumers instead of during SDK init, moving the `mkdirs()` calls off the init (main) thread ([#5792](https://github.com/getsentry/sentry-java/pull/5792)) +- Reduce the number of SDK threads: `LifecycleWatcher` now schedules the session-end task on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5819](https://github.com/getsentry/sentry-java/pull/5819)) +- Reduce the number of SDK threads: `RateLimiter` now schedules its rate-limit-lifted notifications on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5814](https://github.com/getsentry/sentry-java/pull/5814)) +- Speed up deserialization of arbitrary JSON objects by typing numbers without throwing exceptions ([#5783](https://github.com/getsentry/sentry-java/pull/5783)) + +### Dependencies + +- Bump Native SDK from v0.15.4 to v0.16.0 ([#5845](https://github.com/getsentry/sentry-java/pull/5845)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0160) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.4...0.16.0) + +## 8.50.1 + +### Fixes + +- Pin the published Sentry Android SDK's AAR metadata `minCompileSdk` to our `minSdk` (`21`) instead of AGP 9's new default of the SDK's own `compileSdk` (`37`), so apps that depend on the SDK aren't forced to raise their `compileSdk` ([#5823](https://github.com/getsentry/sentry-java/pull/5823)) + +## 8.50.0 + +### Android 17 support + +- We've put Android 17 through a set of rigorous tests. We're now officially giving it the Sentry stamp of compatibility .([#5796](https://github.com/getsentry/sentry-java/pull/5796)) + +### Fixes + +- Reduce main-thread work during `Sentry.init` by resolving the shake-detector accelerometer off the main thread (~1.75ms on a Pixel 10) ([#5784](https://github.com/getsentry/sentry-java/pull/5784)) +- Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) +- `SentryTagModifierNode.isImportantForBounds` now matches the default behavior and returns `true` ([#5789](https://github.com/getsentry/sentry-java/pull/5789)) +- Prevent a `StackOverflowError` when a `beforeSend`, `beforeBreadcrumb`, `beforeSendLog`, or `beforeEnvelope` callback triggers another capture (directly or through a logging integration such as Timber) ([#5737](https://github.com/getsentry/sentry-java/pull/5737)) + - Captures made from within a user callback (event, transaction, breadcrumb, log, envelope, or check-in) are now dropped while that callback runs, instead of recursing. Captures made by event processors are unaffected. +- Replace deprecated `ThrowableProxy` with `LogEvent#getThrown()` in `sentry-log4j2` ([#5751](https://github.com/getsentry/sentry-java/pull/5751)) + +### Dependencies + +- Bump Native SDK from v0.15.3 to v0.15.4 ([#5793](https://github.com/getsentry/sentry-java/pull/5793)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0154) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.3...0.15.4) +- The SDK is now compiled with Android Gradle Plugin 9.2.1 ([#5779](https://github.com/getsentry/sentry-java/pull/5779)) + +## 8.49.0 + +### Features + +- Session Replay: Record segment names (transaction names) ([#5763](https://github.com/getsentry/sentry-java/pull/5763)) + +- Add `io.sentry:sentry-opentelemetry-bom` to align Sentry OpenTelemetry modules with tested OpenTelemetry dependencies ([#5629](https://github.com/getsentry/sentry-java/pull/5629)) + - Spring Boot Gradle plugin: add the Sentry BOM to `dependencyManagement`; explicit imports are applied after Spring Boot's implicit BOM + ```kotlin + dependencyManagement { + imports { + mavenBom("io.sentry:sentry-opentelemetry-bom:") + } + } + ``` + - Gradle: import it as a platform and omit versions from Sentry OpenTelemetry and OpenTelemetry dependencies + ```kotlin + implementation(platform("io.sentry:sentry-opentelemetry-bom:")) + ``` + - Maven: import it before Spring Boot's BOM in the same `` block, or in the child POM when using `spring-boot-starter-parent` + ```xml + + io.sentry + sentry-opentelemetry-bom + ${sentry.version} + pom + import + + ``` + +### Fixes + +- Session Replay: Fix first recording segment missing for replays in `buffer` mode ([#5753](https://github.com/getsentry/sentry-java/pull/5753)) +- Session Replay: Fix error-to-replay linkage in `buffer` mode ([#5754](https://github.com/getsentry/sentry-java/pull/5754)) +- Prevent logs and metrics from remaining queued after a flush scheduling race ([#5756](https://github.com/getsentry/sentry-java/pull/5756)) +- Fix main thread identification for tombstone (native crash) events ([#5742](https://github.com/getsentry/sentry-java/pull/5742)) +- Prevent malformed JDBC URLs, which may contain credentials, from being printed to stdout ([#5656](https://github.com/getsentry/sentry-java/pull/5656)) +- Restrict JVM-global proxy authentication credentials to challenges from the configured proxy host ([#5656](https://github.com/getsentry/sentry-java/pull/5656)) +- Sanitize Spring 7 and Spring Jakarta WebClient span descriptions to prevent embedded URL credentials from being sent to Sentry ([#5656](https://github.com/getsentry/sentry-java/pull/5656)) +- Respect `tracePropagationTargets` when injecting Sentry tracing headers through the OpenTelemetry OTLP propagator ([#5656](https://github.com/getsentry/sentry-java/pull/5656)) + +### Performance + +- Schedule transaction idle/deadline timeouts on a shared, dedicated executor instead of spawning a `Timer` thread per transaction ([#5670](https://github.com/getsentry/sentry-java/pull/5670)) + +### Dependencies + +- Bump OpenTelemetry to support Spring Boot 4.1 ([#5573](https://github.com/getsentry/sentry-java/pull/5573)) + - If this causes issues for you because you are also using Spring Boot Dependency Management Plugin (io.spring.dependency-management), + which may downgrade the OpenTelemetry SDK, please have a look at the changelog entry above that explains how to use `sentry-opentelemetry-bom`. + - OpenTelemetry to 1.63.0 (was 1.60.1) + - OpenTelemetry Instrumentation to 2.29.0 (was 2.26.0) + - OpenTelemetry Instrumentation Alpha to 2.29.0-alpha (was 2.26.0-alpha) + - OpenTelemetry Semantic Conventions to 1.42.0 (was 1.40.0) + - OpenTelemetry Semantic Conventions Alpha to 1.42.0-alpha (was 1.40.0-alpha) +- Bump Native SDK from v0.15.2 to v0.15.3 ([#5728](https://github.com/getsentry/sentry-java/pull/5728)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0153) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.2...0.15.3) + +## 8.48.0 + +### Features + +- Add `Sentry.extendAppStart()`, `Sentry.finishExtendedAppStart()`, and `Sentry.getExtendedAppStartSpan()` to extend the app start measurement past the first frame for extra launch-time work on Android ([#5604](https://github.com/getsentry/sentry-java/pull/5604)) + - Requires standalone app start tracing (`options.isEnableStandaloneAppStartTracing`). Call `extendAppStart()` in `Application.onCreate` after SDK init and `finishExtendedAppStart()` when done: + + ```kotlin + Sentry.extendAppStart() + + // Optionally, retrieve the extended app start span to attach your own child spans + val child = Sentry.getExtendedAppStartSpan()?.startChild("preload", "Preload resources") + // ... extra launch-time work ... + child?.finish() + + Sentry.finishExtendedAppStart() + ``` +- Add `trace_metric_byte` data category and record byte-level client reports when trace metrics are discarded ([#5626](https://github.com/getsentry/sentry-java/pull/5626)) +- Expose sentry-native's heartbeat-based app-hang detection through `SentryAndroidOptions` ([#5623](https://github.com/getsentry/sentry-java/pull/5623)) + - Enable via `setEnableNdkAppHangTracking(true)` (disabled by default) and tune the timeout with `setNdkAppHangTimeoutIntervalMillis(...)` (default `5000` ms), or the `io.sentry.ndk.app-hang.enable` / `io.sentry.ndk.app-hang.timeout-interval-millis` manifest entries + - Intended for hybrid SDKs: emit the heartbeat by calling the native `sentry_app_hang_heartbeat()` from the thread you want monitored. Independent of the JVM-based ANR detection (`setAnrEnabled`) +- Support the `io.sentry.tombstone.report-historical` manifest option to enable historical tombstone reporting via `AndroidManifest.xml` `` ([#5683](https://github.com/getsentry/sentry-java/pull/5683)) + +### Fixes + +- Fix `NoSuchMethodError` from using `Math.floorDiv`/`Math.floorMod` overloads that are unavailable on Java 8 ([#5743](https://github.com/getsentry/sentry-java/pull/5743)) +- Fix main thread identification parsing for ApplicationExitInfo ANRs ([#5733](https://github.com/getsentry/sentry-java/pull/5733)) +- Do not send threads without stacktraces for ApplicationExitInfo ANRs ([#5733](https://github.com/getsentry/sentry-java/pull/5733)) +- Record byte-level client reports when event processors discard logs or trace metrics ([#5718](https://github.com/getsentry/sentry-java/pull/5718)) +- Name the device-info caching thread `SentryDeviceInfoCache` so all threads spawned by the SDK are identifiable ([#5684](https://github.com/getsentry/sentry-java/pull/5684)) +- Apply byte-category rate limits to log and trace metric envelope items ([#5716](https://github.com/getsentry/sentry-java/pull/5716)) + +### Performance + +- Skip `Hint` allocation in `Scope.addBreadcrumb` when no `beforeBreadcrumb` callback is set ([#5689](https://github.com/getsentry/sentry-java/pull/5689)) +- Speed up scope persistence by detecting the Sentry executor thread via a marker instead of a `Thread.getName()` name scan on every scope mutation ([#5691](https://github.com/getsentry/sentry-java/pull/5691)) +- Remove executor prewarm during SDK init ([#5681](https://github.com/getsentry/sentry-java/pull/5681)) + - The single-threaded `SentryExecutorService` queued the prewarm work ahead of the first useful task, so it could only delay init work, never speed it up; the thread and class loading it warmed are paid identically by the first real task submitted right after. + +### Dependencies + +- Bump Native SDK from v0.15.2 to v0.15.3 ([#5623](https://github.com/getsentry/sentry-java/pull/5623)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0153) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.2...0.15.3) + +## 8.47.0 + +### Behavioral Changes + +- `SentryOkHttpInterceptor::intercept` now throws `IOException`. This is a source-only and Java-only breaking change ([#5654](https://github.com/getsentry/sentry-java/pull/5654)) + +### Fixes + +- Fix fragment tracing not working with detach/attach navigation ([#5660](https://github.com/getsentry/sentry-java/pull/5660)) +- Don't start a redundant UI interaction transaction when a transaction is already bound to the Scope ([#5491](https://github.com/getsentry/sentry-java/issues/5491)) + - Previously, `SentryGestureListener` always started a UI transaction and only afterwards skipped binding it to the Scope when a manually-bound transaction already existed, leaving the new transaction to be dropped as an idle transaction without children. +- Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) +- Fix memory leak in `ReplayIntegration` due to persisting executor not being shut down ([#5627](https://github.com/getsentry/sentry-java/pull/5627)) +- Fix AbstractMethodError when compose-ui 1.11+ is used in combination with `Modifier.sentryTag()` or the Sentry Kotlin compiler plugin ([#5672](https://github.com/getsentry/sentry-java/pull/5672)) + +### Performance + +- Speed up touch gesture target detection on deeply nested view hierarchies by hit-testing in local coordinates instead of calling `getLocationOnScreen` per view ([#5595](https://github.com/getsentry/sentry-java/pull/5595)) +- Probe class availability without initializing the class during SDK init ([#5635](https://github.com/getsentry/sentry-java/pull/5635)) +- Avoid constructing an exception per view when resolving view ids during view-hierarchy and gesture capture ([#5631](https://github.com/getsentry/sentry-java/pull/5631)) +- Start the frame metrics thread lazily on first collection instead of during SDK init ([#5641](https://github.com/getsentry/sentry-java/pull/5641)) +- Reduce `SentryId` and `SpanId` allocation overhead by replacing their per-instance `LazyEvaluator` (and its lock) with a lightweight lazily-generated `String`. ([#5645](https://github.com/getsentry/sentry-java/pull/5645)) +- Lazily allocate the `ReentrantLock` backing `AutoClosableReentrantLock` to avoid eager lock allocations for SDK objects that never contend during `SentryAndroid.init` ([#5643](https://github.com/getsentry/sentry-java/pull/5643)) + +## 8.46.0 + +### Fixes + +- Session Replay: Fix network detail response body size being unknown for gzip-compressed responses ([#5592](https://github.com/getsentry/sentry-java/pull/5592)) + +### Behavioral Changes + +- Collections returned by scope (e.g. `getBreadcrumbs`, `getTags`, `getAttachments`) are shared state and should not be mutated. ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) + - Previously, when going through `CombinedScopeView`, we were returning a copy where mutations didn't show up in the underlying scopes. + - This has now changed in order to reduce SDK overhead. +- `Date` objects returned by SDK data model getters are shared state and should not be mutated. ([#5603](https://github.com/getsentry/sentry-java/pull/5603)) + - Previously, these getters returned defensive copies for some date fields. + - This has now changed in order to reduce SDK overhead. + +### Performance + +- Reduce writer buffer size from 8192 to 512 ([#5544](https://github.com/getsentry/sentry-java/pull/5544)) +- Remove redundant event map copies ([#5536](https://github.com/getsentry/sentry-java/pull/5536)) +- Optimize combined scope by adding an early return if only one scope has data ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) +- Reduce model access overhead by avoiding defensive `Date` copies in SDK data model getters. ([#5603](https://github.com/getsentry/sentry-java/pull/5603)) +- Reduce timestamp parsing and formatting overhead with Sentry-specific ISO-8601 handling. ([#5602](https://github.com/getsentry/sentry-java/pull/5602)) +- Reduce JSON serialization overhead by creating the reflection serializer only when unknown-object fallback serialization is needed. ([#5601](https://github.com/getsentry/sentry-java/pull/5601)) +- Reduce JSON serialization overhead by allocating reflection cycle-tracking state only when reflection serialization is used. ([#5600](https://github.com/getsentry/sentry-java/pull/5600)) +- Reduce context serialization overhead by sorting key snapshots with arrays instead of temporary lists. ([#5599](https://github.com/getsentry/sentry-java/pull/5599)) +- Reduce breadcrumb allocation overhead by creating the `Breadcrumb` data map only when data is added. ([#5598](https://github.com/getsentry/sentry-java/pull/5598)) +- Reduce JSON serialization overhead by lowering the initial `JsonWriter` nesting stack size while preserving on-demand growth. ([#5591](https://github.com/getsentry/sentry-java/pull/5591)) +- Reduce timestamp helper overhead by replacing unnecessary `Calendar` usage in `DateUtils` with direct `Date` creation. ([#5589](https://github.com/getsentry/sentry-java/pull/5589)) +- Reduce Android startup overhead by using the default timezone directly on older devices or when no timezone info is available in the locale. ([#5587](https://github.com/getsentry/sentry-java/pull/5587)) + +## 8.45.0 + +### Features + +- On Android 15+ (API 35), the standalone `app.start` transaction now reports why the OS started the process via `app.vitals.start.reason` trace data (e.g. `launcher`, `broadcast`, `service`, `content_provider`), derived from `ApplicationStartInfo.getReason()`. You can search and group by this attribute in the Trace Explorer. ([#5552](https://github.com/getsentry/sentry-java/pull/5552)) + +### Fixes + +- Use `System.nanoTime()` for cron check-in duration measurement to avoid incorrect durations from wall-clock adjustments ([#5611](https://github.com/getsentry/sentry-java/pull/5611)) +- Fix crash when `getHistoricalProcessStartReasons` is called from an isolated or wrong-userId process ([#5597](https://github.com/getsentry/sentry-java/pull/5597)) +- Release `MediaMuxer` when a replay segment has no encodable frames to avoid a resource leak ([#5583](https://github.com/getsentry/sentry-java/pull/5583)) + +### Dependencies + +- Bump Native SDK from v0.15.1 to v0.15.2 ([#5610](https://github.com/getsentry/sentry-java/pull/5610)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0152) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.1...0.15.2) + +## 8.44.1 + +### Fixes + +- Fix `FirstDrawDoneListener` leaking an `OnGlobalLayoutListener` per registration ([#5567](https://github.com/getsentry/sentry-java/pull/5567)) + +### Features + +- Add experimental `SentrySQLiteDriver` to `sentry-android-sqlite` for instrumenting `androidx.sqlite.SQLiteDriver` ([#5563](https://github.com/getsentry/sentry-java/pull/5563)) + - To use it, pass `SQLiteDriver` to `SentrySQLiteDriver.create(...)` + - Requires `androidx.sqlite:sqlite` (2.5.0+) on runtime classpath (typically provided by Room or SQLDelight) + +### Dependencies + +- Bump Native SDK from v0.15.0 to v0.15.1 ([#5570](https://github.com/getsentry/sentry-java/pull/5570)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0151) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.0...0.15.1) + +## 8.44.0 + +### Features + +- Add `enableStandaloneAppStartTracing` option to send app start as a standalone transaction instead of attaching it as a child span of the first activity transaction ([#5342](https://github.com/getsentry/sentry-java/pull/5342)) + - Disabled by default; opt in via `options.isEnableStandaloneAppStartTracing = true` or manifest meta-data `io.sentry.standalone-app-start-tracing.enable` + - Emits a transaction named `App Start` with op `app.start`, carrying the existing app start measurements and phase spans (`process.load`, `contentprovider.load`, `application.load`, activity lifecycle spans) as direct children of the root + - The standalone transaction shares the same `traceId` as the first `ui.load` activity transaction so they remain linked in the trace view + - Also covers non-activity starts (broadcast receivers, services, content providers) + +### Improvements + +- Reduce boxing to improve performance ([#5523](https://github.com/getsentry/sentry-java/pull/5523), [#5527](https://github.com/getsentry/sentry-java/pull/5527), [#5551](https://github.com/getsentry/sentry-java/pull/5551)) +- Replace `Date` with a unix timestamp in `SentryNanotimeDate` to improve performance ([#5550](https://github.com/getsentry/sentry-java/pull/5550)) + - `SentryNanotimeDate` is now marked `@ApiStatus.Internal`. A new `(long unixDateMillis, long nanos)` constructor was added, where `unixDateMillis` is milliseconds since the epoch. The existing `(Date, long)` constructor is retained but deprecated. + +### Dependencies + +- Upgrade to asyncProfiler 4.4 ([#5418](https://github.com/getsentry/sentry-java/pull/5418)) +- Bump Native SDK from v0.14.2 to v0.15.0 ([#5528](https://github.com/getsentry/sentry-java/pull/5528)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0150) + - [diff](https://github.com/getsentry/sentry-native/compare/0.14.2...0.15.0) + +### Fixes + +- Fix attachments being duplicated on native events that carry scope attachments ([#5548](https://github.com/getsentry/sentry-java/pull/5548)) +- Fix performance collector scheduling many tasks in a row ([#5524](https://github.com/getsentry/sentry-java/pull/5524)) + +## 8.43.3 + +### Fixes + +- Fix crash when `getHistoricalProcessStartReasons` is called from an isolated or wrong-userId process ([#5597](https://github.com/getsentry/sentry-java/pull/5597)) + +## 8.43.2 + +### Improvements + +- Improve SDK init performance by replacing `java.net.URI` with custom string parsing for DSN ([#5448](https://github.com/getsentry/sentry-java/pull/5448)) +- Remove unnecessary boxing to improve performance ([#5520](https://github.com/getsentry/sentry-java/pull/5520)) + +### Fixes + +- Session Replay: Fix `VerifyError` in Compose masking under DexGuard/R8 obfuscation ([#5507](https://github.com/getsentry/sentry-java/pull/5507)) +- Session Replay: Fix Compose view masking not working on obfuscated/minified builds ([#5503](https://github.com/getsentry/sentry-java/pull/5503)) + +## 8.43.1 + +### Fixes + +- Session Replay: Fix replay recording freezing on screens with continuous animations ([#5489](https://github.com/getsentry/sentry-java/pull/5489)) +- Session Replay: Populate `trace_ids` in replay events to enable searching replays by trace ID ([#5473](https://github.com/getsentry/sentry-java/pull/5473)) + +## 8.43.0 + +### Features + +- Session Replay: Add `ReplayFrameObserver` for observing captured replay frames ([#5386](https://github.com/getsentry/sentry-java/pull/5386)) + + ```kotlin + SentryAndroid.init(context) { options -> + options.sessionReplay.frameObserver = + SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName -> + val bitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) + if (bitmap != null) { + try { + // Process the masked replay frame + myAnalyzer.processFrame(bitmap, frameTimestamp, screenName) + } finally { + bitmap.recycle() + } + } + } + } + ``` +- Parse ART memory and garbage collector info from ANR tombstones into ART context ([#5428](https://github.com/getsentry/sentry-java/pull/5428)) + +## 8.42.0 + +### Features + +- Add option to attach raw tombstone protobuf on native crash events ([#5446](https://github.com/getsentry/sentry-java/pull/5446)) + - Enable via `options.isAttachRawTombstone = true` or manifest: `` +- Add API to clear feature flags from scopes ([#5426](https://github.com/getsentry/sentry-java/pull/5426)) +- Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) + +### Dependencies + +- Bump Gradle from v9.5.0 to v9.5.1 ([#5419](https://github.com/getsentry/sentry-java/pull/5419)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v951) + - [diff](https://github.com/gradle/gradle/compare/v9.5.0...v9.5.1) +- Bump Native SDK from v0.14.0 to v0.14.2 ([#5433](https://github.com/getsentry/sentry-java/pull/5433), [#5441](https://github.com/getsentry/sentry-java/pull/5441)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0142) + - [diff](https://github.com/getsentry/sentry-native/compare/0.14.0...0.14.2) +- Bump SAGP (Sentry Android Gradle Plugin) from v6.0.0-alpha.6 to v6.6.0 ([#5427](https://github.com/getsentry/sentry-java/pull/5427)) + - [changelog](https://github.com/getsentry/sentry-android-gradle-plugin/blob/main/CHANGELOG.md) + - [diff](https://github.com/getsentry/sentry-android-gradle-plugin/compare/6.0.0-alpha.6...6.6.0) + +## 8.41.0 + +### Features + +- Session Replay: experimental support for capturing `SurfaceView` content (e.g. Unity, video players, maps) ([#5333](https://github.com/getsentry/sentry-java/pull/5333)) + - To enable, set `options.sessionReplay.isCaptureSurfaceViews = true` + - Or via manifest: `` + - **Warning:** masking granularity is at the SurfaceView level only — the SDK cannot mask individual elements rendered inside the SurfaceView (e.g. native Unity UI, map labels, video frames). Only enable for SurfaceViews whose content is safe to record. +- Add `Sentry.feedback()` API for `show()` and `capture()` ([#5349](https://github.com/getsentry/sentry-java/pull/5349)) + - `Sentry.showUserFeedbackDialog()` is deprecated in favor of `Sentry.feedback().show()` + - `Sentry.captureFeedback()` is deprecated in favor of `Sentry.feedback().capture()` + - `Sentry.captureUserFeedback()` and `UserFeedback` are deprecated in favor of `Sentry.feedback().capture()` with the new `Feedback` type + - `SentryUserFeedbackDialog` is deprecated in favor of `SentryUserFeedbackForm` + - All deprecated APIs will be removed in the next major version +- Deprecate `SentryUserFeedbackButton` (View-based and Compose-based) ([#5350](https://github.com/getsentry/sentry-java/pull/5350)) + - It will be removed in the next major version +- Add per-form shake-to-show support for `SentryUserFeedbackForm` ([#5353](https://github.com/getsentry/sentry-java/pull/5353)) + - Useful for enabling shake-to-report on specific screens instead of globally + ```kotlin + SentryUserFeedbackForm.Builder(activity) + .configurator { it.isUseShakeGesture = true } + .create() + ``` +- Add support for Kafka ([#5249](https://github.com/getsentry/sentry-java/pull/5249)) + - You will need to add the `sentry-kafka` dependency and opt-in via the new option. + - Set `options.setEnableQueueTracing(true)` on `Sentry.init` + - Or set `sentry.enable-queue-tracing=true` in `application.properties` + - For Spring Boot Kafka is auto instrumented and no further configuration is needed. + - also see https://docs.sentry.io/platforms/java/guides/spring-boot/integrations/kafka/ + - When using `kafka-clients` directly + - you need to wrap your `KafkaProducer` via `SentryKafkaProducer.wrap(kafkaProducer)` to get `queue.publish` spans + - and you may use our `SentryKafkaConsumerTracing.withTracing` helper to instrument the consumer side manually. + - also see https://docs.sentry.io/platforms/java/integrations/kafka/ + +### Fixes + +- Fix soft input keyboard not being shown on the Feedback form ([#5359](https://github.com/getsentry/sentry-java/pull/5359)) +- Fix shake-to-report not triggering on some devices due to high acceleration threshold ([#5366](https://github.com/getsentry/sentry-java/pull/5366)) +- Fix feedback form retaining previous message when shown again via shake ([#5366](https://github.com/getsentry/sentry-java/pull/5366)) +- Avoid stack overflow when deserializing large flat JSON objects ([#5361](https://github.com/getsentry/sentry-java/pull/5361)) + +### Dependencies + +- Bump Native SDK from v0.13.7 to v0.14.0 ([#5334](https://github.com/getsentry/sentry-java/pull/5334), [#5365](https://github.com/getsentry/sentry-java/pull/5365)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0140) + - [diff](https://github.com/getsentry/sentry-native/compare/0.13.7...0.14.0) +- Bump Gradle from v9.4.1 to v9.5.0 ([#5344](https://github.com/getsentry/sentry-java/pull/5344)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v950) + - [diff](https://github.com/gradle/gradle/compare/v9.4.1...v9.5.0) + +## 8.40.0 + +### Fixes + +- Fix `NoSuchMethodError` for `LayoutCoordinates.localBoundingBoxOf$default` on Compose touch dispatch with AGP 8.13 and `minSdk < 24` ([#5302](https://github.com/getsentry/sentry-java/pull/5302)) +- Fix reporting OkHttp's synthetic 504 "Unsatisfiable Request" responses as errors for `CacheControl.FORCE_CACHE` cache misses ([#5299](https://github.com/getsentry/sentry-java/pull/5299)) +- Make `SentryGestureDetector` thread-safe and recycle `VelocityTracker` per gesture ([#5301](https://github.com/getsentry/sentry-java/pull/5301)) +- Fix duplicate `ui.click` breadcrumbs when another `Window.Callback` wraps `SentryWindowCallback` ([#5300](https://github.com/getsentry/sentry-java/pull/5300)) + +### Dependencies + +- Bump Native SDK from v0.13.6 to v0.13.7 ([#5296](https://github.com/getsentry/sentry-java/pull/5296)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0137) + - [diff](https://github.com/getsentry/sentry-native/compare/0.13.6...0.13.7) + +## 8.39.1 + +### Fixes + +- Fix `JsonObjectReader` and `MapObjectReader` hanging indefinitely when deserialization errors leave the reader in an inconsistent state ([#5293](https://github.com/getsentry/sentry-java/pull/5293)) + - Failed collection values are now skipped so parsing can continue + - Skipped collection values emit `WARNING` logs + - Unknown-key failures and unrecoverable recovery failures emit `ERROR` logs + +## 8.39.0 + +### Fixes + +- Fix ANR caused by `GestureDetectorCompat` Handler/MessageQueue lock contention in `SentryWindowCallback` ([#5138](https://github.com/getsentry/sentry-java/pull/5138)) + +### Internal + +- Bump AGP version from v8.6.0 to v8.13.1 ([#5063](https://github.com/getsentry/sentry-java/pull/5063)) + +### Dependencies + +- Bump Native SDK from v0.13.3 to v0.13.6 ([#5277](https://github.com/getsentry/sentry-java/pull/5277)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0136) + - [diff](https://github.com/getsentry/sentry-native/compare/0.13.3...0.13.6) +- Bump Gradle from v8.14.3 to v9.4.1 ([#5063](https://github.com/getsentry/sentry-java/pull/5063)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v941) + - [diff](https://github.com/gradle/gradle/compare/v8.14.3...v9.4.1) + +## 8.38.0 + +### Features + +- Prevent cross-organization trace continuation ([#5136](https://github.com/getsentry/sentry-java/pull/5136)) + - By default, the SDK now extracts the organization ID from the DSN (e.g. `o123.ingest.sentry.io`) and compares it with the `sentry-org_id` value in incoming baggage headers. When the two differ, the SDK starts a fresh trace instead of continuing the foreign one. This guards against accidentally linking traces across organizations. + - New option `enableStrictTraceContinuation` (default `false`): when enabled, both the SDK's org ID **and** the incoming baggage org ID must be present and match for a trace to be continued. Traces with a missing org ID on either side are rejected. Configurable via code (`setStrictTraceContinuation(true)`), `sentry.properties` (`enable-strict-trace-continuation=true`), Android manifest (`io.sentry.strict-trace-continuation.enabled`), or Spring Boot (`sentry.strict-trace-continuation=true`). + - New option `orgId`: allows explicitly setting the organization ID for self-hosted and Relay setups where it cannot be extracted from the DSN. Configurable via code (`setOrgId("123")`), `sentry.properties` (`org-id=123`), Android manifest (`io.sentry.org-id`), or Spring Boot (`sentry.org-id=123`). +- Android: Attachments on the scope will now be synced to native ([#5211](https://github.com/getsentry/sentry-java/pull/5211)) +- Add THIRD_PARTY_NOTICES.md for vendored third-party code, bundled as SENTRY_THIRD_PARTY_NOTICES.md in the sentry JAR under META-INF ([#5186](https://github.com/getsentry/sentry-java/pull/5186)) + +### Improvements + +- Do not retrieve `ActivityManager` if API < 35 on SDK init ([#5275](https://github.com/getsentry/sentry-java/pull/5275)) + +## 8.37.1 + +### Fixes + +- Fix deadlock in `SentryContextStorage.root()` with virtual threads and OpenTelemetry agent ([#5234](https://github.com/getsentry/sentry-java/pull/5234)) + +## 8.37.0 + +### Fixes + +- Session Replay: Fix Compose text masking mismatch with weighted text ([#5218](https://github.com/getsentry/sentry-java/pull/5218)) + +### Features + +- Add cache tracing instrumentation for Spring Boot 2, 3, and 4 ([#5165](https://github.com/getsentry/sentry-java/pull/5165)) + - Wraps Spring `CacheManager` and `Cache` beans to produce cache spans + - Set `sentry.enable-cache-tracing` to `true` to enable this feature +- Add JCache (JSR-107) cache tracing via new `sentry-jcache` module ([#5165](https://github.com/getsentry/sentry-java/pull/5165)) + - Wraps JCache `Cache` with `SentryJCacheWrapper` to produce cache spans + - Set the `enableCacheTracing` option to `true` to enable this feature +- Add configurable `IScopesStorageFactory` to `SentryOptions` for providing a custom `IScopesStorage`, e.g. when the default `ThreadLocal`-backed storage is incompatible with non-pinning thread models ([#5199](https://github.com/getsentry/sentry-java/pull/5199)) +- Android: Add `beforeErrorSampling` callback to Session Replay ([#5214](https://github.com/getsentry/sentry-java/pull/5214)) + - Allows filtering which errors trigger replay capture before the `onErrorSampleRate` is checked + - Returning `false` skips replay capture entirely for that error; returning `true` proceeds with the normal sample rate check + - Example usage: + ```kotlin + SentryAndroid.init(context) { options -> + options.sessionReplay.beforeErrorSampling = + SentryReplayOptions.BeforeErrorSamplingCallback { event, hint -> + // Only capture replay for crashes (excluding e.g. handled exceptions) + event.isCrashed + } + } + ``` + +### Dependencies + +- Bump Native SDK from v0.13.2 to v0.13.3 ([#5215](https://github.com/getsentry/sentry-java/pull/5215)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0133) + - [diff](https://github.com/getsentry/sentry-native/compare/0.13.2...0.13.3) +- Bump OpenTelemetry ([#5225](https://github.com/getsentry/sentry-java/pull/5225)) + - `opentelemetry` to `1.60.1` (was `1.57.0`) + - `opentelemetry-instrumentation` to `2.26.0` (was `2.23.0`) + - `opentelemetry-instrumentation-alpha` to `2.26.0-alpha` (was `2.23.0-alpha`) + - `opentelemetry-semconv` to `1.40.0` (was `1.37.0`) + - `opentelemetry-semconv-alpha` to `1.40.0-alpha` (was `1.37.0-alpha`) + +## 8.36.0 + +### Features + +- Show feedback form on device shake ([#5150](https://github.com/getsentry/sentry-java/pull/5150)) + - Enable via `options.getFeedbackOptions().setUseShakeGesture(true)` or manifest meta-data `io.sentry.feedback.use-shake-gesture` + - Uses the device's accelerometer — no special permissions required + ### Fixes +- Support masking/unmasking and click/scroll detection for Jetpack Compose 1.10+ ([#5189](https://github.com/getsentry/sentry-java/pull/5189)) + +### Dependencies + +- Bump Native SDK from v0.13.1 to v0.13.2 ([#5181](https://github.com/getsentry/sentry-java/pull/5181)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0132) + - [diff](https://github.com/getsentry/sentry-native/compare/0.13.1...0.13.2) +- Bump `com.abovevacant:epitaph` to `0.1.1` to avoid old D8/R8 dexing crashes in downstream Android builds on old AGP versions such as 7.4.x. ([#5200](https://github.com/getsentry/sentry-java/pull/5200)) + - [changelog](https://github.com/abovevacant/epitaph/blob/main/CHANGELOG.md#011---2026-03-16) + - [diff](https://github.com/abovevacant/epitaph/compare/v0.1.0...v0.1.1) + +## 8.35.0 + +### Fixes + +- Android: Remove the dependency on protobuf-lite for tombstones ([#5157](https://github.com/getsentry/sentry-java/pull/5157)) + +### Features + +- Add new experimental option to capture profiles for ANRs ([#4899](https://github.com/getsentry/sentry-java/pull/4899)) + - This feature will capture a stack profile of the main thread when it gets unresponsive + - The profile gets attached to the ANR event on the next app start, providing a flamegraph of the ANR issue on the sentry issue details page + - Enable via `options.setAnrProfilingSampleRate()` or AndroidManifest.xml: `` + - The sample rate controls the probability of collecting a profile for each detected foreground ANR (0.0 to 1.0, null to disable) + +### Behavioral Changes + +- Add `enableAnrFingerprinting` option which assigns static fingerprints to ANR events with system-only stacktraces + - When enabled, ANRs whose stacktraces contain only system frames (e.g. `java.lang` or `android.os`) are grouped into a single issue instead of creating many separate issues + - This will help to reduce overall ANR issue noise in the Sentry dashboard + - **IMPORTANT:** This option is enabled by default. + - Disable via `options.setEnableAnrFingerprinting(false)` or AndroidManifest.xml: `` + +## 8.34.1 + +### Fixes + +- Common: Finalize previous session even when auto session tracking is disabled ([#5154](https://github.com/getsentry/sentry-java/pull/5154)) +- Android: Add `filterTouchesWhenObscured` to prevent Tapjacking on user feedback dialog ([#5155](https://github.com/getsentry/sentry-java/pull/5155)) +- Android: Add proguard rules to prevent error about missing Replay classes ([#5153](https://github.com/getsentry/sentry-java/pull/5153)) + +## 8.34.0 + +### Features + +- Allow configuring shutdown and session flush timeouts externally ([#4641](https://github.com/getsentry/sentry-java/pull/4641)) + - `sentry.properties`: `shutdown-timeout-millis`, `session-flush-timeout-millis` + - Environment variables: `SENTRY_SHUTDOWN_TIMEOUT_MILLIS`, `SENTRY_SESSION_FLUSH_TIMEOUT_MILLIS` + - Spring Boot `application.properties`: `sentry.shutdownTimeoutMillis`, `sentry.sessionFlushTimeoutMillis` +- Add scope-level attributes API ([#5118](https://github.com/getsentry/sentry-java/pull/5118)) via ([#5148](https://github.com/getsentry/sentry-java/pull/5148)) + - Automatically include scope attributes in logs and metrics ([#5120](https://github.com/getsentry/sentry-java/pull/5120)) + - New APIs are `Sentry.setAttribute`, `Sentry.setAttributes`, `Sentry.removeAttribute` +- Support collections and arrays in attribute type inference ([#5124](https://github.com/getsentry/sentry-java/pull/5124)) +- Add support for `SENTRY_SAMPLE_RATE` environment variable / `sample-rate` property ([#5112](https://github.com/getsentry/sentry-java/pull/5112)) +- Create `sentry-opentelemetry-otlp` and `sentry-opentelemetry-otlp-spring` modules for combining OpenTelemetry SDK OTLP export with Sentry SDK ([#5100](https://github.com/getsentry/sentry-java/pull/5100)) + - OpenTelemetry is configured to send spans to Sentry directly using an OTLP endpoint. + - Sentry only uses trace and span ID from OpenTelemetry (via `OpenTelemetryOtlpEventProcessor`) but will not send spans through OpenTelemetry nor use OpenTelemetry `Context` for `Scopes` propagation. + - See the OTLP setup docs for [Java](https://docs.sentry.io/platforms/java/opentelemetry/setup/otlp/) and [Spring Boot](https://docs.sentry.io/platforms/java/guides/spring-boot/opentelemetry/setup/otlp/) for installation and configuration instructions. +- Add screenshot masking support using view hierarchy ([#5077](https://github.com/getsentry/sentry-java/pull/5077)) + - Masks sensitive content (text, images) in error screenshots using the same view hierarchy approach as Session Replay + - Requires the `sentry-android-replay` module to be present at runtime for masking to work + - Enable via code: + ```kotlin + SentryAndroid.init(context) { options -> + options.isAttachScreenshot = true + options.screenshot.setMaskAllText(true) + options.screenshot.setMaskAllImages(true) + // Or mask specific view classes + options.screenshot.addMaskViewClass("com.example.MyCustomView") + } + ``` + - Or via `AndroidManifest.xml`: + ```xml + + + + ``` +- The `ManifestMetaDataReader` now read the `DIST` ([#5107](https://github.com/getsentry/sentry-java/pull/5107)) + +### Fixes + +- Fix attribute type detection for `Long`, `Short`, `Byte`, `BigInteger`, `AtomicInteger`, and `AtomicLong` being incorrectly inferred as `double` instead of `integer` ([#5122](https://github.com/getsentry/sentry-java/pull/5122)) +- Remove `AndroidRuntimeManager` StrictMode relaxation to prevent ANRs during SDK init ([#5127](https://github.com/getsentry/sentry-java/pull/5127)) + - **IMPORTANT:** StrictMode violations may appear again in debug builds. This is intentional to prevent ANRs in production releases. +- Fix crash when unregistering `SystemEventsBroadcastReceiver` with try-catch block. ([#5106](https://github.com/getsentry/sentry-java/pull/5106)) +- Use `peekDecorView` instead of `getDecorView` in `SentryGestureListener` to avoid forcing view hierarchy construction ([#5134](https://github.com/getsentry/sentry-java/pull/5134)) +- Log an actionable error message when Relay returns HTTP 413 (Content Too Large) ([#5115](https://github.com/getsentry/sentry-java/pull/5115)) + - Also switch the client report discard reason for all HTTP 4xx/5xx errors (except 429) from `network_error` to `send_error` +- Trim DSN string before parsing to avoid `URISyntaxException` caused by trailing whitespace ([#5113](https://github.com/getsentry/sentry-java/pull/5113)) +- Reduce allocations and bytecode instructions during `Sentry.init` ([#5135](https://github.com/getsentry/sentry-java/pull/5135)) + +### Dependencies + +- Bump Native SDK from v0.12.7 to v0.13.1 ([#5104](https://github.com/getsentry/sentry-java/pull/5104)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0131) + - [diff](https://github.com/getsentry/sentry-native/compare/0.12.7...0.13.1) + +## 8.33.0 + +### Features + +- Add `installGroupsOverride` parameter to Build Distribution SDK for programmatic filtering, with support for configuration via properties file using `io.sentry.distribution.install-groups-override` ([#5066](https://github.com/getsentry/sentry-java/pull/5066)) + +### Fixes + +- When merging tombstones with Native SDK, use the tombstone message if the Native SDK didn't explicitly provide one. ([#5095](https://github.com/getsentry/sentry-java/pull/5095)) +- Fix thread leak caused by eager creation of `SentryExecutorService` in `SentryOptions` ([#5093](https://github.com/getsentry/sentry-java/pull/5093)) + - There were cases where we created options that ended up unused but we failed to clean those up. +- Attach user attributes to logs and metrics regardless of `sendDefaultPii` ([#5099](https://github.com/getsentry/sentry-java/pull/5099)) +- No longer log a warning if a logging integration cannot initialize Sentry due to missing DSN ([#5075](https://github.com/getsentry/sentry-java/pull/5075)) + - While this may have been useful to some, it caused lots of confusion. +- Session Replay: Add `androidx.camera.view.PreviewView` to default `maskedViewClasses` to mask camera previews by default. ([#5097](https://github.com/getsentry/sentry-java/pull/5097)) + +### Dependencies + +- Bump Native SDK from v0.12.4 to v0.12.7 ([#5071](https://github.com/getsentry/sentry-java/pull/5071), [#5098](https://github.com/getsentry/sentry-java/pull/5098)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0127) + - [diff](https://github.com/getsentry/sentry-native/compare/0.12.4...0.12.7) + +### Internal + +- Add integration to track session replay custom masking ([#5070](https://github.com/getsentry/sentry-java/pull/5070)) + +## 8.32.0 + +### Features + +- Add `installGroups` property to Build Distribution SDK ([#5062](https://github.com/getsentry/sentry-java/pull/5062)) +- Update Android targetSdk to API 36 (Android 16) ([#5016](https://github.com/getsentry/sentry-java/pull/5016)) +- Add AndroidManifest support for Spotlight configuration via `io.sentry.spotlight.enable` and `io.sentry.spotlight.url` ([#5064](https://github.com/getsentry/sentry-java/pull/5064)) +- Collect database transaction spans (`BEGIN`, `COMMIT`, `ROLLBACK`) ([#5072](https://github.com/getsentry/sentry-java/pull/5072)) + - To enable creation of these spans, set `options.enableDatabaseTransactionTracing` to `true` + - `enable-database-transaction-tracing=true` when using `sentry.properties` + - For Spring Boot, use `sentry.enable-database-transaction-tracing=true` in `application.properties` or in `application.yml`: + ```yaml + sentry: + enable-database-transaction-tracing: true + ``` +- Add support for collecting native crashes using Tombstones ([#4933](https://github.com/getsentry/sentry-java/pull/4933), [#5037](https://github.com/getsentry/sentry-java/pull/5037)) + - Added Tombstone integration that detects native crashes using `ApplicationExitInfo.REASON_CRASH_NATIVE` on Android 12+ + - Crashes enriched with Tombstones contain more crash details and detailed thread info + - Tombstone and NDK integrations are now automatically merged into a single crash event, eliminating duplicate reports + - To enable it, add the integration in your Sentry initialization: + ```kotlin + SentryAndroid.init(context, options -> { + options.isTombstoneEnabled = true + }) + ``` + or in the `AndroidManifest.xml` using: + ```xml + + ``` + +### Fixes + +- Extract `SpotlightIntegration` to separate `sentry-spotlight` module to prevent insecure HTTP URLs from appearing in release APKs ([#5064](https://github.com/getsentry/sentry-java/pull/5064)) + - **Breaking:** Users who enable Spotlight must now add the `io.sentry:sentry-spotlight` dependency: + ```kotlin + dependencies { + debugImplementation("io.sentry:sentry-spotlight:") + } + ``` +- Fix scroll target detection for Jetpack Compose ([#5017](https://github.com/getsentry/sentry-java/pull/5017)) +- No longer fork Sentry `Scopes` for `reactor-kafka` consumer poll `Runnable` ([#5080](https://github.com/getsentry/sentry-java/pull/5080)) + - This was causing a memory leak because `reactor-kafka`'s poll event reschedules itself infinitely, and each invocation of `SentryScheduleHook` created forked scopes with a parent reference, building an unbounded chain that couldn't be garbage collected. +- Fix cold/warm app start type detection for Android devices running API level 34+ ([#4999](https://github.com/getsentry/sentry-java/pull/4999)) + +### Internal + +- Establish new native exception mechanisms to differentiate events generated by `sentry-native` from `ApplicationExitInfo`. ([#5052](https://github.com/getsentry/sentry-java/pull/5052)) +- Set `write` permission for `statuses` in the changelog preview GHA workflow. ([#5053](https://github.com/getsentry/sentry-java/pull/5053)) + +### Dependencies + +- Bump Native SDK from v0.12.3 to v0.12.4 ([#5061](https://github.com/getsentry/sentry-java/pull/5061)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0124) + - [diff](https://github.com/getsentry/sentry-native/compare/0.12.3...0.12.4) + +## 8.31.0 + +### Features + +- Added `io.sentry.ndk.sdk-name` Android manifest option to configure the native SDK's name ([#5027](https://github.com/getsentry/sentry-java/pull/5027)) +- Replace `sentry.trace.parent_span_id` attribute with `spanId` property on `SentryLogEvent` ([#5040](https://github.com/getsentry/sentry-java/pull/5040)) + +### Fixes + +- Only attach user attributes to logs if `sendDefaultPii` is enabled ([#5036](https://github.com/getsentry/sentry-java/pull/5036)) +- Reject new logs if `LoggerBatchProcessor` is shutting down ([#5041](https://github.com/getsentry/sentry-java/pull/5041)) +- Downgrade protobuf-javalite dependency from 4.33.1 to 3.25.8 ([#5044](https://github.com/getsentry/sentry-java/pull/5044)) + +### Dependencies + +- Bump Native SDK from v0.12.2 to v0.12.3 ([#5012](https://github.com/getsentry/sentry-java/pull/5012)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0123) + - [diff](https://github.com/getsentry/sentry-native/compare/0.12.2...0.12.3) + +## 8.30.0 + +### Fixes + +- Fix ANRs when collecting device context ([#4970](https://github.com/getsentry/sentry-java/pull/4970)) + - **IMPORTANT:** This disables collecting external storage size (total/free) by default, to enable it back + use `options.isCollectExternalStorageContext = true` or `` +- Fix `NullPointerException` when reading ANR marker ([#4979](https://github.com/getsentry/sentry-java/pull/4979)) +- Report discarded log in batch processor as `log_byte` ([#4971](https://github.com/getsentry/sentry-java/pull/4971)) + +### Improvements + +- Expose `MAX_EVENT_SIZE_BYTES` constant in SentryOptions ([#4962](https://github.com/getsentry/sentry-java/pull/4962)) +- Discard envelopes on `4xx` and `5xx` response ([#4950](https://github.com/getsentry/sentry-java/pull/4950)) + - This aims to not overwhelm Sentry after an outage or load shedding (including HTTP 429) where too many events are sent at once + +### Features + +- 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 + - For more details, see the Metrics documentation: https://docs.sentry.io/product/explore/metrics/getting-started/ + +## 8.29.0 + +### Fixes + +- Support serialization of primitive arrays (boolean[], byte[], short[], char[], int[], long[], float[], double[]) ([#4968](https://github.com/getsentry/sentry-java/pull/4968)) +- Session Replay: Improve network body parsing and truncation handling ([#4958](https://github.com/getsentry/sentry-java/pull/4958)) + +### Internal + +- Support `metric` envelope item type ([#4956](https://github.com/getsentry/sentry-java/pull/4956)) + +## 8.28.0 + +### Features + +- Android: Flush logs when app enters background ([#4951](https://github.com/getsentry/sentry-java/pull/4951)) +- Add option to capture additional OkHttp network request/response details in session replays ([#4919](https://github.com/getsentry/sentry-java/pull/4919)) + - Depends on `SentryOkHttpInterceptor` to intercept the request and extract request/response bodies + - To enable, add url regexes via the `io.sentry.session-replay.network-detail-allow-urls` metadata tag in AndroidManifest ([code sample](https://github.com/getsentry/sentry-java/blob/b03edbb1b0d8b871c62a09bc02cbd8a4e1f6fea1/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml#L196-L205)) - Or you can manually specify SentryReplayOptions via `SentryAndroid#init`: + _(Make sure you disable the auto init via manifest meta-data: io.sentry.auto-init=false)_ + +
+ Kotlin + +```kotlin +SentryAndroid.init( + this, + options -> { + // options.dsn = "https://examplePublicKey@o0.ingest.sentry.io/0" + // options.sessionReplay.sessionSampleRate = 1.0 + // options.sessionReplay.onErrorSampleRate = 1.0 + // .. + + options.sessionReplay.networkDetailAllowUrls = listOf(".*") + options.sessionReplay.networkDetailDenyUrls = listOf(".*deny.*") + options.sessionReplay.networkRequestHeaders = listOf("Authorization", "X-Custom-Header", "X-Test-Request") + options.sessionReplay.networkResponseHeaders = listOf("X-Response-Time", "X-Cache-Status", "X-Test-Response") + }); +``` + +
+ +
+ Java + +```java +SentryAndroid.init( + this, + options -> { + options.getSessionReplay().setNetworkDetailAllowUrls(Arrays.asList(".*")); + options.getSessionReplay().setNetworkDetailDenyUrls(Arrays.asList(".*deny.*")); + options.getSessionReplay().setNetworkRequestHeaders( + Arrays.asList("Authorization", "X-Custom-Header", "X-Test-Request")); + options.getSessionReplay().setNetworkResponseHeaders( + Arrays.asList("X-Response-Time", "X-Cache-Status", "X-Test-Response")); + }); + +``` + +
+ +### Improvements + +- Avoid forking `rootScopes` for Reactor if current thread has `NoOpScopes` ([#4793](https://github.com/getsentry/sentry-java/pull/4793)) + - This reduces the SDKs overhead by avoiding unnecessary scope forks + +### Fixes + +- Fix missing thread stacks for ANRv1 events ([#4918](https://github.com/getsentry/sentry-java/pull/4918)) +- Fix handling of unparseable mime-type on request filter ([#4939](https://github.com/getsentry/sentry-java/pull/4939)) + +### Internal + +- Support `span` envelope item type ([#4935](https://github.com/getsentry/sentry-java/pull/4935)) + +### Dependencies + +- Bump Native SDK from v0.12.1 to v0.12.2 ([#4944](https://github.com/getsentry/sentry-java/pull/4944)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0122) + - [diff](https://github.com/getsentry/sentry-native/compare/0.12.1...0.12.2) + +## 8.27.1 + +### Fixes + +- Do not log if `sentry.properties` in rundir has not been found ([#4929](https://github.com/getsentry/sentry-java/pull/4929)) + +## 8.27.0 + +### Features + +- Implement OpenFeature Integration that tracks Feature Flag evaluations ([#4910](https://github.com/getsentry/sentry-java/pull/4910)) + - To make use of it, add the `sentry-openfeature` dependency and register the the hook using: `openFeatureApiInstance.addHooks(new SentryOpenFeatureHook());` +- Implement LaunchDarkly Integrations that track Feature Flag evaluations ([#4917](https://github.com/getsentry/sentry-java/pull/4917)) + - For Android, please add `sentry-launchdarkly-android` as a dependency and register the `SentryLaunchDarklyAndroidHook` + - For Server / JVM, please add `sentry-launchdarkly-server` as a dependency and register the `SentryLaunchDarklyServerHook` +- Detect oversized events and reduce their size ([#4903](https://github.com/getsentry/sentry-java/pull/4903)) + - You can opt into this new behaviour by setting `enableEventSizeLimiting` to `true` (`sentry.enable-event-size-limiting=true` for Spring Boot `application.properties`) + - You may optionally register an `onOversizedEvent` callback to implement custom logic that is executed in case an oversized event is detected + - This is executed first and if event size was reduced sufficiently, no further truncation is performed + - In case we detect an oversized event, we first drop breadcrumbs and if that isn't sufficient we also drop stack frames in order to get an events size down + +### Improvements + +- Do not send manual log origin ([#4897](https://github.com/getsentry/sentry-java/pull/4897)) + +### Dependencies + +- Bump Spring Boot 4 to GA ([#4923](https://github.com/getsentry/sentry-java/pull/4923)) + +## 8.26.0 + +### Features + +- Add feature flags API ([#4812](https://github.com/getsentry/sentry-java/pull/4812)) and ([#4831](https://github.com/getsentry/sentry-java/pull/4831)) + - You may now keep track of your feature flag evaluations and have them show up in Sentry. + - Top level API (`Sentry.addFeatureFlag("my-feature-flag", true);`) writes to scopes and the current span (if there is one) + - It is also possible to use API on `IScope`, `IScopes`, `ISpan` and `ITransaction` directly + - Feature flag evaluations tracked on scope(s) will be added to any errors reported to Sentry. + - The SDK keeps the latest 100 evaluations from scope(s), replacing old entries as new evaluations are added. + - For feature flag evaluations tracked on spans: + - Only 10 evaluations are tracked per span, existing flags are updated but new ones exceeding the limit are ignored + - Spans do not inherit evaluations from their parent +- Drop log events once buffer hits hard limit ([#4889](https://github.com/getsentry/sentry-java/pull/4889)) + - If we have 1000 log events queued up, we drop any new logs coming in to prevent OOM +- Remove vendored code and upgrade to async profiler 4.2 ([#4856](https://github.com/getsentry/sentry-java/pull/4856)) + - This adds support for JDK 23+ + +### Fixes + +- Removed SentryExecutorService limit for delayed scheduled tasks ([#4846](https://github.com/getsentry/sentry-java/pull/4846)) +- Fix visual artifacts for the Canvas strategy on some devices ([#4861](https://github.com/getsentry/sentry-java/pull/4861)) +- [Config] Trim whitespace on properties path ([#4880](https://github.com/getsentry/sentry-java/pull/4880)) +- Only set `DefaultReplayBreadcrumbConverter` if replay is available ([#4888](https://github.com/getsentry/sentry-java/pull/4888)) +- Session Replay: Cache connection status instead of using blocking calls ([#4891](https://github.com/getsentry/sentry-java/pull/4891)) +- Fix log count in client reports ([#4869](https://github.com/getsentry/sentry-java/pull/4869)) +- Fix profilerId propagation ([#4833](https://github.com/getsentry/sentry-java/pull/4833)) +- Fix profiling init for Spring and Spring Boot w Agent auto-init ([#4815](https://github.com/getsentry/sentry-java/pull/4815)) +- Copy active span on scope clone ([#4878](https://github.com/getsentry/sentry-java/pull/4878)) + +### Improvements + +- Fallback to distinct-id as user.id logging attribute when user is not set ([#4847](https://github.com/getsentry/sentry-java/pull/4847)) +- Report Timber.tag() as `timber.tag` log attribute ([#4845](https://github.com/getsentry/sentry-java/pull/4845)) +- Session Replay: Add screenshot strategy serialization to RRWeb events ([#4851](https://github.com/getsentry/sentry-java/pull/4851)) +- Report discarded log bytes ([#4871](https://github.com/getsentry/sentry-java/pull/4871)) +- Log why a properties file was not loaded ([#4879](https://github.com/getsentry/sentry-java/pull/4879)) + +### Dependencies + +- Bump Native SDK from v0.11.3 to v0.12.1 ([#4859](https://github.com/getsentry/sentry-java/pull/4859)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0121) + - [diff](https://github.com/getsentry/sentry-native/compare/0.11.3...0.12.1) +- Bump Spring Boot 4 to RC2 ([#4886](https://github.com/getsentry/sentry-java/pull/4886)) + +## 8.25.0 + +### Fixes + +- [ANR] Removed AndroidTransactionProfiler lock ([#4817](https://github.com/getsentry/sentry-java/pull/4817)) +- Avoid ExecutorService for DefaultCompositePerformanceCollector timeout ([#4841](https://github.com/getsentry/sentry-java/pull/4841)) + - This avoids infinite data collection for never stopped transactions, leading to OOMs +- Fix wrong .super() call in SentryTimberTree ([#4844](https://github.com/getsentry/sentry-java/pull/4844)) + +### Improvements + +- [ANR] Defer some class availability checks ([#4825](https://github.com/getsentry/sentry-java/pull/4825)) +- Collect PerformanceCollectionData only for sampled transactions ([#4834](https://github.com/getsentry/sentry-java/pull/4834)) + - **Breaking change**: Transactions with a deferred sampling decision (`sampled == null`) won't be collecting any performance data anymore (CPU, RAM, slow/frozen frames). + +### Dependencies + +- Bump Native SDK from v0.11.2 to v0.11.3 ([#4810](https://github.com/getsentry/sentry-java/pull/4810)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0113) + - [diff](https://github.com/getsentry/sentry-native/compare/0.11.2...0.11.3) + +## 8.24.0 + +### Features + +- Attach MDC properties to logs as attributes ([#4786](https://github.com/getsentry/sentry-java/pull/4786)) + - MDC properties set using supported logging frameworks (Logback, Log4j2, java.util.Logging) are now attached to structured logs as attributes. + - The attribute reflected on the log is `mdc.`, where `` is the original key in the MDC. + - This means that you will be able to filter/aggregate logs in the product based on these properties. + - Only properties with keys matching the configured `contextTags` are sent as log attributes. + - You can configure which properties are sent using `options.setContextTags` if initalizing manually, or by specifying a comma-separated list of keys with a `context-tags` entry in `sentry.properties` or `sentry.context-tags` in `application.properties`. + - Note that keys containing spaces are not supported. +- Add experimental Sentry Android Distribution module for integrating with Sentry Build Distribution to check for and install updates ([#4804](https://github.com/getsentry/sentry-java/pull/4804)) +- Allow passing a different `Handler` to `SystemEventsBreadcrumbsIntegration` and `AndroidConnectionStatusProvider` so their callbacks are deliver to that handler ([#4808](https://github.com/getsentry/sentry-java/pull/4808)) +- Session Replay: Add new _experimental_ Canvas Capture Strategy ([#4777](https://github.com/getsentry/sentry-java/pull/4777)) + - A new screenshot capture strategy that uses Android's Canvas API for more accurate and reliable text and image masking + - Any `.drawText()` or `.drawBitmap()` calls are replaced by rectangles, ensuring no text or images are present in the resulting output + - Note: If this strategy is used, all text and images will be masked, regardless of any masking configuration + - To enable this feature, set the `screenshotStrategy`, either via code: + ```kotlin + SentryAndroid.init(context) { options -> + options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.CANVAS + } + ``` + or AndroidManifest.xml: + ```xml + + + + ``` + +### Fixes + +- Avoid StrictMode warnings ([#4724](https://github.com/getsentry/sentry-java/pull/4724)) - Use logger from options for JVM profiler ([#4771](https://github.com/getsentry/sentry-java/pull/4771)) - Session Replay: Avoid deadlock when pausing replay if no connection ([#4788](https://github.com/getsentry/sentry-java/pull/4788)) +- Session Replay: Fix capturing roots with no windows ([#4805](https://github.com/getsentry/sentry-java/pull/4805)) +- Session Replay: Fix `java.lang.IllegalArgumentException: width and height must be > 0` ([#4805](https://github.com/getsentry/sentry-java/pull/4805)) +- Handle `NoOpScopes` in `Context` when starting a span through OpenTelemetry ([#4823](https://github.com/getsentry/sentry-java/pull/4823)) + - This fixes "java.lang.IllegalArgumentException: The DSN is required" when combining WebFlux and OpenTelemetry +- Session Replay: Do not use recycled screenshots for masking ([#4790](https://github.com/getsentry/sentry-java/pull/4790)) + - This fixes native crashes seen in `Canvas.`/`ScreenshotRecorder.capture` +- Session Replay: Ensure bitmaps are recycled properly ([#4820](https://github.com/getsentry/sentry-java/pull/4820)) ### Miscellaneous @@ -110,15 +1149,15 @@ - Add onDiscard to enable users to track the type and amount of data discarded before reaching Sentry ([#4612](https://github.com/getsentry/sentry-java/pull/4612)) - Stub for setting the callback on `Sentry.init`: - ```java - Sentry.init(options -> { - ... - options.setOnDiscard( - (reason, category, number) -> { - // Your logic to process discarded data - }); - }); - ``` + ```java + Sentry.init(options -> { + ... + options.setOnDiscard( + (reason, category, number) -> { + // Your logic to process discarded data + }); + }); + ``` ## 8.19.1 @@ -168,7 +1207,7 @@ - Move and flush unfinished previous session on init ([#4624](https://github.com/getsentry/sentry-java/pull/4624)) - This removes the need for unnecessary blocking our background queue for 15 seconds in the case of a background app start - Switch to compileOnly dependency for compose-ui-material ([#4630](https://github.com/getsentry/sentry-java/pull/4630)) - - This fixes `StackOverflowError` when using OSS Licenses plugin + - This fixes `StackOverflowError` when using OSS Licenses plugin ### Dependencies @@ -373,21 +1412,24 @@ ### Features - Add New User Feedback Widget ([#4450](https://github.com/getsentry/sentry-java/pull/4450)) - - This widget is a custom button that can be used to show the user feedback form + - This widget is a custom button that can be used to show the user feedback form - Add New User Feedback form ([#4384](https://github.com/getsentry/sentry-java/pull/4384)) - - We now introduce SentryUserFeedbackDialog, which extends AlertDialog, inheriting the show() and cancel() methods, among others. - To use it, just instantiate it and call show() on the instance (Sentry must be previously initialized). - For customization options, please check the [User Feedback documentation](https://docs.sentry.io/platforms/android/user-feedback/configuration/). - ```java - import io.sentry.android.core.SentryUserFeedbackDialog; - - new SentryUserFeedbackDialog.Builder(context).create().show(); - ``` - ```kotlin - import io.sentry.android.core.SentryUserFeedbackDialog - - SentryUserFeedbackDialog.Builder(context).create().show() - ``` + - We now introduce SentryUserFeedbackDialog, which extends AlertDialog, inheriting the show() and cancel() methods, among others. + To use it, just instantiate it and call show() on the instance (Sentry must be previously initialized). + For customization options, please check the [User Feedback documentation](https://docs.sentry.io/platforms/android/user-feedback/configuration/). + + ```java + import io.sentry.android.core.SentryUserFeedbackDialog; + + new SentryUserFeedbackDialog.Builder(context).create().show(); + ``` + + ```kotlin + import io.sentry.android.core.SentryUserFeedbackDialog + + SentryUserFeedbackDialog.Builder(context).create().show() + ``` + - Add `user.id`, `user.name` and `user.email` to log attributes ([#4486](https://github.com/getsentry/sentry-java/pull/4486)) - User `name` attribute has been deprecated, please use `username` instead ([#4486](https://github.com/getsentry/sentry-java/pull/4486)) - Add device (`device.brand`, `device.model` and `device.family`) and OS (`os.name` and `os.version`) attributes to logs ([#4493](https://github.com/getsentry/sentry-java/pull/4493)) @@ -433,8 +1475,8 @@ ### Features - Add debug mode for Session Replay masking ([#4357](https://github.com/getsentry/sentry-java/pull/4357)) - - Use `Sentry.replay().enableDebugMaskingOverlay()` to overlay the screen with the Session Replay masks. - - The masks will be invalidated at most once per `frameRate` (default 1 fps). + - Use `Sentry.replay().enableDebugMaskingOverlay()` to overlay the screen with the Session Replay masks. + - The masks will be invalidated at most once per `frameRate` (default 1 fps). - Extend Logs API to allow passing in `attributes` ([#4402](https://github.com/getsentry/sentry-java/pull/4402)) - `Sentry.logger.log` now takes a `SentryLogParameters` - Use `SentryLogParameters.create(SentryAttributes.of(...))` to pass attributes @@ -465,17 +1507,17 @@ ### Features - Add new User Feedback API ([#4286](https://github.com/getsentry/sentry-java/pull/4286)) - - We now introduced Sentry.captureFeedback, which supersedes Sentry.captureUserFeedback + - We now introduced Sentry.captureFeedback, which supersedes Sentry.captureUserFeedback - Add Sentry Log Feature ([#4372](https://github.com/getsentry/sentry-java/pull/4372)) - - The feature is disabled by default and needs to be enabled by: - - `options.getLogs().setEnabled(true)` in `Sentry.init` / `SentryAndroid.init` - - `` in `AndroidManifest.xml` - - `logs.enabled=true` in `sentry.properties` - - `sentry.logs.enabled=true` in `application.properties` - - `sentry.logs.enabled: true` in `application.yml` - - Logs can be captured using `Sentry.logger().info()` and similar methods. - - Logs also take a format string and arguments which we then send through `String.format`. - - Please use `options.getLogs().setBeforeSend()` to filter outgoing logs + - The feature is disabled by default and needs to be enabled by: + - `options.getLogs().setEnabled(true)` in `Sentry.init` / `SentryAndroid.init` + - `` in `AndroidManifest.xml` + - `logs.enabled=true` in `sentry.properties` + - `sentry.logs.enabled=true` in `application.properties` + - `sentry.logs.enabled: true` in `application.yml` + - Logs can be captured using `Sentry.logger().info()` and similar methods. + - Logs also take a format string and arguments which we then send through `String.format`. + - Please use `options.getLogs().setBeforeSend()` to filter outgoing logs ### Fixes @@ -510,11 +1552,11 @@ ### Features - Wrap configured OpenTelemetry `ContextStorageProvider` if available ([#4359](https://github.com/getsentry/sentry-java/pull/4359)) - - This is only relevant if you see `java.lang.IllegalStateException: Found multiple ContextStorageProvider. Set the io.opentelemetry.context.ContextStorageProvider property to the fully qualified class name of the provider to use. Falling back to default ContextStorage. Found providers: ...` + - This is only relevant if you see `java.lang.IllegalStateException: Found multiple ContextStorageProvider. Set the io.opentelemetry.context.ContextStorageProvider property to the fully qualified class name of the provider to use. Falling back to default ContextStorage. Found providers: ...` - Set `-Dio.opentelemetry.context.contextStorageProvider=io.sentry.opentelemetry.SentryContextStorageProvider` on your `java` command - Sentry will then wrap the other `ContextStorageProvider` that has been configured by loading it through SPI - If no other `ContextStorageProvider` is available or there are problems loading it, we fall back to using `SentryOtelThreadLocalStorage` - + ### Fixes - Update profile chunk rate limit and client report ([#4353](https://github.com/getsentry/sentry-java/pull/4353)) @@ -573,9 +1615,9 @@ - UI Profiling GA Continuous Profiling is now GA, named UI Profiling. To enable it you can use one of the following options. More info can be found at https://docs.sentry.io/platforms/android/profiling/. - Note: Both `options.profilesSampler` and `options.profilesSampleRate` must **not** be set to enable UI Profiling. - To keep the same transaction-based behaviour, without the 30 seconds limitation, you can use the `trace` lifecycle mode. - + Note: Both `options.profilesSampler` and `options.profilesSampleRate` must **not** be set to enable UI Profiling. + To keep the same transaction-based behaviour, without the 30 seconds limitation, you can use the `trace` lifecycle mode. + ```xml @@ -586,10 +1628,11 @@ ``` + ```java import io.sentry.ProfileLifecycle; import io.sentry.android.core.SentryAndroid; - + SentryAndroid.init(context, options -> { // Enable UI profiling, adjust in production env. This is evaluated only once per session options.setProfileSessionSampleRate(1.0); @@ -599,6 +1642,7 @@ options.setStartProfilerOnAppStart(true); }); ``` + ```kotlin import io.sentry.ProfileLifecycle import io.sentry.android.core.SentryAndroid @@ -669,10 +1713,10 @@ ### Features - Add native stack frame address information and debug image metadata to ANR events ([#4061](https://github.com/getsentry/sentry-java/pull/4061)) - - This enables symbolication for stripped native code in ANRs + - This enables symbolication for stripped native code in ANRs - Add Continuous Profiling Support ([#3710](https://github.com/getsentry/sentry-java/pull/3710)) - To enable Continuous Profiling use the `Sentry.startProfiler` and `Sentry.stopProfiler` experimental APIs. Sampling rate can be set through `options.profileSessionSampleRate`, which defaults to null (disabled). + To enable Continuous Profiling use the `Sentry.startProfiler` and `Sentry.stopProfiler` experimental APIs. Sampling rate can be set through `options.profileSessionSampleRate`, which defaults to null (disabled). Note: Both `options.profilesSampler` and `options.profilesSampleRate` must **not** be set to enable Continuous Profiling. ```java @@ -680,7 +1724,7 @@ import io.sentry.android.core.SentryAndroid; SentryAndroid.init(context) { options -> - + // Currently under experimental options: options.getExperimental().setProfileSessionSampleRate(1.0); // In manual mode, you need to start and stop the profiler manually using Sentry.startProfiler and Sentry.stopProfiler @@ -689,16 +1733,17 @@ } // Start profiling Sentry.startProfiler(); - + // After all profiling is done, stop the profiler. Profiles can last indefinitely if not stopped. Sentry.stopProfiler(); ``` + ```kotlin import io.sentry.ProfileLifecycle import io.sentry.android.core.SentryAndroid SentryAndroid.init(context) { options -> - + // Currently under experimental options: options.experimental.profileSessionSampleRate = 1.0 // In manual mode, you need to start and stop the profiler manually using Sentry.startProfiler and Sentry.stopProfiler @@ -707,7 +1752,7 @@ } // Start profiling Sentry.startProfiler() - + // After all profiling is done, stop the profiler. Profiles can last indefinitely if not stopped. Sentry.stopProfiler() ``` @@ -745,7 +1790,7 @@ - remove any previous value if the new value is set to `null` - Add support for setting in-app-includes/in-app-excludes via AndroidManifest.xml ([#4240](https://github.com/getsentry/sentry-java/pull/4240)) - Modifications to OkHttp requests are now properly propagated to the affected span / breadcrumbs ([#4238](https://github.com/getsentry/sentry-java/pull/4238)) - - Please ensure the SentryOkHttpInterceptor is added last to your OkHttpClient, as otherwise changes to the `Request` by subsequent interceptors won't be considered + - Please ensure the SentryOkHttpInterceptor is added last to your OkHttpClient, as otherwise changes to the `Request` by subsequent interceptors won't be considered - Fix "class ch.qos.logback.classic.spi.ThrowableProxyVO cannot be cast to class ch.qos.logback.classic.spi.ThrowableProxy" ([#4206](https://github.com/getsentry/sentry-java/pull/4206)) - In this case we cannot report the `Throwable` to Sentry as it's not available - If you are using OpenTelemetry v1 `OpenTelemetryAppender`, please consider upgrading to v2 @@ -818,7 +1863,7 @@ ### Behavioural Changes - The class `io.sentry.spring.jakarta.webflux.ReactorUtils` is now deprecated, please use `io.sentry.reactor.SentryReactorUtils` in the new `sentry-reactor` module instead ([#4155](https://github.com/getsentry/sentry-java/pull/4155)) - - The new module will be exposed as an `api` dependency when using `sentry-spring-boot-jakarta` (Spring Boot 3) or `sentry-spring-jakarta` (Spring 6). + - The new module will be exposed as an `api` dependency when using `sentry-spring-boot-jakarta` (Spring Boot 3) or `sentry-spring-jakarta` (Spring 6). Therefore, if you're using one of those modules, changing your imports will suffice. ## 8.2.0 @@ -832,7 +1877,7 @@ - Create onCreate and onStart spans for all Activities ([#4025](https://github.com/getsentry/sentry-java/pull/4025)) - Add split apks info to the `App` context ([#3193](https://github.com/getsentry/sentry-java/pull/3193)) - Expose new `withSentryObservableEffect` method overload that accepts `SentryNavigationListener` as a parameter ([#4143](https://github.com/getsentry/sentry-java/pull/4143)) - - This allows sharing the same `SentryNavigationListener` instance across fragments and composables to preserve the trace + - This allows sharing the same `SentryNavigationListener` instance across fragments and composables to preserve the trace - (Internal) Add API to filter native debug images based on stacktrace addresses ([#4089](https://github.com/getsentry/sentry-java/pull/4089)) - Propagate sampling random value ([#4153](https://github.com/getsentry/sentry-java/pull/4153)) - The random value used for sampling traces is now sent to Sentry and attached to the `baggage` header on outgoing requests @@ -903,6 +1948,7 @@ SentryAndroid.init(context) { options -> ``` If you would like to keep some of the default broadcast events as breadcrumbs, consider opening a [GitHub issue](https://github.com/getsentry/sentry-java/issues/new). + - Set mechanism `type` to `suppressed` for suppressed exceptions ([#4125](https://github.com/getsentry/sentry-java/pull/4125)) - This helps to distinguish an exceptions cause from any suppressed exceptions in the Sentry UI @@ -924,10 +1970,10 @@ Version 8 of the Sentry Android/Java SDK brings a variety of features and fixes. - Lifecycle tokens have been introduced to manage `Scope` lifecycle, see "Behavioural Changes" for more details. - Bumping `minSdk` level to 21 (Android 5.0) - Our `sentry-opentelemetry-agent` has been improved and now works in combination with the rest of Sentry. You may now combine OpenTelemetry and Sentry for instrumenting your application. - - You may now use both OpenTelemetry SDK and Sentry SDK to capture transactions and spans. They can also be mixed and end up on the same transaction. - - OpenTelemetry extends the Sentry SDK by adding spans for numerous integrations, like Ktor, Vert.x and MongoDB. Please check [the OpenTelemetry GitHub repository](https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/instrumentation) for a full list. - - OpenTelemetry allows propagating trace information from and to additional libraries, that Sentry did not support before, for example gRPC. - - OpenTelemetry also has broader support for propagating the Sentry `Scopes` through reactive libraries like RxJava. + - You may now use both OpenTelemetry SDK and Sentry SDK to capture transactions and spans. They can also be mixed and end up on the same transaction. + - OpenTelemetry extends the Sentry SDK by adding spans for numerous integrations, like Ktor, Vert.x and MongoDB. Please check [the OpenTelemetry GitHub repository](https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/instrumentation) for a full list. + - OpenTelemetry allows propagating trace information from and to additional libraries, that Sentry did not support before, for example gRPC. + - OpenTelemetry also has broader support for propagating the Sentry `Scopes` through reactive libraries like RxJava. - The SDK is now compatible with Spring Boot 3.4 - We now support GraphQL v22 (`sentry-graphql-22`) - Metrics have been removed @@ -944,11 +1990,11 @@ This SDK version is compatible with a self-hosted version of Sentry `22.12.0` or - The minSdk level for sentry-android-ndk changed from 19 to 21 ([#3851](https://github.com/getsentry/sentry-java/pull/3851)) - Throw IllegalArgumentException when calling Sentry.init on Android ([#3596](https://github.com/getsentry/sentry-java/pull/3596)) - Metrics have been removed from the SDK ([#3774](https://github.com/getsentry/sentry-java/pull/3774)) - - Metrics will return but we don't know in what exact form yet + - Metrics will return but we don't know in what exact form yet - `enableTracing` option (a.k.a `enable-tracing`) has been removed from the SDK ([#3776](https://github.com/getsentry/sentry-java/pull/3776)) - - Please set `tracesSampleRate` to a value >= 0.0 for enabling performance instead. The default value is `null` which means performance is disabled. + - Please set `tracesSampleRate` to a value >= 0.0 for enabling performance instead. The default value is `null` which means performance is disabled. - Replace `synchronized` methods and blocks with `ReentrantLock` (`AutoClosableReentrantLock`) ([#3715](https://github.com/getsentry/sentry-java/pull/3715)) - - If you are subclassing any Sentry classes, please check if the parent class used `synchronized` before. Please make sure to use the same lock object as the parent class in that case. + - If you are subclassing any Sentry classes, please check if the parent class used `synchronized` before. Please make sure to use the same lock object as the parent class in that case. - `traceOrigins` option (`io.sentry.traces.tracing-origins` in manifest) has been removed, please use `tracePropagationTargets` (`io.sentry.traces.trace-propagation-targets` in manifest`) instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) - `profilingEnabled` option (`io.sentry.traces.profiling.enable` in manifest) has been removed, please use `profilesSampleRate` (`io.sentry.traces.profiling.sample-rate` instead) instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) - `shutdownTimeout` option has been removed, please use `shutdownTimeoutMillis` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) @@ -968,32 +2014,32 @@ This SDK version is compatible with a self-hosted version of Sentry `22.12.0` or - User segment has been removed ([#3512](https://github.com/getsentry/sentry-java/pull/3512)) - One of the `AndroidTransactionProfiler` constructors has been removed, please use a different one ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) - Use String instead of UUID for SessionId ([#3834](https://github.com/getsentry/sentry-java/pull/3834)) - - The `Session` constructor now takes a `String` instead of a `UUID` for the `sessionId` parameter. - - `Session.getSessionId()` now returns a `String` instead of a `UUID`. + - The `Session` constructor now takes a `String` instead of a `UUID` for the `sessionId` parameter. + - `Session.getSessionId()` now returns a `String` instead of a `UUID`. - All status codes below 400 are now mapped to `SpanStatus.OK` ([#3869](https://github.com/getsentry/sentry-java/pull/3869)) - Change OkHttp sub-spans to span attributes ([#3556](https://github.com/getsentry/sentry-java/pull/3556)) - - This will reduce the number of spans created by the SDK + - This will reduce the number of spans created by the SDK - `instrumenter` option should no longer be needed as our new OpenTelemetry integration now works in combination with the rest of Sentry ### Behavioural Changes - We're introducing some new `Scope` types in the SDK, allowing for better control over what data is attached where. Previously there was a stack of scopes that was pushed and popped. Instead we now fork scopes for a given lifecycle and then restore the previous scopes. Since `Hub` is gone, it is also never cloned anymore. Separation of data now happens through the different scope types while making it easier to manipulate exactly what you need without having to attach data at the right time to have it apply where wanted. - - Global scope is attached to all events created by the SDK. It can also be modified before `Sentry.init` has been called. It can be manipulated using `Sentry.configureScope(ScopeType.GLOBAL, (scope) -> { ... })`. - - Isolation scope can be used e.g. to attach data to all events that come up while handling an incoming request. It can also be used for other isolation purposes. It can be manipulated using `Sentry.configureScope(ScopeType.ISOLATION, (scope) -> { ... })`. The SDK automatically forks isolation scope in certain cases like incoming requests, CRON jobs, Spring `@Async` and more. - - Current scope is forked often and data added to it is only added to events that are created while this scope is active. Data is also passed on to newly forked child scopes but not to parents. It can be manipulated using `Sentry.configureScope(ScopeType.CURRENT, (scope) -> { ... })`. + - Global scope is attached to all events created by the SDK. It can also be modified before `Sentry.init` has been called. It can be manipulated using `Sentry.configureScope(ScopeType.GLOBAL, (scope) -> { ... })`. + - Isolation scope can be used e.g. to attach data to all events that come up while handling an incoming request. It can also be used for other isolation purposes. It can be manipulated using `Sentry.configureScope(ScopeType.ISOLATION, (scope) -> { ... })`. The SDK automatically forks isolation scope in certain cases like incoming requests, CRON jobs, Spring `@Async` and more. + - Current scope is forked often and data added to it is only added to events that are created while this scope is active. Data is also passed on to newly forked child scopes but not to parents. It can be manipulated using `Sentry.configureScope(ScopeType.CURRENT, (scope) -> { ... })`. - `Sentry.popScope` has been deprecated, please call `.close()` on the token returned by `Sentry.pushScope` instead or use it in a way described in more detail in [our migration guide](https://docs.sentry.io/platforms/java/migration/7.x-to-8.0). - We have chosen a default scope that is used for `Sentry.configureScope()` as well as API like `Sentry.setTag()` - - For Android the type defaults to `CURRENT` scope - - For Backend and other JVM applicatons it defaults to `ISOLATION` scope + - For Android the type defaults to `CURRENT` scope + - For Backend and other JVM applicatons it defaults to `ISOLATION` scope - Event processors on `Scope` can now be ordered by overriding the `getOrder` method on implementations of `EventProcessor`. NOTE: This order only applies to event processors on `Scope` but not `SentryOptions` at the moment. Feel free to request this if you need it. - `Hub` is deprecated in favor of `Scopes`, alongside some `Hub` relevant APIs. More details can be found in [our migration guide](https://docs.sentry.io/platforms/java/migration/7.x-to-8.0). - Send file name and path only if `isSendDefaultPii` is `true` ([#3919](https://github.com/getsentry/sentry-java/pull/3919)) - (Android) Enable Performance V2 by default ([#3824](https://github.com/getsentry/sentry-java/pull/3824)) - - With this change cold app start spans will include spans for ContentProviders, Application and Activity load. + - With this change cold app start spans will include spans for ContentProviders, Application and Activity load. - (Android) Replace thread id with kernel thread id in span data ([#3706](https://github.com/getsentry/sentry-java/pull/3706)) - (Android) The JNI layer for sentry-native has now been moved from sentry-java to sentry-native ([#3189](https://github.com/getsentry/sentry-java/pull/3189)) - - This now includes prefab support for sentry-native, allowing you to link and access the sentry-native API within your native app code - - Checkout the `sentry-samples/sentry-samples-android` example on how to configure CMake and consume `sentry.h` + - This now includes prefab support for sentry-native, allowing you to link and access the sentry-native API within your native app code + - Checkout the `sentry-samples/sentry-samples-android` example on how to configure CMake and consume `sentry.h` - The user ip-address is now only set to `"{{auto}}"` if `sendDefaultPii` is enabled ([#4072](https://github.com/getsentry/sentry-java/pull/4072)) - This change gives you control over IP address collection directly on the client @@ -1001,49 +2047,49 @@ This SDK version is compatible with a self-hosted version of Sentry `22.12.0` or - The SDK is now compatible with Spring Boot 3.4 ([#3939](https://github.com/getsentry/sentry-java/pull/3939)) - Our `sentry-opentelemetry-agent` has been completely reworked and now plays nicely with the rest of the Java SDK - - You may also want to give this new agent a try even if you haven't used OpenTelemetry (with Sentry) before. It offers support for [many more libraries and frameworks](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md), improving on our trace propagation, `Scopes` (used to be `Hub`) propagation as well as performance instrumentation (i.e. more spans). - - If you are using a framework we did not support before and currently resort to manual instrumentation, please give the agent a try. See [here for a list of supported libraries, frameworks and application servers](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md). - - Please see [Java SDK docs](https://docs.sentry.io/platforms/java/tracing/instrumentation/opentelemetry/) for more details on how to set up the agent. Please make sure to select the correct SDK from the dropdown on the left side of the docs. - - What's new about the Agent - - When the OpenTelemetry Agent is used, Sentry API creates OpenTelemetry spans under the hood, handing back a wrapper object which bridges the gap between traditional Sentry API and OpenTelemetry. We might be replacing some of the Sentry performance API in the future. - - This is achieved by configuring the SDK to use `OtelSpanFactory` instead of `DefaultSpanFactory` which is done automatically by the auto init of the Java Agent. - - OpenTelemetry spans are now only turned into Sentry spans when they are finished so they can be sent to the Sentry server. - - Now registers an OpenTelemetry `Sampler` which uses Sentry sampling configuration - - Other Performance integrations automatically stop creating spans to avoid duplicate spans - - The Sentry SDK now makes use of OpenTelemetry `Context` for storing Sentry `Scopes` (which is similar to what used to be called `Hub`) and thus relies on OpenTelemetry for `Context` propagation. - - Classes used for the previous version of our OpenTelemetry support have been deprecated but can still be used manually. We're not planning to keep the old agent around in favor of less complexity in the SDK. + - You may also want to give this new agent a try even if you haven't used OpenTelemetry (with Sentry) before. It offers support for [many more libraries and frameworks](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md), improving on our trace propagation, `Scopes` (used to be `Hub`) propagation as well as performance instrumentation (i.e. more spans). + - If you are using a framework we did not support before and currently resort to manual instrumentation, please give the agent a try. See [here for a list of supported libraries, frameworks and application servers](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md). + - Please see [Java SDK docs](https://docs.sentry.io/platforms/java/tracing/instrumentation/opentelemetry/) for more details on how to set up the agent. Please make sure to select the correct SDK from the dropdown on the left side of the docs. + - What's new about the Agent + - When the OpenTelemetry Agent is used, Sentry API creates OpenTelemetry spans under the hood, handing back a wrapper object which bridges the gap between traditional Sentry API and OpenTelemetry. We might be replacing some of the Sentry performance API in the future. + - This is achieved by configuring the SDK to use `OtelSpanFactory` instead of `DefaultSpanFactory` which is done automatically by the auto init of the Java Agent. + - OpenTelemetry spans are now only turned into Sentry spans when they are finished so they can be sent to the Sentry server. + - Now registers an OpenTelemetry `Sampler` which uses Sentry sampling configuration + - Other Performance integrations automatically stop creating spans to avoid duplicate spans + - The Sentry SDK now makes use of OpenTelemetry `Context` for storing Sentry `Scopes` (which is similar to what used to be called `Hub`) and thus relies on OpenTelemetry for `Context` propagation. + - Classes used for the previous version of our OpenTelemetry support have been deprecated but can still be used manually. We're not planning to keep the old agent around in favor of less complexity in the SDK. - Add `sentry-opentelemetry-agentless-spring` module ([#4000](https://github.com/getsentry/sentry-java/pull/4000)) - - This module can be added as a dependency when using Sentry with OpenTelemetry and Spring Boot but don't want to use our Agent. It takes care of configuring OpenTelemetry for use with Sentry. - - You may want to set `OTEL_LOGS_EXPORTER=none;OTEL_METRICS_EXPORTER=none;OTEL_TRACES_EXPORTER=none` env vars to not have the log flooded with error messages regarding OpenTelemetry features we don't use. + - This module can be added as a dependency when using Sentry with OpenTelemetry and Spring Boot but don't want to use our Agent. It takes care of configuring OpenTelemetry for use with Sentry. + - You may want to set `OTEL_LOGS_EXPORTER=none;OTEL_METRICS_EXPORTER=none;OTEL_TRACES_EXPORTER=none` env vars to not have the log flooded with error messages regarding OpenTelemetry features we don't use. - Add `sentry-opentelemetry-agentless` module ([#3961](https://github.com/getsentry/sentry-java/pull/3961)) - - This module can be added as a dependency when using Sentry with OpenTelemetry but don't want to use our Agent. It takes care of configuring OpenTelemetry for use with Sentry. - - To enable the auto configuration of it, please set `-Dotel.java.global-autoconfigure.enabled=true` on the `java` command, when starting your application. - - You may also want to set `OTEL_LOGS_EXPORTER=none;OTEL_METRICS_EXPORTER=none;OTEL_TRACES_EXPORTER=none` env vars to not have the log flooded with error messages regarding OpenTelemetry features we don't use. + - This module can be added as a dependency when using Sentry with OpenTelemetry but don't want to use our Agent. It takes care of configuring OpenTelemetry for use with Sentry. + - To enable the auto configuration of it, please set `-Dotel.java.global-autoconfigure.enabled=true` on the `java` command, when starting your application. + - You may also want to set `OTEL_LOGS_EXPORTER=none;OTEL_METRICS_EXPORTER=none;OTEL_TRACES_EXPORTER=none` env vars to not have the log flooded with error messages regarding OpenTelemetry features we don't use. - `OpenTelemetryUtil.applyOpenTelemetryOptions` now takes an enum instead of a boolean for its mode - Add `openTelemetryMode` option ([#3994](https://github.com/getsentry/sentry-java/pull/3994)) - - It defaults to `AUTO` meaning the SDK will figure out how to best configure itself for use with OpenTelemetry - - Use of OpenTelemetry can also be disabled completely by setting it to `OFF` ([#3995](https://github.com/getsentry/sentry-java/pull/3995)) - - In this case even if OpenTelemetry is present, the Sentry SDK will not use it - - Use `AGENT` when using `sentry-opentelemetry-agent` - - Use `AGENTLESS` when using `sentry-opentelemetry-agentless` - - Use `AGENTLESS_SPRING` when using `sentry-opentelemetry-agentless-spring` + - It defaults to `AUTO` meaning the SDK will figure out how to best configure itself for use with OpenTelemetry + - Use of OpenTelemetry can also be disabled completely by setting it to `OFF` ([#3995](https://github.com/getsentry/sentry-java/pull/3995)) + - In this case even if OpenTelemetry is present, the Sentry SDK will not use it + - Use `AGENT` when using `sentry-opentelemetry-agent` + - Use `AGENTLESS` when using `sentry-opentelemetry-agentless` + - Use `AGENTLESS_SPRING` when using `sentry-opentelemetry-agentless-spring` - Add `ignoredTransactions` option to filter out transactions by name ([#3871](https://github.com/getsentry/sentry-java/pull/3871)) - - can be used via ENV vars, e.g. `SENTRY_IGNORED_TRANSACTIONS=POST /person/,GET /pers.*` - - can also be set in options directly, e.g. `options.setIgnoredTransactions(...)` - - can also be set in `sentry.properties`, e.g. `ignored-transactions=POST /person/,GET /pers.*` - - can also be set in Spring config `application.properties`, e.g. `sentry.ignored-transactions=POST /person/,GET /pers.*` + - can be used via ENV vars, e.g. `SENTRY_IGNORED_TRANSACTIONS=POST /person/,GET /pers.*` + - can also be set in options directly, e.g. `options.setIgnoredTransactions(...)` + - can also be set in `sentry.properties`, e.g. `ignored-transactions=POST /person/,GET /pers.*` + - can also be set in Spring config `application.properties`, e.g. `sentry.ignored-transactions=POST /person/,GET /pers.*` - Add `scopeBindingMode` to `SpanOptions` ([#4004](https://github.com/getsentry/sentry-java/pull/4004)) - - This setting only affects the SDK when used with OpenTelemetry. - - Defaults to `AUTO` meaning the SDK will decide whether the span should be bound to the current scope. It will not bind transactions to scope using `AUTO`, it will only bind spans where the parent span is on the current scope. - - `ON` sets the new span on the current scope. - - `OFF` does not set the new span on the scope. + - This setting only affects the SDK when used with OpenTelemetry. + - Defaults to `AUTO` meaning the SDK will decide whether the span should be bound to the current scope. It will not bind transactions to scope using `AUTO`, it will only bind spans where the parent span is on the current scope. + - `ON` sets the new span on the current scope. + - `OFF` does not set the new span on the scope. - Add `ignoredSpanOrigins` option for ignoring spans coming from certain integrations - - We pre-configure this to ignore Performance instrumentation for Spring and other integrations when using our OpenTelemetry Agent to avoid duplicate spans + - We pre-configure this to ignore Performance instrumentation for Spring and other integrations when using our OpenTelemetry Agent to avoid duplicate spans - Support `graphql-java` v22 via a new module `sentry-graphql-22` ([#3740](https://github.com/getsentry/sentry-java/pull/3740)) - - If you are using `graphql-java` v21 or earlier, you can use the `sentry-graphql` module - - For `graphql-java` v22 and newer please use the `sentry-graphql-22` module + - If you are using `graphql-java` v21 or earlier, you can use the `sentry-graphql` module + - For `graphql-java` v22 and newer please use the `sentry-graphql-22` module - We now provide a `SentryInstrumenter` bean directly for Spring (Boot) if there is none yet instead of using `GraphQlSourceBuilderCustomizer` to add the instrumentation ([#3744](https://github.com/getsentry/sentry-java/pull/3744)) - - It is now also possible to provide a bean of type `SentryGraphqlInstrumentation.BeforeSpanCallback` which is then used by `SentryInstrumenter` + - It is now also possible to provide a bean of type `SentryGraphqlInstrumentation.BeforeSpanCallback` which is then used by `SentryInstrumenter` - Add data fetching environment hint to breadcrumb for GraphQL (#3413) ([#3431](https://github.com/getsentry/sentry-java/pull/3431)) - Report exceptions returned by Throwable.getSuppressed() to Sentry as exception groups ([#3396] https://github.com/getsentry/sentry-java/pull/3396) - Any suppressed exceptions are added to the issue details page in Sentry, the same way any cause is. @@ -1051,23 +2097,23 @@ This SDK version is compatible with a self-hosted version of Sentry `22.12.0` or - Enable `ThreadLocalAccessor` for Spring Boot 3 WebFlux by default ([#4023](https://github.com/getsentry/sentry-java/pull/4023)) - Allow passing `environment` to `CheckinUtils.withCheckIn` ([3889](https://github.com/getsentry/sentry-java/pull/3889)) - Add `globalHubMode` to options ([#3805](https://github.com/getsentry/sentry-java/pull/3805)) - - `globalHubMode` used to only be a param on `Sentry.init`. To make it easier to be used in e.g. Desktop environments, we now additionally added it as an option on SentryOptions that can also be set via `sentry.properties`. - - If both the param on `Sentry.init` and the option are set, the option will win. By default the option is set to `null` meaning whatever is passed to `Sentry.init` takes effect. + - `globalHubMode` used to only be a param on `Sentry.init`. To make it easier to be used in e.g. Desktop environments, we now additionally added it as an option on SentryOptions that can also be set via `sentry.properties`. + - If both the param on `Sentry.init` and the option are set, the option will win. By default the option is set to `null` meaning whatever is passed to `Sentry.init` takes effect. - Lazy uuid generation for SentryId and SpanId ([#3770](https://github.com/getsentry/sentry-java/pull/3770)) - Faster generation of Sentry and Span IDs ([#3818](https://github.com/getsentry/sentry-java/pull/3818)) - - Uses faster implementation to convert UUID to SentryID String - - Uses faster Random implementation to generate UUIDs + - Uses faster implementation to convert UUID to SentryID String + - Uses faster Random implementation to generate UUIDs - Android 15: Add support for 16KB page sizes ([#3851](https://github.com/getsentry/sentry-java/pull/3851)) - - See https://developer.android.com/guide/practices/page-sizes for more details + - See https://developer.android.com/guide/practices/page-sizes for more details - Add init priority settings ([#3674](https://github.com/getsentry/sentry-java/pull/3674)) - - You may now set `forceInit=true` (`force-init` for `.properties` files) to ensure a call to Sentry.init / SentryAndroid.init takes effect + - You may now set `forceInit=true` (`force-init` for `.properties` files) to ensure a call to Sentry.init / SentryAndroid.init takes effect - Add force init option to Android Manifest ([#3675](https://github.com/getsentry/sentry-java/pull/3675)) - - Use `` to ensure Sentry Android auto init is not easily overwritten + - Use `` to ensure Sentry Android auto init is not easily overwritten - Attach request body for `application/x-www-form-urlencoded` requests in Spring ([#3731](https://github.com/getsentry/sentry-java/pull/3731)) - - Previously request body was only attached for `application/json` requests + - Previously request body was only attached for `application/json` requests - Set breadcrumb level based on http status ([#3771](https://github.com/getsentry/sentry-java/pull/3771)) - Emit transaction.data inside contexts.trace.data ([#3735](https://github.com/getsentry/sentry-java/pull/3735)) - - Also does not emit `transaction.data` in `extras` anymore + - Also does not emit `transaction.data` in `extras` anymore - Add a sample for showcasing Sentry with OpenTelemetry for Spring Boot 3 with our Java agent (`sentry-samples-spring-boot-jakarta-opentelemetry`) ([#3856](https://github.com/getsentry/sentry-java/pull/3828)) - Add a sample for showcasing Sentry with OpenTelemetry for Spring Boot 3 without our Java agent (`sentry-samples-spring-boot-jakarta-opentelemetry-noagent`) ([#3856](https://github.com/getsentry/sentry-java/pull/3856)) - Add a sample for showcasing Sentry with OpenTelemetry (`sentry-samples-console-opentelemetry-noagent`) ([#3856](https://github.com/getsentry/sentry-java/pull/3862)) @@ -1075,28 +2121,28 @@ This SDK version is compatible with a self-hosted version of Sentry `22.12.0` or ### Fixes - Fix incoming defer sampling decision `sentry-trace` header ([#3942](https://github.com/getsentry/sentry-java/pull/3942)) - - A `sentry-trace` header that only contains trace ID and span ID but no sampled flag (`-1`, `-0` suffix) means the receiving system can make its own sampling decision - - When generating `sentry-trace` header from `PropagationContext` we now copy the `sampled` flag. - - In `TransactionContext.fromPropagationContext` when there is no parent sampling decision, keep the decision `null` so a new sampling decision is made instead of defaulting to `false` + - A `sentry-trace` header that only contains trace ID and span ID but no sampled flag (`-1`, `-0` suffix) means the receiving system can make its own sampling decision + - When generating `sentry-trace` header from `PropagationContext` we now copy the `sampled` flag. + - In `TransactionContext.fromPropagationContext` when there is no parent sampling decision, keep the decision `null` so a new sampling decision is made instead of defaulting to `false` - Fix order of calling `close` on previous Sentry instance when re-initializing ([#3750](https://github.com/getsentry/sentry-java/pull/3750)) - - Previously some parts of Sentry were immediately closed after re-init that should have stayed open and some parts of the previous init were never closed + - Previously some parts of Sentry were immediately closed after re-init that should have stayed open and some parts of the previous init were never closed - All status codes below 400 are now mapped to `SpanStatus.OK` ([#3869](https://github.com/getsentry/sentry-java/pull/3869)) - Improve ignored check performance ([#3992](https://github.com/getsentry/sentry-java/pull/3992)) - - Checking if a span origin, a transaction or a checkIn should be ignored is now faster + - Checking if a span origin, a transaction or a checkIn should be ignored is now faster - Cache requests for Spring using Springs `ContentCachingRequestWrapper` instead of our own Wrapper to also cache parameters ([#3641](https://github.com/getsentry/sentry-java/pull/3641)) - - Previously only the body was cached which could lead to problems in the FilterChain as Request parameters were not available + - Previously only the body was cached which could lead to problems in the FilterChain as Request parameters were not available - Close backpressure monitor on SDK shutdown ([#3998](https://github.com/getsentry/sentry-java/pull/3998)) - - Due to the backpressure monitor rescheduling a task to run every 10s, it very likely caused shutdown to wait the full `shutdownTimeoutMillis` (defaulting to 2s) instead of being able to terminate immediately + - Due to the backpressure monitor rescheduling a task to run every 10s, it very likely caused shutdown to wait the full `shutdownTimeoutMillis` (defaulting to 2s) instead of being able to terminate immediately - Let OpenTelemetry auto instrumentation handle extracting and injecting tracing information if present ([#3953](https://github.com/getsentry/sentry-java/pull/3953)) - - Our integrations no longer call `.continueTrace` and also do not inject tracing headers if the integration has been added to `ignoredSpanOrigins` + - Our integrations no longer call `.continueTrace` and also do not inject tracing headers if the integration has been added to `ignoredSpanOrigins` - Fix testTag not working for Jetpack Compose user interaction tracking ([#3878](https://github.com/getsentry/sentry-java/pull/3878)) - Mark `DiskFlushNotification` hint flushed when rate limited ([#3892](https://github.com/getsentry/sentry-java/pull/3892)) - - Our `UncaughtExceptionHandlerIntegration` waited for the full flush timeout duration (default 15s) when rate limited. + - Our `UncaughtExceptionHandlerIntegration` waited for the full flush timeout duration (default 15s) when rate limited. - Do not replace `op` with auto generated content for OpenTelemetry spans with span kind `INTERNAL` ([#3906](https://github.com/getsentry/sentry-java/pull/3906)) - Add `enable-spotlight` and `spotlight-connection-url` to external options and check if spotlight is enabled when deciding whether to inspect an OpenTelemetry span for connecting to splotlight ([#3709](https://github.com/getsentry/sentry-java/pull/3709)) - Trace context on `Contexts.setTrace` has been marked `@NotNull` ([#3721](https://github.com/getsentry/sentry-java/pull/3721)) - - Setting it to `null` would cause an exception. - - Transactions are dropped if trace context is missing + - Setting it to `null` would cause an exception. + - Transactions are dropped if trace context is missing - Remove internal annotation on `SpanOptions` ([#3722](https://github.com/getsentry/sentry-java/pull/3722)) - `SentryLogbackInitializer` is now public ([#3723](https://github.com/getsentry/sentry-java/pull/3723)) - Parse and use `send-default-pii` and `max-request-body-size` from `sentry.properties` ([#3534](https://github.com/getsentry/sentry-java/pull/3534)) @@ -1113,66 +2159,66 @@ These changes have been made during development of `8.0.0`. You may skip this se - Extract OpenTelemetry `URL_PATH` span attribute into description ([#3933](https://github.com/getsentry/sentry-java/pull/3933)) - Replace OpenTelemetry `ContextStorage` wrapper with `ContextStorageProvider` ([#3938](https://github.com/getsentry/sentry-java/pull/3938)) - - The wrapper had to be put in place before any call to `Context` whereas `ContextStorageProvider` is automatically invoked at the correct time. + - The wrapper had to be put in place before any call to `Context` whereas `ContextStorageProvider` is automatically invoked at the correct time. - Send `otel.kind` to Sentry ([#3907](https://github.com/getsentry/sentry-java/pull/3907)) - Spring Boot now automatically detects if OpenTelemetry is available and makes use of it ([#3846](https://github.com/getsentry/sentry-java/pull/3846)) - - This is only enabled if there is no OpenTelemetry agent available - - We prefer to use the OpenTelemetry agent as it offers more auto instrumentation - - In some cases the OpenTelemetry agent cannot be used, please see https://opentelemetry.io/docs/zero-code/java/spring-boot-starter/ for more details on when to prefer the Agent and when the Spring Boot starter makes more sense. - - In this mode the SDK makes use of the `OpenTelemetry` bean that is created by `opentelemetry-spring-boot-starter` instead of `GlobalOpenTelemetry` + - This is only enabled if there is no OpenTelemetry agent available + - We prefer to use the OpenTelemetry agent as it offers more auto instrumentation + - In some cases the OpenTelemetry agent cannot be used, please see https://opentelemetry.io/docs/zero-code/java/spring-boot-starter/ for more details on when to prefer the Agent and when the Spring Boot starter makes more sense. + - In this mode the SDK makes use of the `OpenTelemetry` bean that is created by `opentelemetry-spring-boot-starter` instead of `GlobalOpenTelemetry` - Spring Boot now automatically detects our OpenTelemetry agent if its auto init is disabled ([#3848](https://github.com/getsentry/sentry-java/pull/3848)) - - This means Spring Boot config mechanisms can now be combined with our OpenTelemetry agent - - The `sentry-opentelemetry-extra` module has been removed again, most classes have been moved to `sentry-opentelemetry-bootstrap` which is loaded into the bootstrap classloader (i.e. `null`) when our Java agent is used. The rest has been moved into `sentry-opentelemetry-agentcustomization` and is loaded into the agent classloader when our Java agent is used. - - The `sentry-opentelemetry-bootstrap` and `sentry-opentelemetry-agentcustomization` modules can be used without the agent as well, in which case all classes are loaded into the application classloader. Check out our `sentry-samples-spring-boot-jakarta-opentelemetry-noagent` sample. - - In this mode the SDK makes use of `GlobalOpenTelemetry` + - This means Spring Boot config mechanisms can now be combined with our OpenTelemetry agent + - The `sentry-opentelemetry-extra` module has been removed again, most classes have been moved to `sentry-opentelemetry-bootstrap` which is loaded into the bootstrap classloader (i.e. `null`) when our Java agent is used. The rest has been moved into `sentry-opentelemetry-agentcustomization` and is loaded into the agent classloader when our Java agent is used. + - The `sentry-opentelemetry-bootstrap` and `sentry-opentelemetry-agentcustomization` modules can be used without the agent as well, in which case all classes are loaded into the application classloader. Check out our `sentry-samples-spring-boot-jakarta-opentelemetry-noagent` sample. + - In this mode the SDK makes use of `GlobalOpenTelemetry` - Automatically set span factory based on presence of OpenTelemetry ([#3858](https://github.com/getsentry/sentry-java/pull/3858)) - - `SentrySpanFactoryHolder` has been removed as it is no longer required. + - `SentrySpanFactoryHolder` has been removed as it is no longer required. - Replace deprecated `SimpleInstrumentation` with `SimplePerformantInstrumentation` for graphql 22 ([#3974](https://github.com/getsentry/sentry-java/pull/3974)) - We now hold a strong reference to the underlying OpenTelemetry span when it is created through Sentry API ([#3997](https://github.com/getsentry/sentry-java/pull/3997)) - - This keeps it from being garbage collected too early + - This keeps it from being garbage collected too early - Defer sampling decision by setting `sampled` to `null` in `PropagationContext` when using OpenTelemetry in case of an incoming defer sampling `sentry-trace` header. ([#3945](https://github.com/getsentry/sentry-java/pull/3945)) - Build `PropagationContext` from `SamplingDecision` made by `SentrySampler` instead of parsing headers and potentially ignoring a sampling decision in case a `sentry-trace` header comes in with deferred sampling decision. ([#3947](https://github.com/getsentry/sentry-java/pull/3947)) - The Sentry OpenTelemetry Java agent now makes sure Sentry `Scopes` storage is initialized even if the agents auto init is disabled ([#3848](https://github.com/getsentry/sentry-java/pull/3848)) - - This is required for all integrations to work together with our OpenTelemetry Java agent if its auto init has been disabled and the SDKs init should be used instead. + - This is required for all integrations to work together with our OpenTelemetry Java agent if its auto init has been disabled and the SDKs init should be used instead. - Fix `startChild` for span that is not in current OpenTelemetry `Context` ([#3862](https://github.com/getsentry/sentry-java/pull/3862)) - - Starting a child span from a transaction that wasn't in the current `Context` lead to multiple transactions being created (one for the transaction and another per span created). + - Starting a child span from a transaction that wasn't in the current `Context` lead to multiple transactions being created (one for the transaction and another per span created). - Add `auto.graphql.graphql22` to ignored span origins when using OpenTelemetry ([#3828](https://github.com/getsentry/sentry-java/pull/3828)) - Use OpenTelemetry span name as fallback for transaction name ([#3557](https://github.com/getsentry/sentry-java/pull/3557)) - - In certain cases we were sending transactions as "" when using OpenTelemetry + - In certain cases we were sending transactions as "" when using OpenTelemetry - Add OpenTelemetry span data to Sentry span ([#3593](https://github.com/getsentry/sentry-java/pull/3593)) - No longer selectively copy OpenTelemetry attributes to Sentry spans / transactions `data` ([#3663](https://github.com/getsentry/sentry-java/pull/3663)) - Remove `PROCESS_COMMAND_ARGS` (`process.command_args`) OpenTelemetry span attribute as it can be very large ([#3664](https://github.com/getsentry/sentry-java/pull/3664)) - Use RECORD_ONLY sampling decision if performance is disabled ([#3659](https://github.com/getsentry/sentry-java/pull/3659)) - - Also fix check whether Performance is enabled when making a sampling decision in the OpenTelemetry sampler + - Also fix check whether Performance is enabled when making a sampling decision in the OpenTelemetry sampler - Sentry OpenTelemetry Java Agent now sets Instrumenter to SENTRY (used to be OTEL) ([#3697](https://github.com/getsentry/sentry-java/pull/3697)) - Set span origin in `ActivityLifecycleIntegration` on span options instead of after creating the span / transaction ([#3702](https://github.com/getsentry/sentry-java/pull/3702)) - - This allows spans to be filtered by span origin on creation + - This allows spans to be filtered by span origin on creation - Honor ignored span origins in `SentryTracer.startChild` ([#3704](https://github.com/getsentry/sentry-java/pull/3704)) - Use span id of remote parent ([#3548](https://github.com/getsentry/sentry-java/pull/3548)) - - Traces were broken because on an incoming request, OtelSentrySpanProcessor did not set the parentSpanId on the span correctly. Traces were not referencing the actual parent span but some other (random) span ID which the server doesn't know. + - Traces were broken because on an incoming request, OtelSentrySpanProcessor did not set the parentSpanId on the span correctly. Traces were not referencing the actual parent span but some other (random) span ID which the server doesn't know. - Attach active span to scope when using OpenTelemetry ([#3549](https://github.com/getsentry/sentry-java/pull/3549)) - - Errors weren't linked to traces correctly due to parts of the SDK not knowing the current span + - Errors weren't linked to traces correctly due to parts of the SDK not knowing the current span - Record dropped spans in client report when sampling out OpenTelemetry spans ([#3552](https://github.com/getsentry/sentry-java/pull/3552)) - Retrieve the correct current span from `Scope`/`Scopes` when using OpenTelemetry ([#3554](https://github.com/getsentry/sentry-java/pull/3554)) - Support spans that are split into multiple batches ([#3539](https://github.com/getsentry/sentry-java/pull/3539)) - - When spans belonging to a single transaction were split into multiple batches for SpanExporter, we did not add all spans because the isSpanTooOld check wasn't inverted. + - When spans belonging to a single transaction were split into multiple batches for SpanExporter, we did not add all spans because the isSpanTooOld check wasn't inverted. - Partially fix bootstrap class loading ([#3543](https://github.com/getsentry/sentry-java/pull/3543)) - - There was a problem with two separate Sentry `Scopes` being active inside each OpenTelemetry `Context` due to using context keys from more than one class loader. + - There was a problem with two separate Sentry `Scopes` being active inside each OpenTelemetry `Context` due to using context keys from more than one class loader. - The Spring Boot 3 WebFlux sample now uses our GraphQL v22 integration ([#3828](https://github.com/getsentry/sentry-java/pull/3828)) - Do not ignore certain span origins for OpenTelemetry without agent ([#3856](https://github.com/getsentry/sentry-java/pull/3856)) - `span.startChild` now uses `.makeCurrent()` by default ([#3544](https://github.com/getsentry/sentry-java/pull/3544)) - - This caused an issue where the span tree wasn't correct because some spans were not added to their direct parent + - This caused an issue where the span tree wasn't correct because some spans were not added to their direct parent - Do not set the exception group marker when there is a suppressed exception ([#4056](https://github.com/getsentry/sentry-java/pull/4056)) - - Due to how grouping works in Sentry currently sometimes the suppressed exception is treated as the main exception. This change ensures we keep using the main exception and not change how grouping works. - - As a consequence the list of exceptions in the group on top of an issue is no longer shown in Sentry UI. - - We are planning to improve this in the future but opted for this fix first. + - Due to how grouping works in Sentry currently sometimes the suppressed exception is treated as the main exception. This change ensures we keep using the main exception and not change how grouping works. + - As a consequence the list of exceptions in the group on top of an issue is no longer shown in Sentry UI. + - We are planning to improve this in the future but opted for this fix first. ### Dependencies - Bump Native SDK from v0.7.0 to v0.7.17 ([#3441](https://github.com/getsentry/sentry-java/pull/3189)) ([#3851](https://github.com/getsentry/sentry-java/pull/3851)) ([#3914](https://github.com/getsentry/sentry-java/pull/3914)) ([#4003](https://github.com/getsentry/sentry-java/pull/4003)) - - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0717) - - [diff](https://github.com/getsentry/sentry-native/compare/0.7.0...0.7.17) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0717) + - [diff](https://github.com/getsentry/sentry-native/compare/0.7.0...0.7.17) - Bump OpenTelemetry to 1.44.1, OpenTelemetry Java Agent to 2.10.0 and Semantic Conventions to 1.28.0 ([#3668](https://github.com/getsentry/sentry-java/pull/3668)) ([#3935](https://github.com/getsentry/sentry-java/pull/3935)) ### Migration Guide / Deprecations @@ -1180,10 +2226,10 @@ These changes have been made during development of `8.0.0`. You may skip this se Please take a look at [our migration guide in docs](https://docs.sentry.io/platforms/java/migration/7.x-to-8.0). - `Hub` has been deprecated, we're replacing the following: - - `IHub` has been replaced by `IScopes`, however you should be able to simply pass `IHub` instances to code expecting `IScopes`, allowing for an easier migration. - - `HubAdapter.getInstance()` has been replaced by `ScopesAdapter.getInstance()` - - The `.clone()` method on `IHub`/`IScopes` has been deprecated, please use `.pushScope()` or `.pushIsolationScope()` instead - - Some internal methods like `.getCurrentHub()` and `.setCurrentHub()` have also been replaced. + - `IHub` has been replaced by `IScopes`, however you should be able to simply pass `IHub` instances to code expecting `IScopes`, allowing for an easier migration. + - `HubAdapter.getInstance()` has been replaced by `ScopesAdapter.getInstance()` + - The `.clone()` method on `IHub`/`IScopes` has been deprecated, please use `.pushScope()` or `.pushIsolationScope()` instead + - Some internal methods like `.getCurrentHub()` and `.setCurrentHub()` have also been replaced. - `Sentry.popScope` has been replaced by calling `.close()` on the token returned by `Sentry.pushScope()` and `Sentry.pushIsolationScope()`. The token can also be used in a `try` block like this: ``` @@ -1194,28 +2240,27 @@ try (final @NotNull ISentryLifecycleToken ignored = Sentry.pushScope()) { as well as: - ``` try (final @NotNull ISentryLifecycleToken ignored = Sentry.pushIsolationScope()) { // this block has its separate isolation scope } ``` + - Classes used by our previous OpenTelemetry integration have been deprecated (`SentrySpanProcessor`, `SentryPropagator`, `OpenTelemetryLinkErrorEventProcessor`). Please take a look at [docs](https://docs.sentry.io/platforms/java/tracing/instrumentation/opentelemetry/) on how to setup OpenTelemetry in v8. You may also use `LifecycleHelper.close(token)`, e.g. in case you need to pass the token around for closing later. - ### Changes from `rc.4` If you have been using `8.0.0-rc.4` of the Java SDK, here's the new changes that have been included in the `8.0.0` release: - Make `SentryClient` constructor public ([#4045](https://github.com/getsentry/sentry-java/pull/4045)) - The user ip-address is now only set to `"{{auto}}"` if sendDefaultPii is enabled ([#4072](https://github.com/getsentry/sentry-java/pull/4072)) - - This change gives you control over IP address collection directly on the client + - This change gives you control over IP address collection directly on the client - Do not set the exception group marker when there is a suppressed exception ([#4056](https://github.com/getsentry/sentry-java/pull/4056)) - - Due to how grouping works in Sentry currently sometimes the suppressed exception is treated as the main exception. This change ensures we keep using the main exception and not change how grouping works. - - As a consequence the list of exceptions in the group on top of an issue is no longer shown in Sentry UI. - - We are planning to improve this in the future but opted for this fix first. + - Due to how grouping works in Sentry currently sometimes the suppressed exception is treated as the main exception. This change ensures we keep using the main exception and not change how grouping works. + - As a consequence the list of exceptions in the group on top of an issue is no longer shown in Sentry UI. + - We are planning to improve this in the future but opted for this fix first. - Fix swallow NDK loadLibrary errors ([#4082](https://github.com/getsentry/sentry-java/pull/4082)) ## 7.22.6 @@ -1226,7 +2271,7 @@ If you have been using `8.0.0-rc.4` of the Java SDK, here's the new changes that - Improve low memory breadcrumb capturing ([#4325](https://github.com/getsentry/sentry-java/pull/4325)) - Make `SystemEventsBreadcrumbsIntegration` faster ([#4330](https://github.com/getsentry/sentry-java/pull/4330)) - Fix unregister `SystemEventsBroadcastReceiver` when entering background ([#4338](https://github.com/getsentry/sentry-java/pull/4338)) - - This should reduce ANRs seen with this class in the stack trace for Android 14 and above + - This should reduce ANRs seen with this class in the stack trace for Android 14 and above - Pre-load modules on a background thread upon SDK init ([#4348](https://github.com/getsentry/sentry-java/pull/4348)) - Session Replay: Fix inconsistent `segment_id` ([#4471](https://github.com/getsentry/sentry-java/pull/4471)) - Session Replay: Do not capture current replay for cached events from the past ([#4474](https://github.com/getsentry/sentry-java/pull/4474)) @@ -1274,11 +2319,11 @@ If you have been using `8.0.0-rc.4` of the Java SDK, here's the new changes that ### Fixes - Session Replay: Fix various crashes and issues ([#4135](https://github.com/getsentry/sentry-java/pull/4135)) - - Fix `FileNotFoundException` when trying to read/write `.ongoing_segment` file - - Fix `IllegalStateException` when registering `onDrawListener` - - Fix SIGABRT native crashes on Motorola devices when encoding a video + - Fix `FileNotFoundException` when trying to read/write `.ongoing_segment` file + - Fix `IllegalStateException` when registering `onDrawListener` + - Fix SIGABRT native crashes on Motorola devices when encoding a video - (Jetpack Compose) Modifier.sentryTag now uses Modifier.Node ([#4029](https://github.com/getsentry/sentry-java/pull/4029)) - - This allows Composables that use this modifier to be skippable + - This allows Composables that use this modifier to be skippable ## 7.21.0 @@ -1292,7 +2337,7 @@ If you have been using `8.0.0-rc.4` of the Java SDK, here's the new changes that ### Behavioural Changes - (changed in [7.20.1](https://github.com/getsentry/sentry-java/releases/tag/7.20.1)) The user ip-address is now only set to `"{{auto}}"` if sendDefaultPii is enabled ([#4071](https://github.com/getsentry/sentry-java/pull/4071)) - - This change gives you control over IP address collection directly on the client + - This change gives you control over IP address collection directly on the client - Reduce the number of broadcasts the SDK is subscribed for ([#4052](https://github.com/getsentry/sentry-java/pull/4052)) - Drop `TempSensorBreadcrumbsIntegration` - Drop `PhoneStateBreadcrumbsIntegration` @@ -1341,7 +2386,7 @@ If you would like to keep some of the default broadcast events as breadcrumbs, c ### Behavioural Changes - The user ip-address is now only set to `"{{auto}}"` if sendDefaultPii is enabled ([#4071](https://github.com/getsentry/sentry-java/pull/4071)) - - This change gives you control over IP address collection directly on the client + - This change gives you control over IP address collection directly on the client ## 7.20.0 @@ -1351,23 +2396,23 @@ If you would like to keep some of the default broadcast events as breadcrumbs, c To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onErrorSampleRate` options. - ```kotlin - import io.sentry.SentryReplayOptions - import io.sentry.android.core.SentryAndroid +```kotlin +import io.sentry.SentryReplayOptions +import io.sentry.android.core.SentryAndroid - SentryAndroid.init(context) { options -> - - options.sessionReplay.sessionSampleRate = 1.0 - options.sessionReplay.onErrorSampleRate = 1.0 - - // To change default redaction behavior (defaults to true) - options.sessionReplay.redactAllImages = true - options.sessionReplay.redactAllText = true - - // To change quality of the recording (defaults to MEDIUM) - options.sessionReplay.quality = SentryReplayOptions.SentryReplayQuality.MEDIUM // (LOW|MEDIUM|HIGH) - } - ``` +SentryAndroid.init(context) { options -> + + options.sessionReplay.sessionSampleRate = 1.0 + options.sessionReplay.onErrorSampleRate = 1.0 + + // To change default redaction behavior (defaults to true) + options.sessionReplay.redactAllImages = true + options.sessionReplay.redactAllText = true + + // To change quality of the recording (defaults to MEDIUM) + options.sessionReplay.quality = SentryReplayOptions.SentryReplayQuality.MEDIUM // (LOW|MEDIUM|HIGH) +} +``` ### Fixes @@ -1401,16 +2446,16 @@ To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onE ### Fixes - Session Replay: fix various crashes and issues ([#3970](https://github.com/getsentry/sentry-java/pull/3970)) - - Fix `IndexOutOfBoundsException` when tracking window changes - - Fix `IllegalStateException` when adding/removing draw listener for a dead view - - Fix `ConcurrentModificationException` when registering window listeners and stopping `WindowRecorder`/`GestureRecorder` + - Fix `IndexOutOfBoundsException` when tracking window changes + - Fix `IllegalStateException` when adding/removing draw listener for a dead view + - Fix `ConcurrentModificationException` when registering window listeners and stopping `WindowRecorder`/`GestureRecorder` - Add support for setting sentry-native handler_strategy ([#3671](https://github.com/getsentry/sentry-java/pull/3671)) ### Dependencies - Bump Native SDK from v0.7.8 to v0.7.16 ([#3671](https://github.com/getsentry/sentry-java/pull/3671)) - - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0716) - - [diff](https://github.com/getsentry/sentry-native/compare/0.7.8...0.7.16) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0716) + - [diff](https://github.com/getsentry/sentry-native/compare/0.7.8...0.7.16) ## 7.18.1 @@ -1423,7 +2468,7 @@ To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onE ### Features - Android 15: Add support for 16KB page sizes ([#3620](https://github.com/getsentry/sentry-java/pull/3620)) - - See https://developer.android.com/guide/practices/page-sizes for more details + - See https://developer.android.com/guide/practices/page-sizes for more details - Session Replay: Add `beforeSendReplay` callback ([#3855](https://github.com/getsentry/sentry-java/pull/3855)) - Session Replay: Add support for masking/unmasking view containers ([#3881](https://github.com/getsentry/sentry-java/pull/3881)) @@ -1432,14 +2477,14 @@ To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onE - Avoid collecting normal frames ([#3782](https://github.com/getsentry/sentry-java/pull/3782)) - Ensure android initialization process continues even if options configuration block throws an exception ([#3887](https://github.com/getsentry/sentry-java/pull/3887)) - Do not report parsing ANR error when there are no threads ([#3888](https://github.com/getsentry/sentry-java/pull/3888)) - - This should significantly reduce the number of events with message "Sentry Android SDK failed to parse system thread dump..." reported + - This should significantly reduce the number of events with message "Sentry Android SDK failed to parse system thread dump..." reported - Session Replay: Disable replay in session mode when rate limit is active ([#3854](https://github.com/getsentry/sentry-java/pull/3854)) ### Dependencies - Bump Native SDK from v0.7.2 to v0.7.8 ([#3620](https://github.com/getsentry/sentry-java/pull/3620)) - - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#078) - - [diff](https://github.com/getsentry/sentry-native/compare/0.7.2...0.7.8) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#078) + - [diff](https://github.com/getsentry/sentry-native/compare/0.7.2...0.7.8) ## 7.17.0 @@ -1453,8 +2498,8 @@ To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onE - Using MaxBreadcrumb with value 0 no longer crashes. ([#3836](https://github.com/getsentry/sentry-java/pull/3836)) - Accept manifest integer values when requiring floating values ([#3823](https://github.com/getsentry/sentry-java/pull/3823)) - Fix standalone tomcat jndi issue ([#3873](https://github.com/getsentry/sentry-java/pull/3873)) - - Using Sentry Spring Boot on a standalone tomcat caused the following error: - - Failed to bind properties under 'sentry.parsed-dsn' to io.sentry.Dsn + - Using Sentry Spring Boot on a standalone tomcat caused the following error: + - Failed to bind properties under 'sentry.parsed-dsn' to io.sentry.Dsn ## 7.16.0 @@ -1478,7 +2523,7 @@ To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onE ### Breaking changes -- The method `addIntegrationToSdkVersion(Ljava/lang/Class;)V` has been removed from the core (`io.sentry:sentry`) package. Please make sure all of the packages (e.g. `io.sentry:sentry-android-core`, `io.sentry:sentry-android-fragment`, `io.sentry:sentry-okhttp` and others) are all aligned and using the same version to prevent the `NoSuchMethodError` exception. +- The method `addIntegrationToSdkVersion(Ljava/lang/Class;)V` has been removed from the core (`io.sentry:sentry`) package. Please make sure all of the packages (e.g. `io.sentry:sentry-android-core`, `io.sentry:sentry-android-fragment`, `io.sentry:sentry-okhttp` and others) are all aligned and using the same version to prevent the `NoSuchMethodError` exception. ## 7.16.0-alpha.1 @@ -1505,12 +2550,12 @@ To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onE - Add support for `feedback` envelope header item type ([#3687](https://github.com/getsentry/sentry-java/pull/3687)) - Add breadcrumb.origin field ([#3727](https://github.com/getsentry/sentry-java/pull/3727)) - Session Replay: Add options to selectively mask/unmask views captured in replay. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) - - `android:tag="sentry-mask|sentry-unmask"` in XML or `view.setTag("sentry-mask|sentry-unmask")` in code tags - - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "mask|unmask")` in code - - `view.sentryReplayMask()` or `view.sentryReplayUnmask()` extension functions - - mask/unmask `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addMaskViewClass()` or `options.experimental.sessionReplay.addUnmaskViewClass()`. Note, that all of the view subclasses/subtypes will be masked/unmasked as well - - For example, (this is already a default behavior) to mask all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addMaskViewClass("android.widget.TextView")` - - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified + - `android:tag="sentry-mask|sentry-unmask"` in XML or `view.setTag("sentry-mask|sentry-unmask")` in code tags + - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "mask|unmask")` in code + - `view.sentryReplayMask()` or `view.sentryReplayUnmask()` extension functions + - mask/unmask `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addMaskViewClass()` or `options.experimental.sessionReplay.addUnmaskViewClass()`. Note, that all of the view subclasses/subtypes will be masked/unmasked as well + - For example, (this is already a default behavior) to mask all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addMaskViewClass("android.widget.TextView")` + - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified - Session Replay: Support Jetpack Compose masking ([#3739](https://github.com/getsentry/sentry-java/pull/3739)) - To selectively mask/unmask @Composables, use `Modifier.sentryReplayMask()` and `Modifier.sentryReplayUnmask()` modifiers - Session Replay: Mask `WebView`, `VideoView` and `androidx.media3.ui.PlayerView` by default ([#3775](https://github.com/getsentry/sentry-java/pull/3775)) @@ -1524,7 +2569,7 @@ To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onE - Fix potential ANRs due to default integrations ([#3778](https://github.com/getsentry/sentry-java/pull/3778)) - Lazily initialize heavy `SentryOptions` members to avoid ANRs on app start ([#3749](https://github.com/getsentry/sentry-java/pull/3749)) -*Breaking changes*: +_Breaking changes_: - `options.experimental.sessionReplay.errorSampleRate` was renamed to `options.experimental.sessionReplay.onErrorSampleRate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) - Manifest option `io.sentry.session-replay.error-sample-rate` was renamed to `io.sentry.session-replay.on-error-sample-rate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) @@ -1594,15 +2639,15 @@ To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onE import io.sentry.android.core.SentryAndroid SentryAndroid.init(context) { options -> - + // Currently under experimental options: options.experimental.sessionReplay.sessionSampleRate = 1.0 options.experimental.sessionReplay.errorSampleRate = 1.0 - + // To change default redaction behavior (defaults to true) options.experimental.sessionReplay.redactAllImages = true options.experimental.sessionReplay.redactAllText = true - + // To change quality of the recording (defaults to MEDIUM) options.experimental.sessionReplay.quality = SentryReplayOptions.SentryReplayQuality.MEDIUM // (LOW|MEDIUM|HIGH) } @@ -1691,7 +2736,7 @@ To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onE ### Features -- Experimental: Add support for Sentry Developer Metrics ([#3205](https://github.com/getsentry/sentry-java/pull/3205), [#3238](https://github.com/getsentry/sentry-java/pull/3238), [#3248](https://github.com/getsentry/sentry-java/pull/3248), [#3250](https://github.com/getsentry/sentry-java/pull/3250)) +- Experimental: Add support for Sentry Developer Metrics ([#3205](https://github.com/getsentry/sentry-java/pull/3205), [#3238](https://github.com/getsentry/sentry-java/pull/3238), [#3248](https://github.com/getsentry/sentry-java/pull/3248), [#3250](https://github.com/getsentry/sentry-java/pull/3250)) Use the Metrics API to track processing time, download sizes, user signups, and conversion rates and correlate them back to tracing data in order to get deeper insights and solve issues faster. Our API supports counters, distributions, sets, gauges and timers, and it's easy to get started: ```kotlin Sentry.metrics() @@ -1735,8 +2780,8 @@ To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onE - (perf-v2): Calculate frame delay on a span level ([#3197](https://github.com/getsentry/sentry-java/pull/3197)) - Resolve spring properties in @SentryCheckIn annotation ([#3194](https://github.com/getsentry/sentry-java/pull/3194)) - Experimental: Add Spotlight integration ([#3166](https://github.com/getsentry/sentry-java/pull/3166)) - - For more details about Spotlight head over to https://spotlightjs.com/ - - Set `options.isEnableSpotlight = true` to enable Spotlight + - For more details about Spotlight head over to https://spotlightjs.com/ + - Set `options.isEnableSpotlight = true` to enable Spotlight ### Fixes @@ -1750,12 +2795,12 @@ To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onE ### Features - Added App Start profiling - - This depends on the new option `io.sentry.profiling.enable-app-start`, other than the already existing `io.sentry.traces.profiling.sample-rate`. - - Sampler functions can check the new `isForNextAppStart` flag, to adjust startup profiling sampling programmatically. - Relevant PRs: - - Decouple Profiler from Transaction ([#3101](https://github.com/getsentry/sentry-java/pull/3101)) - - Add options and sampling logic ([#3121](https://github.com/getsentry/sentry-java/pull/3121)) - - Add ContentProvider and start profile ([#3128](https://github.com/getsentry/sentry-java/pull/3128)) + - This depends on the new option `io.sentry.profiling.enable-app-start`, other than the already existing `io.sentry.traces.profiling.sample-rate`. + - Sampler functions can check the new `isForNextAppStart` flag, to adjust startup profiling sampling programmatically. + Relevant PRs: + - Decouple Profiler from Transaction ([#3101](https://github.com/getsentry/sentry-java/pull/3101)) + - Add options and sampling logic ([#3121](https://github.com/getsentry/sentry-java/pull/3121)) + - Add ContentProvider and start profile ([#3128](https://github.com/getsentry/sentry-java/pull/3128)) - Extend internal performance collector APIs ([#3102](https://github.com/getsentry/sentry-java/pull/3102)) - Collect slow and frozen frames for spans using `OnFrameMetricsAvailableListener` ([#3111](https://github.com/getsentry/sentry-java/pull/3111)) - Interpolate total frame count to match span duration ([#3158](https://github.com/getsentry/sentry-java/pull/3158)) @@ -1837,8 +2882,9 @@ To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onE ## 7.0.0 Version 7 of the Sentry Android/Java SDK brings a variety of features and fixes. The most notable changes are: + - Bumping `minSdk` level to 19 (Android 4.4) -- The SDK will now listen to connectivity changes and try to re-upload cached events when internet connection is re-established additionally to uploading events on app restart +- The SDK will now listen to connectivity changes and try to re-upload cached events when internet connection is re-established additionally to uploading events on app restart - `Sentry.getSpan` now returns the root transaction, which should improve the span hierarchy and make it leaner - Multiple improvements to reduce probability of the SDK causing ANRs - New `sentry-okhttp` artifact is unbundled from Android and can be used in pure JVM-only apps @@ -1872,8 +2918,8 @@ Similarly, if you have a Sentry SDK (e.g. `sentry-android-core`) dependency on o - `SentryOkHttpUtils` was removed from public API as it's been exposed by mistake ([#3005](https://github.com/getsentry/sentry-java/pull/3005)) - `Scope` now implements the `IScope` interface, therefore some methods like `ScopeCallback.run` accept `IScope` now ([#3066](https://github.com/getsentry/sentry-java/pull/3066)) - Cleanup `startTransaction` overloads ([#2964](https://github.com/getsentry/sentry-java/pull/2964)) - - We have reduced the number of overloads by allowing to pass in a `TransactionOptions` object instead of having separate parameters for certain options - - `TransactionOptions` has defaults set and can be customized, for example: + - We have reduced the number of overloads by allowing to pass in a `TransactionOptions` object instead of having separate parameters for certain options + - `TransactionOptions` has defaults set and can be customized, for example: ```kotlin // old @@ -1886,14 +2932,14 @@ val transaction = Sentry.startTransaction("name", "op", TransactionOptions().app - Android only: `Sentry.getSpan()` returns the root span/transaction instead of the latest span ([#2855](https://github.com/getsentry/sentry-java/pull/2855)) - Capture failed HTTP and GraphQL (Apollo) requests by default ([#2794](https://github.com/getsentry/sentry-java/pull/2794)) - - This can increase your event consumption and may affect your quota, because we will report failed network requests as Sentry events by default, if you're using the `sentry-android-okhttp` or `sentry-apollo-3` integrations. You can customize what errors you want/don't want to have reported for [OkHttp](https://docs.sentry.io/platforms/android/integrations/okhttp#http-client-errors) and [Apollo3](https://docs.sentry.io/platforms/android/integrations/apollo3#graphql-client-errors) respectively. + - This can increase your event consumption and may affect your quota, because we will report failed network requests as Sentry events by default, if you're using the `sentry-android-okhttp` or `sentry-apollo-3` integrations. You can customize what errors you want/don't want to have reported for [OkHttp](https://docs.sentry.io/platforms/android/integrations/okhttp#http-client-errors) and [Apollo3](https://docs.sentry.io/platforms/android/integrations/apollo3#graphql-client-errors) respectively. - Measure AppStart time till First Draw instead of `onResume` ([#2851](https://github.com/getsentry/sentry-java/pull/2851)) - Automatic user interaction tracking: every click now starts a new automatic transaction ([#2891](https://github.com/getsentry/sentry-java/pull/2891)) - - Previously performing a click on the same UI widget twice would keep the existing transaction running, the new behavior now better aligns with other SDKs + - Previously performing a click on the same UI widget twice would keep the existing transaction running, the new behavior now better aligns with other SDKs - Add deadline timeout for automatic transactions ([#2865](https://github.com/getsentry/sentry-java/pull/2865)) - - This affects all automatically generated transactions on Android (UI, clicks), the default timeout is 30s, meaning the automatic transaction will be force-finished with status `deadline_exceeded` when reaching the deadline + - This affects all automatically generated transactions on Android (UI, clicks), the default timeout is 30s, meaning the automatic transaction will be force-finished with status `deadline_exceeded` when reaching the deadline - Set ip_address to {{auto}} by default, even if sendDefaultPII is disabled ([#2860](https://github.com/getsentry/sentry-java/pull/2860)) - - Instead use the "Prevent Storing of IP Addresses" option in the "Security & Privacy" project settings on sentry.io + - Instead use the "Prevent Storing of IP Addresses" option in the "Security & Privacy" project settings on sentry.io - Raw logback message and parameters are now guarded by `sendDefaultPii` if an `encoder` has been configured ([#2976](https://github.com/getsentry/sentry-java/pull/2976)) - The `maxSpans` setting (defaults to 1000) is enforced for nested child spans which means a single transaction can have `maxSpans` number of children (nested or not) at most ([#3065](https://github.com/getsentry/sentry-java/pull/3065)) - The `ScopeCallback` in `withScope` is now always executed ([#3066](https://github.com/getsentry/sentry-java/pull/3066)) @@ -1907,8 +2953,8 @@ val transaction = Sentry.startTransaction("name", "op", TransactionOptions().app ### Features - Observe network state to upload any unsent envelopes ([#2910](https://github.com/getsentry/sentry-java/pull/2910)) - - Android: it works out-of-the-box as part of the default `SendCachedEnvelopeIntegration` - - JVM: you'd have to install `SendCachedEnvelopeFireAndForgetIntegration` as mentioned in https://docs.sentry.io/platforms/java/configuration/#configuring-offline-caching and provide your own implementation of `IConnectionStatusProvider` via `SentryOptions` + - Android: it works out-of-the-box as part of the default `SendCachedEnvelopeIntegration` + - JVM: you'd have to install `SendCachedEnvelopeFireAndForgetIntegration` as mentioned in https://docs.sentry.io/platforms/java/configuration/#configuring-offline-caching and provide your own implementation of `IConnectionStatusProvider` via `SentryOptions` - Add `sentry-okhttp` module to support instrumenting OkHttp in non-Android projects ([#3005](https://github.com/getsentry/sentry-java/pull/3005)) - Do not filter out Sentry SDK frames in case of uncaught exceptions ([#3021](https://github.com/getsentry/sentry-java/pull/3021)) - Do not try to send and drop cached envelopes when rate-limiting is active ([#2937](https://github.com/getsentry/sentry-java/pull/2937)) @@ -1916,16 +2962,16 @@ val transaction = Sentry.startTransaction("name", "op", TransactionOptions().app ### Fixes - Use `getMyMemoryState()` instead of `getRunningAppProcesses()` to retrieve process importance ([#3004](https://github.com/getsentry/sentry-java/pull/3004)) - - This should prevent some app stores from flagging apps as violating their privacy + - This should prevent some app stores from flagging apps as violating their privacy - Reduce flush timeout to 4s on Android to avoid ANRs ([#2858](https://github.com/getsentry/sentry-java/pull/2858)) - Reduce timeout of AsyncHttpTransport to avoid ANR ([#2879](https://github.com/getsentry/sentry-java/pull/2879)) - Do not overwrite UI transaction status if set by the user ([#2852](https://github.com/getsentry/sentry-java/pull/2852)) - Capture unfinished transaction on Scope with status `aborted` in case a crash happens ([#2938](https://github.com/getsentry/sentry-java/pull/2938)) - - This will fix the link between transactions and corresponding crashes, you'll be able to see them in a single trace + - This will fix the link between transactions and corresponding crashes, you'll be able to see them in a single trace - Fix Coroutine Context Propagation using CopyableThreadContextElement ([#2838](https://github.com/getsentry/sentry-java/pull/2838)) - Fix don't overwrite the span status of unfinished spans ([#2859](https://github.com/getsentry/sentry-java/pull/2859)) - Migrate from `default` interface methods to proper implementations in each interface implementor ([#2847](https://github.com/getsentry/sentry-java/pull/2847)) - - This prevents issues when using the SDK on older AGP versions (< 4.x.x) + - This prevents issues when using the SDK on older AGP versions (< 4.x.x) - Reduce main thread work on init ([#3036](https://github.com/getsentry/sentry-java/pull/3036)) - Move Integrations registration to background on init ([#3043](https://github.com/getsentry/sentry-java/pull/3043)) - Fix `SentryOkHttpInterceptor.BeforeSpanCallback` was not finishing span when it was dropped ([#2958](https://github.com/getsentry/sentry-java/pull/2958)) @@ -1944,7 +2990,7 @@ val transaction = Sentry.startTransaction("name", "op", TransactionOptions().app ### Fixes -- Fix SIGSEV, SIGABRT and SIGBUS crashes happening after/around the August Google Play System update, see [#2955](https://github.com/getsentry/sentry-java/issues/2955) for more details (fix provided by Native SDK bump) +- Fix SIGSEV, SIGABRT and SIGBUS crashes happening after/around the August Google Play System update, see [#2955](https://github.com/getsentry/sentry-java/issues/2955) for more details (fix provided by Native SDK bump) - Ensure DSN uses http/https protocol ([#3044](https://github.com/getsentry/sentry-java/pull/3044)) ### Dependencies @@ -1957,7 +3003,7 @@ val transaction = Sentry.startTransaction("name", "op", TransactionOptions().app ### Fixes -- Fix SIGSEV, SIGABRT and SIGBUS crashes happening after/around the August Google Play System update, see [#2955](https://github.com/getsentry/sentry-java/issues/2955) for more details (fix provided by Native SDK bump) +- Fix SIGSEV, SIGABRT and SIGBUS crashes happening after/around the August Google Play System update, see [#2955](https://github.com/getsentry/sentry-java/issues/2955) for more details (fix provided by Native SDK bump) ### Dependencies @@ -2069,15 +3115,15 @@ val transaction = Sentry.startTransaction("name", "op", TransactionOptions().app - Add HTTP response code to Spring WebFlux transactions ([#2870](https://github.com/getsentry/sentry-java/pull/2870)) - Add `sampled` to Dynamic Sampling Context ([#2869](https://github.com/getsentry/sentry-java/pull/2869)) - Improve server side GraphQL support for spring-graphql and Nextflix DGS ([#2856](https://github.com/getsentry/sentry-java/pull/2856)) - - If you have already been using `SentryDataFetcherExceptionHandler` that still works but has been deprecated. Please use `SentryGenericDataFetcherExceptionHandler` combined with `SentryInstrumentation` instead for better error reporting. - - More exceptions and errors caught and reported to Sentry by also looking at the `ExecutionResult` (more specifically its `errors`) - - You may want to filter out certain errors, please see [docs on filtering](https://docs.sentry.io/platforms/java/configuration/filtering/) - - More details for Sentry events: query, variables and response (where possible) - - Breadcrumbs for operation (query, mutation, subscription), data fetchers and data loaders (Spring only) - - Better hub propagation by using `GraphQLContext` + - If you have already been using `SentryDataFetcherExceptionHandler` that still works but has been deprecated. Please use `SentryGenericDataFetcherExceptionHandler` combined with `SentryInstrumentation` instead for better error reporting. + - More exceptions and errors caught and reported to Sentry by also looking at the `ExecutionResult` (more specifically its `errors`) + - You may want to filter out certain errors, please see [docs on filtering](https://docs.sentry.io/platforms/java/configuration/filtering/) + - More details for Sentry events: query, variables and response (where possible) + - Breadcrumbs for operation (query, mutation, subscription), data fetchers and data loaders (Spring only) + - Better hub propagation by using `GraphQLContext` - Add autoconfigure modules for Spring Boot called `sentry-spring-boot` and `sentry-spring-boot-jakarta` ([#2880](https://github.com/getsentry/sentry-java/pull/2880)) - The autoconfigure modules `sentry-spring-boot` and `sentry-spring-boot-jakarta` have a `compileOnly` dependency on `spring-boot-starter` which is needed for our auto installation in [sentry-android-gradle-plugin](https://github.com/getsentry/sentry-android-gradle-plugin) - - The starter modules `sentry-spring-boot-starter` and `sentry-spring-boot-starter-jakarta` now bring `spring-boot-starter` as a dependency + - The starter modules `sentry-spring-boot-starter` and `sentry-spring-boot-starter-jakarta` now bring `spring-boot-starter` as a dependency - You can now disable Sentry by setting the `enabled` option to `false` ([#2840](https://github.com/getsentry/sentry-java/pull/2840)) ### Fixes @@ -2100,6 +3146,7 @@ val transaction = Sentry.startTransaction("name", "op", TransactionOptions().app ## 6.26.0 ### Features + - (Internal) Extend APIs for hybrid SDKs ([#2814](https://github.com/getsentry/sentry-java/pull/2814), [#2846](https://github.com/getsentry/sentry-java/pull/2846)) ### Fixes @@ -2160,13 +3207,13 @@ val transaction = Sentry.startTransaction("name", "op", TransactionOptions().app - Add debouncing mechanism and before-capture callbacks for screenshots and view hierarchies ([#2773](https://github.com/getsentry/sentry-java/pull/2773)) - Improve ANRv2 implementation ([#2792](https://github.com/getsentry/sentry-java/pull/2792)) - Add a proguard rule to keep `ApplicationNotResponding` class from obfuscation - - Add a new option `setReportHistoricalAnrs`; when enabled, it will report all of the ANRs from the [getHistoricalExitReasons](https://developer.android.com/reference/android/app/ActivityManager?hl=en#getHistoricalProcessExitReasons(java.lang.String,%20int,%20int)) list. - By default, the SDK only reports and enriches the latest ANR and only this one counts towards ANR rate. - Worth noting that this option is mainly useful when updating the SDK to the version where ANRv2 has been introduced, to report all ANRs happened prior to the SDK update. After that, the SDK will always pick up the latest ANR from the historical exit reasons list on next app restart, so there should be no historical ANRs to report. - These ANRs are reported with the `HistoricalAppExitInfo` mechanism. - - Add a new option `setAttachAnrThreadDump` to send ANR thread dump from the system as an attachment. - This is only useful as additional information, because the SDK attempts to parse the thread dump into proper threads with stacktraces by default. - - If [ApplicationExitInfo#getTraceInputStream](https://developer.android.com/reference/android/app/ApplicationExitInfo#getTraceInputStream()) returns null, the SDK no longer reports an ANR event, as these events are not very useful without it. + - Add a new option `setReportHistoricalAnrs`; when enabled, it will report all of the ANRs from the [getHistoricalExitReasons]() list. + By default, the SDK only reports and enriches the latest ANR and only this one counts towards ANR rate. + Worth noting that this option is mainly useful when updating the SDK to the version where ANRv2 has been introduced, to report all ANRs happened prior to the SDK update. After that, the SDK will always pick up the latest ANR from the historical exit reasons list on next app restart, so there should be no historical ANRs to report. + These ANRs are reported with the `HistoricalAppExitInfo` mechanism. + - Add a new option `setAttachAnrThreadDump` to send ANR thread dump from the system as an attachment. + This is only useful as additional information, because the SDK attempts to parse the thread dump into proper threads with stacktraces by default. + - If [ApplicationExitInfo#getTraceInputStream]() returns null, the SDK no longer reports an ANR event, as these events are not very useful without it. - Enhance regex patterns for native stackframes ## 6.23.0 @@ -2182,7 +3229,7 @@ import io.sentry.apollo3.sentryTracing val apolloClient = ApolloClient.Builder() .serverUrl("https://example.com/graphql") - .sentryTracing(captureFailedRequests = true) + .sentryTracing(captureFailedRequests = true) .build() ``` @@ -2213,9 +3260,9 @@ val apolloClient = ApolloClient.Builder() ### Features - Introduce new `sentry-android-sqlite` integration ([#2722](https://github.com/getsentry/sentry-java/pull/2722)) - - This integration replaces the old `androidx.sqlite` database instrumentation in the Sentry Android Gradle plugin - - A new capability to manually instrument your `androidx.sqlite` databases. - - You can wrap your custom `SupportSQLiteOpenHelper` instance into `SentrySupportSQLiteOpenHelper(myHelper)` if you're not using the Sentry Android Gradle plugin and still benefit from performance auto-instrumentation. + - This integration replaces the old `androidx.sqlite` database instrumentation in the Sentry Android Gradle plugin + - A new capability to manually instrument your `androidx.sqlite` databases. + - You can wrap your custom `SupportSQLiteOpenHelper` instance into `SentrySupportSQLiteOpenHelper(myHelper)` if you're not using the Sentry Android Gradle plugin and still benefit from performance auto-instrumentation. - Add SentryWrapper for Callable and Supplier Interface ([#2720](https://github.com/getsentry/sentry-java/pull/2720)) - Load sentry-debug-meta.properties ([#2734](https://github.com/getsentry/sentry-java/pull/2734)) - This enables source context for Java @@ -2238,15 +3285,15 @@ val apolloClient = ApolloClient.Builder() - [View Hierarchy](https://docs.sentry.io/platforms/android/enriching-events/viewhierarchy/) support for Jetpack Compose screens - Automatic breadcrumbs for [user interactions](https://docs.sentry.io/platforms/android/performance/instrumentation/automatic-instrumentation/#user-interaction-instrumentation) - More granular http requests instrumentation with a new SentryOkHttpEventListener ([#2659](https://github.com/getsentry/sentry-java/pull/2659)) - - Create spans for time spent on: - - Proxy selection - - DNS resolution - - HTTPS setup - - Connection - - Requesting headers - - Receiving response - - You can attach the event listener to your OkHttpClient through `client.eventListener(new SentryOkHttpEventListener()).addInterceptor(new SentryOkHttpInterceptor()).build();` - - In case you already have an event listener you can use the SentryOkHttpEventListener as well through `client.eventListener(new SentryOkHttpEventListener(myListener)).addInterceptor(new SentryOkHttpInterceptor()).build();` + - Create spans for time spent on: + - Proxy selection + - DNS resolution + - HTTPS setup + - Connection + - Requesting headers + - Receiving response + - You can attach the event listener to your OkHttpClient through `client.eventListener(new SentryOkHttpEventListener()).addInterceptor(new SentryOkHttpInterceptor()).build();` + - In case you already have an event listener you can use the SentryOkHttpEventListener as well through `client.eventListener(new SentryOkHttpEventListener(myListener)).addInterceptor(new SentryOkHttpInterceptor()).build();` - Add a new option to disable `RootChecker` ([#2735](https://github.com/getsentry/sentry-java/pull/2735)) ### Fixes @@ -2267,16 +3314,16 @@ val apolloClient = ApolloClient.Builder() - Add Screenshot and ViewHierarchy to integrations list ([#2698](https://github.com/getsentry/sentry-java/pull/2698)) - New ANR detection based on [ApplicationExitInfo API](https://developer.android.com/reference/android/app/ApplicationExitInfo) ([#2697](https://github.com/getsentry/sentry-java/pull/2697)) - - This implementation completely replaces the old one (based on a watchdog) on devices running Android 11 and above: - - New implementation provides more precise ANR events/ANR rate detection as well as system thread dump information. The new implementation reports ANRs exactly as Google Play Console, without producing false positives or missing important background ANR events. - - New implementation reports ANR events with a new mechanism `mechanism:AppExitInfo`. - - However, despite producing many false positives, the old implementation is capable of better enriching ANR errors (which is not available with the new implementation), for example: - - Capturing screenshots at the time of ANR event; - - Capturing transactions and profiling data corresponding to the ANR event; - - Auxiliary information (such as current memory load) at the time of ANR event. - - If you would like us to provide support for the old approach working alongside the new one on Android 11 and above (e.g. for raising events for slow code on main thread), consider upvoting [this issue](https://github.com/getsentry/sentry-java/issues/2693). - - The old watchdog implementation will continue working for older API versions (Android < 11): - - The old implementation reports ANR events with the existing mechanism `mechanism:ANR`. + - This implementation completely replaces the old one (based on a watchdog) on devices running Android 11 and above: + - New implementation provides more precise ANR events/ANR rate detection as well as system thread dump information. The new implementation reports ANRs exactly as Google Play Console, without producing false positives or missing important background ANR events. + - New implementation reports ANR events with a new mechanism `mechanism:AppExitInfo`. + - However, despite producing many false positives, the old implementation is capable of better enriching ANR errors (which is not available with the new implementation), for example: + - Capturing screenshots at the time of ANR event; + - Capturing transactions and profiling data corresponding to the ANR event; + - Auxiliary information (such as current memory load) at the time of ANR event. + - If you would like us to provide support for the old approach working alongside the new one on Android 11 and above (e.g. for raising events for slow code on main thread), consider upvoting [this issue](https://github.com/getsentry/sentry-java/issues/2693). + - The old watchdog implementation will continue working for older API versions (Android < 11): + - The old implementation reports ANR events with the existing mechanism `mechanism:ANR`. - Open up `TransactionOptions`, `ITransaction` and `IHub` methods allowing consumers modify start/end timestamp of transactions and spans ([#2701](https://github.com/getsentry/sentry-java/pull/2701)) - Send source bundle IDs to Sentry to enable source context ([#2663](https://github.com/getsentry/sentry-java/pull/2663)) - For more information on how to enable source context, please refer to [#633](https://github.com/getsentry/sentry-java/issues/633#issuecomment-1465599120) @@ -2311,7 +3358,7 @@ val apolloClient = ApolloClient.Builder() - Attach Trace Context when an ANR is detected (ANRv1) ([#2583](https://github.com/getsentry/sentry-java/pull/2583)) - Make log4j2 integration compatible with log4j 3.0 ([#2634](https://github.com/getsentry/sentry-java/pull/2634)) - - Instead of relying on package scanning, we now use an annotation processor to generate `Log4j2Plugins.dat` + - Instead of relying on package scanning, we now use an annotation processor to generate `Log4j2Plugins.dat` - Create `User` and `Breadcrumb` from map ([#2614](https://github.com/getsentry/sentry-java/pull/2614)) - Add `sent_at` to envelope header item ([#2638](https://github.com/getsentry/sentry-java/pull/2638)) @@ -2324,12 +3371,13 @@ val apolloClient = ApolloClient.Builder() - Fix aar artifacts publishing for Maven ([#2641](https://github.com/getsentry/sentry-java/pull/2641)) ### Dependencies + - Bump Kotlin compile version from v1.6.10 to 1.8.0 ([#2563](https://github.com/getsentry/sentry-java/pull/2563)) - Bump Compose compile version from v1.1.1 to v1.3.0 ([#2563](https://github.com/getsentry/sentry-java/pull/2563)) - Bump AGP version from v7.3.0 to v7.4.2 ([#2574](https://github.com/getsentry/sentry-java/pull/2574)) - Bump Gradle from v7.6.0 to v8.0.2 ([#2563](https://github.com/getsentry/sentry-java/pull/2563)) - - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v802) - - [diff](https://github.com/gradle/gradle/compare/v7.6.0...v8.0.2) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v802) + - [diff](https://github.com/gradle/gradle/compare/v7.6.0...v8.0.2) - Bump Gradle from v8.0.2 to v8.1.0 ([#2650](https://github.com/getsentry/sentry-java/pull/2650)) - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v810) - [diff](https://github.com/gradle/gradle/compare/v8.0.2...v8.1.0) @@ -2338,7 +3386,7 @@ val apolloClient = ApolloClient.Builder() ### Features -- Add `name` and `geo` to `User` ([#2556](https://github.com/getsentry/sentry-java/pull/2556)) +- Add `name` and `geo` to `User` ([#2556](https://github.com/getsentry/sentry-java/pull/2556)) - Add breadcrumbs on network changes ([#2608](https://github.com/getsentry/sentry-java/pull/2608)) - Add time-to-initial-display and time-to-full-display measurements to Activity transactions ([#2611](https://github.com/getsentry/sentry-java/pull/2611)) - Read integration list written by sentry gradle plugin from manifest ([#2598](https://github.com/getsentry/sentry-java/pull/2598)) @@ -2376,7 +3424,7 @@ val apolloClient = ApolloClient.Builder() - Fix timestamps of slow and frozen frames for profiles ([#2584](https://github.com/getsentry/sentry-java/pull/2584)) - Deprecate reportFullDisplayed in favor of reportFullyDisplayed ([#2585](https://github.com/getsentry/sentry-java/pull/2585)) - Add mechanism for logging integrations and update spring mechanism types ([#2595](https://github.com/getsentry/sentry-java/pull/2595)) - - NOTE: If you're using these mechanism types (`HandlerExceptionResolver`, `SentryWebExceptionHandler`) in your dashboards please update them to use the new types. + - NOTE: If you're using these mechanism types (`HandlerExceptionResolver`, `SentryWebExceptionHandler`) in your dashboards please update them to use the new types. - Filter out session cookies sent by Spring and Spring Boot integrations ([#2593](https://github.com/getsentry/sentry-java/pull/2593)) - We filter out some common cookies like JSESSIONID - We also read the value from `server.servlet.session.cookie.name` and filter it out @@ -2401,9 +3449,9 @@ val apolloClient = ApolloClient.Builder() - Adjust time-to-full-display span if reportFullDisplayed is called too early ([#2550](https://github.com/getsentry/sentry-java/pull/2550)) - Add `enableTracing` option ([#2530](https://github.com/getsentry/sentry-java/pull/2530)) - - This change is backwards compatible. The default is `null` meaning existing behaviour remains unchanged (setting either `tracesSampleRate` or `tracesSampler` enables performance). - - If set to `true`, performance is enabled, even if no `tracesSampleRate` or `tracesSampler` have been configured. - - If set to `false` performance is disabled, regardless of `tracesSampleRate` and `tracesSampler` options. + - This change is backwards compatible. The default is `null` meaning existing behaviour remains unchanged (setting either `tracesSampleRate` or `tracesSampler` enables performance). + - If set to `true`, performance is enabled, even if no `tracesSampleRate` or `tracesSampler` have been configured. + - If set to `false` performance is disabled, regardless of `tracesSampleRate` and `tracesSampler` options. - Detect dependencies by listing MANIFEST.MF files at runtime ([#2538](https://github.com/getsentry/sentry-java/pull/2538)) - Report integrations in use, report packages in use more consistently ([#2179](https://github.com/getsentry/sentry-java/pull/2179)) - Implement `ThreadLocalAccessor` for propagating Sentry hub with reactor / WebFlux ([#2570](https://github.com/getsentry/sentry-java/pull/2570)) @@ -2430,7 +3478,7 @@ val apolloClient = ApolloClient.Builder() ### Features - Add time-to-full-display span to Activity auto-instrumentation ([#2432](https://github.com/getsentry/sentry-java/pull/2432)) -- Add `main` flag to threads and `in_foreground` flag for app contexts ([#2516](https://github.com/getsentry/sentry-java/pull/2516)) +- Add `main` flag to threads and `in_foreground` flag for app contexts ([#2516](https://github.com/getsentry/sentry-java/pull/2516)) ### Fixes @@ -2666,7 +3714,7 @@ val apolloClient = ApolloClient.Builder() ### Features -- Server-Side Dynamic Sampling Context support ([#2226](https://github.com/getsentry/sentry-java/pull/2226)) +- Server-Side Dynamic Sampling Context support ([#2226](https://github.com/getsentry/sentry-java/pull/2226)) ## 6.4.4 @@ -2727,7 +3775,6 @@ val apolloClient = ApolloClient.Builder() - `attach-screenshot` set on Manual init. didn't work ([#2186](https://github.com/getsentry/sentry-java/pull/2186)) - Remove extra space from `spring.factories` causing issues in old versions of Spring Boot ([#2181](https://github.com/getsentry/sentry-java/pull/2181)) - ### Features - Bump Native SDK to v0.4.18 ([#2154](https://github.com/getsentry/sentry-java/pull/2154)) @@ -2762,6 +3809,7 @@ val apolloClient = ApolloClient.Builder() - Add sample rate to baggage as well as trace in envelope header and flatten user ([#2135](https://github.com/getsentry/sentry-java/pull/2135)) Breaking Changes: + - The boolean parameter `samplingDecision` in the `TransactionContext` constructor has been replaced with a `TracesSamplingDecision` object. Feel free to ignore the `@ApiStatus.Internal` in this case. ## 6.1.4 @@ -2825,19 +3873,19 @@ Breaking Changes: - Add Android profiling traces ([#1897](https://github.com/getsentry/sentry-java/pull/1897)) ([#1959](https://github.com/getsentry/sentry-java/pull/1959)) and its tests ([#1949](https://github.com/getsentry/sentry-java/pull/1949)) - Enable enableScopeSync by default for Android ([#1928](https://github.com/getsentry/sentry-java/pull/1928)) - Feat: Vendor JSON ([#1554](https://github.com/getsentry/sentry-java/pull/1554)) - - Introduce `JsonSerializable` and `JsonDeserializer` interfaces for manual json - serialization/deserialization. - - Introduce `JsonUnknwon` interface to preserve unknown properties when deserializing/serializing - SDK classes. - - When passing custom objects, for example in `Contexts`, these are supported for serialization: - - `JsonSerializable` - - `Map`, `Collection`, `Array`, `String` and all primitive types. - - Objects with the help of refection. - - `Map`, `Collection`, `Array`, `String` and all primitive types. - - Call `toString()` on objects that have a cyclic reference to a ancestor object. - - Call `toString()` where object graphs exceed max depth. - - Remove `gson` dependency. - - Remove `IUnknownPropertiesConsumer` + - Introduce `JsonSerializable` and `JsonDeserializer` interfaces for manual json + serialization/deserialization. + - Introduce `JsonUnknwon` interface to preserve unknown properties when deserializing/serializing + SDK classes. + - When passing custom objects, for example in `Contexts`, these are supported for serialization: + - `JsonSerializable` + - `Map`, `Collection`, `Array`, `String` and all primitive types. + - Objects with the help of refection. + - `Map`, `Collection`, `Array`, `String` and all primitive types. + - Call `toString()` on objects that have a cyclic reference to a ancestor object. + - Call `toString()` where object graphs exceed max depth. + - Remove `gson` dependency. + - Remove `IUnknownPropertiesConsumer` - Pass MDC tags as Sentry tags ([#1954](https://github.com/getsentry/sentry-java/pull/1954)) ### Fixes @@ -2879,7 +3927,7 @@ Breaking Changes: ### Fixes -* Change order of event filtering mechanisms and only send session update for dropped events if session state changed (#2028) +- Change order of event filtering mechanisms and only send session update for dropped events if session state changed (#2028) ## 5.7.3 @@ -3273,7 +4321,6 @@ Thank you: ### Fixes - - Ref: Deprecate SentryBaseEvent#getOriginThrowable and add SentryBaseEvent#getThrowableMechanism ([#1502](https://github.com/getsentry/sentry-java/pull/1502)) - Graceful Shutdown flushes event instead of Closing SDK ([#1500](https://github.com/getsentry/sentry-java/pull/1500)) - Do not append threads that come from the EnvelopeFileObserver ([#1501](https://github.com/getsentry/sentry-java/pull/1501)) @@ -3432,6 +4479,7 @@ Breaking Changes: - SentryTransaction#finish should not clear another transaction from the scope ([#1278](https://github.com/getsentry/sentry-java/pull/1278)) Breaking Changes: + - Enchancement: SentryExceptionResolver should not send handled errors by default ([#1248](https://github.com/getsentry/sentry-java/pull/1248)). - Ref: Simplify RestTemplate instrumentation ([#1246](https://github.com/getsentry/sentry-java/pull/1246)) - Enchancement: Add overloads for startTransaction taking op and description ([#1244](https://github.com/getsentry/sentry-java/pull/1244)) @@ -3585,7 +4633,7 @@ This release brings the Sentry Performance feature to Java SDK, Spring, Spring B - Set current thread only if theres no exceptions ([#1064](https://github.com/getsentry/sentry-java/pull/1064)) - Append DebugImage list if event already has it ([#1092](https://github.com/getsentry/sentry-java/pull/1092)) - Sort breadcrumbs by Date if there are breadcrumbs already in the event ([#1094](https://github.com/getsentry/sentry-java/pull/1094)) -- Free Local Refs manually due to Android local ref. count limits ([#1179](https://github.com/getsentry/sentry-java/pull/1179)) +- Free Local Refs manually due to Android local ref. count limits ([#1179](https://github.com/getsentry/sentry-java/pull/1179)) ## 3.2.0 @@ -3706,6 +4754,7 @@ Packages were released on [`bintray sentry-java`](https://dl.bintray.com/getsent ## Where is the Java 1.7 code base? The previous Java releases, are all available in this repository through the tagged releases. + ## 3.0.0-beta.1 ## What’s Changed @@ -3745,7 +4794,7 @@ TBD Packages were released on [bintray](https://dl.bintray.com/getsentry/maven/io/sentry/) > Note: This release marks the unification of the Java and Android Sentry codebases based on the core of the Android SDK (version 2.x). -Previous releases for the Android SDK (version 2.x) can be found on the now archived: https://github.com/getsentry/sentry-android/ +> Previous releases for the Android SDK (version 2.x) can be found on the now archived: https://github.com/getsentry/sentry-android/ ## 3.0.0-alpha.1 @@ -3753,7 +4802,6 @@ Previous releases for the Android SDK (version 2.x) can be found on the now arch ### Fixes - ## New releases will happen on a different repository: https://github.com/getsentry/sentry-java @@ -3764,7 +4812,6 @@ https://github.com/getsentry/sentry-java ### Fixes - - feat: enable release health by default Packages were released on [`bintray`](https://dl.bintray.com/getsentry/sentry-android/io/sentry/sentry-android/), [`jcenter`](https://jcenter.bintray.com/io/sentry/sentry-android/) and [`mavenCentral`](https://repo.maven.apache.org/maven2/io/sentry/sentry-android/) @@ -3848,15 +4895,15 @@ We'd love to get feedback. - feat: timber integration ([#464](https://github.com/getsentry/sentry-android/pull/464)) @marandaneto -1) To add integrations it requires a [manual initialization](https://docs.sentry.io/platforms/android/#manual-initialization) of the Android SDK. +1. To add integrations it requires a [manual initialization](https://docs.sentry.io/platforms/android/#manual-initialization) of the Android SDK. -2) Add the `sentry-android-timber` dependency: +2. Add the `sentry-android-timber` dependency: ```groovy implementation 'io.sentry:sentry-android-timber:{version}' // version >= 2.2.0 ``` -3) Initialize and add the `SentryTimberIntegration`: +3. Initialize and add the `SentryTimberIntegration`: ```java SentryAndroid.init(this, options -> { @@ -3870,7 +4917,7 @@ SentryAndroid.init(this, options -> { }); ``` -4) Use the Timber integration: +4. Use the Timber integration: ```java try { @@ -4159,8 +5206,8 @@ New features not offered by (1.7.x): - Captures crashes caused by native code - Access to the [`sentry-native` SDK](https://github.com/getsentry/sentry-native/) API by your native (C/C++/Rust code/..). - Automatic init (just add your `DSN` to the manifest) - - Proguard rules are added automatically - - Permission (Internet) is added automatically + - Proguard rules are added automatically + - Permission (Internet) is added automatically - Uncaught Exceptions might be captured even before the app restarts - Sentry's Unified API. - More context/device information @@ -4221,7 +5268,6 @@ Release of Sentry's new SDK for Android. ### Fixes - - Update ndk for new sentry-native version ([#235](https://github.com/getsentry/sentry-android/pull/235)) @Swatinem @marandaneto - Make integrations public ([#256](https://github.com/getsentry/sentry-android/pull/256)) @marandaneto - Bump build-tools ([#255](https://github.com/getsentry/sentry-android/pull/255)) @marandaneto @@ -4259,7 +5305,6 @@ Release of Sentry's new SDK for Android. ### Fixes - - Honor RetryAfter ([#236](https://github.com/getsentry/sentry-android/pull/236)) @marandaneto - Add tests for SentryValues ([#238](https://github.com/getsentry/sentry-android/pull/238)) @philipphofmann - Do not set frames if there's none ([#234](https://github.com/getsentry/sentry-android/pull/234)) @marandaneto @@ -4360,7 +5405,7 @@ Third release of Sentry's new SDK for Android. ### Fixes -- Fixed release for jcenter and bintray +- Fixed release for jcenter and bintray Packages were released on [`bintray`](https://dl.bintray.com/getsentry/sentry-android/io/sentry/), [`jcenter`](https://jcenter.bintray.com/io/sentry/sentry-android/) @@ -4390,8 +5435,8 @@ New features not offered by our current (1.7.x), stable SDK are: - Captures crashes caused by native code - Access to the [`sentry-native` SDK](https://github.com/getsentry/sentry-native/) API by your native (C/C++/Rust code/..). - Automatic init (just add your `DSN` to the manifest) - - Proguard rules are added automatically - - Permission (Internet) is added automatically + - Proguard rules are added automatically + - Permission (Internet) is added automatically - Uncaught Exceptions might be captured even before the app restarts - Unified API which include scopes etc. - More context/device information diff --git a/CLAUDE.md b/CLAUDE.md index 9de4130c1a7..f59e5a152f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,156 +1,9 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## STOP — Required Reading (Do This First) -## Project Overview - -This is the Sentry Java/Android SDK - a comprehensive error monitoring and performance tracking SDK for Java and Android applications. The repository contains multiple modules for different integrations and platforms. - -## Build System - -The project uses **Gradle** with Kotlin DSL. Key build files: -- `build.gradle.kts` - Root build configuration -- `settings.gradle.kts` - Multi-module project structure -- `buildSrc/` and `build-logic/` - Custom build logic and plugins -- `Makefile` - High-level build commands - -## Essential Commands - -### Development Workflow -```bash -# Format code and regenerate .api files (REQUIRED before committing) -./gradlew spotlessApply apiDump - -# Run all tests and linter -./gradlew check - -# Build entire project -./gradlew build - -# Create coverage reports -./gradlew jacocoTestReport koverXmlReportRelease - -# Generate documentation -./gradlew aggregateJavadocs -``` - -### Testing -```bash -# Run unit tests for a specific file -./gradlew '::testDebugUnitTest' --tests="**" --info - -# Run system tests (requires Python virtual env) -make systemTest - -# Run specific test suites -./gradlew :sentry-android-core:testDebugUnitTest -./gradlew :sentry:test -``` - -### Code Quality -```bash -# Check code formatting -./gradlew spotlessJavaCheck spotlessKotlinCheck - -# Apply code formatting -./gradlew spotlessApply - -# Update API dump files (after API changes) -./gradlew apiDump - -# Dependency updates check -./gradlew dependencyUpdates -Drevision=release -``` - -### Android-Specific Commands -```bash -# Assemble Android test APKs -./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease -./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest -DtestBuildType=release - -# Run critical UI tests -./scripts/test-ui-critical.sh -``` - -## Development Workflow Rules - -### Planning and Implementation Process -1. **First think through the problem**: Read the codebase for relevant files and propose a plan -2. **Check in before beginning**: Verify the plan before starting implementation -3. **Use todo tracking**: Work through todo items, marking them as complete as you go -4. **High-level communication**: Give high-level explanations of changes made, not step-by-step descriptions -5. **Simplicity first**: Make every task and code change as simple as possible. Avoid massive or complex changes. Impact as little code as possible. -6. **Format and regenerate**: Once done, format code and regenerate .api files: `./gradlew spotlessApply apiDump` -7. **Propose commit**: As final step, git stage relevant files and propose (but not execute) a single git commit command - -## Module Architecture - -The repository is organized into multiple modules: - -### Core Modules -- **`sentry`** - Core Java SDK implementation -- **`sentry-android-core`** - Core Android SDK implementation -- **`sentry-android`** - High-level Android SDK - -### Integration Modules -- **Spring Framework**: `sentry-spring*`, `sentry-spring-boot*` -- **Logging**: `sentry-logback`, `sentry-log4j2`, `sentry-jul` -- **Web**: `sentry-servlet*`, `sentry-okhttp`, `sentry-apache-http-client-5` -- **GraphQL**: `sentry-graphql*`, `sentry-apollo*` -- **Android UI**: `sentry-android-fragment`, `sentry-android-navigation`, `sentry-compose` -- **Reactive**: `sentry-reactor`, `sentry-ktor-client` -- **Monitoring**: `sentry-opentelemetry*`, `sentry-quartz` - -### Utility Modules -- **`sentry-test-support`** - Shared test utilities -- **`sentry-system-test-support`** - System testing infrastructure -- **`sentry-samples`** - Example applications -- **`sentry-bom`** - Bill of Materials for dependency management - -### Key Architectural Patterns -- **Multi-platform**: Supports JVM, Android, and Kotlin Multiplatform (Compose modules) -- **Modular Design**: Each integration is a separate module with minimal dependencies -- **Options Pattern**: Features are opt-in via `SentryOptions` and similar configuration classes -- **Transport Layer**: Pluggable transport implementations for different environments -- **Scope Management**: Thread-safe scope/context management for error tracking - -## Development Guidelines - -### Code Style -- **Languages**: Java 8+ and Kotlin -- **Formatting**: Enforced via Spotless - always run `./gradlew spotlessApply` before committing -- **API Compatibility**: Binary compatibility is enforced - run `./gradlew apiDump` after API changes - -### Testing Requirements -- Write comprehensive unit tests for new features -- Android modules require both unit tests and instrumented tests where applicable -- System tests validate end-to-end functionality with sample applications -- Coverage reports are generated for both JaCoCo (Java/Android) and Kover (KMP modules) - -### Contributing Guidelines -1. Follow existing code style and language -2. Do not modify API files (e.g. sentry.api) manually - run `./gradlew apiDump` to regenerate them -3. Write comprehensive tests -4. New features must be **opt-in by default** - extend `SentryOptions` or similar Option classes with getters/setters -5. Consider backwards compatibility - -## Domain-Specific Knowledge Areas - -For complex SDK functionality, refer to the detailed cursor rules in `.cursor/rules/`: - -- **Scopes and Hub Management**: See `.cursor/rules/scopes.mdc` for details on `IScopes`, scope types (global/isolation/current), thread-local storage, forking behavior, and v7→v8 migration patterns -- **Event Deduplication**: See `.cursor/rules/deduplication.mdc` for `DuplicateEventDetectionEventProcessor` and `enableDeduplication` option -- **Offline Behavior and Caching**: See `.cursor/rules/offline.mdc` for envelope caching, retry logic, transport behavior, and Android vs JVM differences -- **OpenTelemetry Integration**: See `.cursor/rules/opentelemetry.mdc` for agent vs agentless modes, span processing, context propagation, and configuration -- **System Testing (E2E)**: See `.cursor/rules/e2e_tests.mdc` for system test framework, mock server setup, and CI workflows - -### Usage Pattern -When working on these specific areas, read the corresponding cursor rule file first to understand the detailed architecture, then proceed with implementation. - -## Useful Resources - -- Main SDK documentation: https://develop.sentry.dev/sdk/overview/ -- Internal contributing guide: https://docs.sentry.io/internal/contributing/ -- Git commit message conventions: https://develop.sentry.dev/engineering-practices/commit-messages/ - -This SDK is production-ready and used by thousands of applications. Changes should be thoroughly tested and maintain backwards compatibility. \ No newline at end of file +Before doing ANYTHING else (including answering questions), you MUST use the Read tool to load +[AGENTS.md](AGENTS.md) and follow ALL of its instructions. It is the single source of truth +for build commands, contributing guidelines, workflow rules, and the index of the +domain-specific rules. +Do NOT skip this step. Do NOT proceed without reading it first. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e2c8b78bf1..f4354c72a89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,7 +57,19 @@ or However, if your change did not intend to modify the public API, consider changing the method/property visibility or removing the change altogether. +# Linking issues + +If a PR should notify a linked issue after release, use a GitHub closing keyword in the PR +description, such as `Fixes #123`, `Closes #123`, or `Resolves #123`. Release notification +automation only comments on issues GitHub recognizes as closed by the released PR; mentioning an +issue without a closing keyword is not enough. + # CI Build and tests are automatically run against branches and pull requests via GH Actions. + + +# AI Use + +You are welcome to use whatever tools you prefer for making a contribution. However, any changes you propose have to be reviewed and tested by you, a human, first, before you submit a pull request with them for the Sentry team to review. If we feel like that did not happen, we will close the PR outright. For example, we will not review visibly AI-generated PRs from an agent instructed to look for and "fix" open issues in the repo. This aligns with our SDK principle: [every line has an owner](https://develop.sentry.dev/sdk/getting-started/principles/#every-line-has-an-owner). diff --git a/Makefile b/Makefile index 55f465a9663..3967ff856ad 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ -.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleBenchmarkTestRelease assembleUiTestRelease assembleUiTestCriticalRelease createCoverageReports runUiTestCritical setupPython systemTest systemTestInteractive check preMerge publish +.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleBenchmarkTestRelease assembleUiTestRelease assembleUiTestCriticalRelease runUiTestCritical setupPython systemTest systemTestInteractive check preMerge publish -all: stop clean javadocs compile createCoverageReports +all: stop clean javadocs compile assembleBenchmarks: assembleBenchmarkTestRelease assembleUiTests: assembleUiTestRelease -preMerge: check createCoverageReports +preMerge: check publish: clean dryRelease # deep clean @@ -37,13 +37,11 @@ api: # Assemble release and Android test apk of the uitest-android-benchmark module assembleBenchmarkTestRelease: - ./gradlew :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleRelease - ./gradlew :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleAndroidTest -DtestBuildType=release + ./gradlew :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleRelease :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleAndroidTest # Assemble release and Android test apk of the uitest-android module assembleUiTestRelease: - ./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease - ./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest -DtestBuildType=release + ./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest # Assemble release of the uitest-android-critical module assembleUiTestCriticalRelease: @@ -53,13 +51,6 @@ assembleUiTestCriticalRelease: runUiTestCritical: ./scripts/test-ui-critical.sh -# Create coverage reports -# - Jacoco for Java & Android modules -# - Kover for KMP modules e.g sentry-compose -createCoverageReports: - ./gradlew jacocoTestReport - ./gradlew koverXmlReportRelease - # Create the Python virtual environment for system tests, and install the necessary dependencies setupPython: @test -d .venv || python3 -m venv .venv diff --git a/README.md b/README.md index 096ecf7d19a..849aaf74457 100644 --- a/README.md +++ b/README.md @@ -13,55 +13,65 @@ _Bad software is everywhere, and we're tired of it. Sentry is on a mission to he Sentry SDK for Java and Android =========== [![GH Workflow](https://img.shields.io/github/actions/workflow/status/getsentry/sentry-java/build.yml?branch=main)](https://github.com/getsentry/sentry-java/actions) -[![codecov](https://codecov.io/gh/getsentry/sentry-java/branch/main/graph/badge.svg)](https://codecov.io/gh/getsentry/sentry-java) +[![X Follow](https://img.shields.io/twitter/follow/sentry?label=sentry&style=social)](https://x.com/intent/follow?screen_name=sentry) [![Discord Chat](https://img.shields.io/discord/621778831602221064?logo=discord&logoColor=ffffff&color=7389D8)](https://discord.gg/PXa5Apfe7K) -| Packages | Maven Central | Minimum Android API Version | -|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------- | -| sentry-android | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android) | 21 | -| sentry-android-core | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-core) | 21 | -| sentry-android-ndk | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-ndk/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-ndk) | 21 | -| sentry-android-timber | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-timber/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-timber) | 21 | -| sentry-android-fragment | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-fragment/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-fragment) | 21 | -| sentry-android-navigation | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-navigation/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-navigation) | 21 | -| sentry-android-sqlite | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-sqlite/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-sqlite) | 21 | -| sentry-android-replay | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-replay/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-replay) | 26 | -| sentry-compose-android | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-compose-android/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-compose-android) | 21 | -| sentry-compose-desktop | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-compose-desktop/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-compose-desktop) | -| sentry-compose | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-compose/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-compose) | -| sentry-apache-http-client-5 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apache-http-client-5/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apache-http-client-5) | -| sentry | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry) | 21 | -| sentry-jul | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-jul/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-jul) | -| sentry-jdbc | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-jdbc/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-jdbc) | -| sentry-apollo | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apollo/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apollo) | 21 | -| sentry-apollo-3 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apollo-3/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apollo-3) | 21 | -| sentry-apollo-4 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apollo-4/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apollo-4) | 21 | -| sentry-kotlin-extensions | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-kotlin-extensions/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-kotlin-extensions) | 21 | -| sentry-ktor-client | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-ktor-client/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-ktor-client) | 21 | -| sentry-servlet | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-servlet/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-servlet) | | -| sentry-servlet-jakarta | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-servlet-jakarta/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-servlet-jakarta) | | -| sentry-spring-boot | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot) | -| sentry-spring-boot-jakarta | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-jakarta/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-jakarta) | -| sentry-spring-boot-4 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-4/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-4) | -| sentry-spring-boot-4-starter | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-4-starter/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-4-starter) | -| sentry-spring-boot-starter | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-starter/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-starter) | -| sentry-spring-boot-starter-jakarta | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-starter-jakarta/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-starter-jakarta) | -| sentry-spring | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring) | -| sentry-spring-jakarta | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-jakarta/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-jakarta) | -| sentry-spring-7 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-7/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-7) | -| sentry-logback | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-logback/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-logback) | -| sentry-log4j2 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-log4j2/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-log4j2) | -| sentry-bom | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-bom/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-bom) | -| sentry-graphql | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql) | -| sentry-graphql-core | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql-core) | -| sentry-graphql-22 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql-22/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql-22) | -| sentry-quartz | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-quartz/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-quartz) | -| sentry-openfeign | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-openfeign/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-openfeign) | -| sentry-opentelemetry-agent | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agent/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agent) | -| sentry-opentelemetry-agentcustomization | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agentcustomization/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agentcustomization) | -| sentry-opentelemetry-core | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core) | -| sentry-okhttp | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp) | -| sentry-reactor | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-reactor/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-reactor) | +| Packages | Maven Central | Minimum Android API Version | +|-----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| ------- | +| sentry-android | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android) | 21 | +| sentry-android-core | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-core?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-core) | 21 | +| sentry-android-distribution | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-distribution?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-distribution) | 21 | +| sentry-android-ndk | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-ndk?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-ndk) | 21 | +| sentry-android-timber | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-timber?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-timber) | 21 | +| sentry-android-fragment | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-fragment?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-fragment) | 21 | +| sentry-android-navigation | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-navigation?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-navigation) | 21 | +| sentry-android-sqlite | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-sqlite?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-sqlite) | 21 | +| sentry-android-replay | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-android-replay?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-android-replay) | 26 | +| sentry-compose-android | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-compose-android?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-compose-android) | 21 | +| sentry-compose-desktop | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-compose-desktop?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-compose-desktop) | +| sentry-compose | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-compose?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-compose) | +| sentry-apache-http-client-5 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apache-http-client-5?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apache-http-client-5) | +| sentry | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry) | 21 | +| sentry-jul | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-jul?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-jul) | +| sentry-jdbc | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-jdbc?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-jdbc) | +| sentry-kafka | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-kafka?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-kafka) | +| sentry-apollo | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apollo?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apollo) | 21 | +| sentry-apollo-3 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apollo-3?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apollo-3) | 21 | +| sentry-apollo-4 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-apollo-4?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-apollo-4) | 21 | +| sentry-kotlin-extensions | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-kotlin-extensions?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-kotlin-extensions) | 21 | +| sentry-ktor-client | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-ktor-client?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-ktor-client) | 21 | +| sentry-servlet | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-servlet?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-servlet) | | +| sentry-servlet-jakarta | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-servlet-jakarta?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-servlet-jakarta) | | +| sentry-spring-boot | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-spring-boot?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-spring-boot) | +| sentry-spring-boot-jakarta | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-spring-boot-jakarta?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-spring-boot-jakarta) | +| sentry-spring-boot-4 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-spring-boot-4?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-spring-boot-4) | +| sentry-spring-boot-4-starter | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-spring-boot-4-starter?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-spring-boot-4-starter) | +| sentry-spring-boot-starter | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-spring-boot-starter?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-spring-boot-starter) | +| sentry-spring-boot-starter-jakarta | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-spring-boot-starter-jakarta?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-spring-boot-starter-jakarta) | +| sentry-spring | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-spring?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-spring) | +| sentry-spring-jakarta | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-spring-jakarta?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-spring-jakarta) | +| sentry-spring-7 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-spring-7?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-spring-7) | +| sentry-logback | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-logback?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-logback) | +| sentry-log4j2 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-log4j2?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-log4j2) | +| sentry-bom | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-bom?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-bom) | +| sentry-graphql | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-graphql?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-graphql) | +| sentry-graphql-core | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-graphql-core?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-graphql-core) | +| sentry-graphql-22 | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-graphql-22?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-graphql-22) | +| sentry-jcache | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-jcache?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-jcache) | +| sentry-quartz | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-quartz?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-quartz) | +| sentry-openfeign | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-openfeign?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-openfeign) | +| sentry-openfeature | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-openfeature?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-openfeature) | +| sentry-launchdarkly-android | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-launchdarkly-android?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-launchdarkly-android) | +| sentry-launchdarkly-server | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-launchdarkly-server?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-launchdarkly-server) | +| sentry-opentelemetry-agent | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-agent?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-opentelemetry-agent) | +| sentry-opentelemetry-bom | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-bom?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-opentelemetry-bom) | +| sentry-opentelemetry-agentcustomization | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-agentcustomization?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-opentelemetry-agentcustomization) | +| sentry-opentelemetry-core | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-core?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-opentelemetry-core) | +| sentry-opentelemetry-otlp | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-otlp?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-opentelemetry-otlp) | +| sentry-opentelemetry-otlp-spring | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-otlp-spring?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-opentelemetry-otlp-spring) | +| sentry-okhttp | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-okhttp?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-okhttp) | +| sentry-reactor | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-reactor?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-reactor) | +| sentry-spotlight | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-spotlight?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-spotlight) | # Releases diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000000..a0040916145 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,598 @@ +# Third-Party Software Notices and Information + +The Sentry Java SDK distribution includes software developed by third parties which carry their own copyright notices and license terms. These notices are provided below. + +In the event that a required notice is missing or incorrect, please inform us by creating an issue [here](https://github.com/getsentry/sentry-java/issues). + +--- + +## Google GSON (Apache 2.0) + +**Source:** https://github.com/google/gson (Tag: gson-parent-2.8.7)
+**License:** Apache License 2.0
+**Copyright:** Copyright (C) 2010 Google Inc. + +### Scope + +The Sentry Java SDK includes vendored JSON stream reading and writing classes extracted from the GSON library. The code resides in the `io.sentry.vendor.gson.stream` package and includes `JsonReader`, `JsonWriter`, `JsonScope`, `JsonToken`, and `MalformedJsonException`. + +``` +Copyright (C) 2010 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## Google Guava — LongMath (Apache 2.0) + +**Source:** https://github.com/google/guava/blob/v33.0.0/guava/src/com/google/common/math/LongMath.java
+**License:** Apache License 2.0
+**Copyright:** Copyright (C) 2011 The Guava Authors + +### Scope + +The Sentry Java SDK includes adapted floor division logic from Guava's `LongMath` class to support older Android API levels. The code resides in `io.sentry.vendor.SentryMath`. + +``` +Copyright (C) 2011 The Guava Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## FasterXML Jackson — ISO8601Utils (Apache 2.0) + +**Source:** https://github.com/FasterXML/jackson-databind
+**License:** Apache License 2.0
+**Copyright:** Copyright (C) 2007-, Tatu Saloranta + +### Scope + +The Sentry Java SDK includes an adapted version of `ISO8601Utils` from the Jackson Databind library for ISO 8601 date/time parsing and formatting. The code resides in `io.sentry.vendor.gson.internal.bind.util.ISO8601Utils`. + +``` +Copyright (C) 2007-, Tatu Saloranta + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## Howard Hinnant — Date Algorithms (Public Domain) + +**Source:** https://howardhinnant.github.io/date_algorithms.html
+**License:** Public Domain
+**Copyright:** None; public domain dedication by Howard Hinnant + +### Scope + +The Sentry Java SDK includes adapted civil date conversion algorithms from Howard Hinnant's date algorithms for UTC ISO 8601 timestamp parsing and formatting. The code resides in `io.sentry.vendor.SentryIso8601Utils`. + +``` +Consider these donated to the public domain. +``` + +--- + +## Android Open Source Project — Base64 (Apache 2.0) + +**Source:** https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/Base64.java
+**License:** Apache License 2.0
+**Copyright:** Copyright (C) 2010 The Android Open Source Project + +### Scope + +The Sentry Java SDK includes an adapted version of the Android `Base64` class for Base64 encoding and decoding on non-Android platforms. The code resides in `io.sentry.vendor.Base64`. + +``` +Copyright (C) 2010 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## Square — Tape (Apache 2.0) + +**Source:** https://github.com/square/tape (Commit: 445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8, archived 2024-10-25)
+**License:** Apache License 2.0
+**Copyright:** Copyright (C) 2010 Square, Inc. + +### Scope + +The Sentry Java SDK includes an adapted version of Square's Tape library, a file-based FIFO queue implementation used for reliable event storage. The code resides in the `io.sentry.cache.tape` package and includes `QueueFile`, `FileObjectQueue`, and `ObjectQueue`. + +Upstream was archived on 2024-10-25 and is no longer maintained. This copy is maintained in-tree and has diverged from the linked commit: it recovers from file corruption by recreating the file, bounds the queue to a maximum number of elements, and supports optional buffered writes flushed by an explicit `sync()`. + +``` +Copyright (C) 2010 Square, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## Square — Seismic (Apache 2.0) + +**Source:** https://github.com/square/seismic
+**License:** Apache License 2.0
+**Copyright:** Copyright 2010 Square, Inc. + +### Scope + +The Sentry Java SDK includes an adapted version of Square's Seismic shake detection algorithm. The rolling sample window approach and `SampleQueue`/`SamplePool` data structures in `io.sentry.android.core.SentryShakeDetector` are based on Seismic's `ShakeDetector`. + +``` +Copyright 2010 Square, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## Square — Curtains (Apache 2.0) + +**Source:** https://github.com/square/curtains (v1.2.5)
+**License:** Apache License 2.0
+**Copyright:** Copyright 2021 Square Inc. + +### Scope + +The Sentry Java SDK includes adapted versions of Square's Curtains library for null-safe `Window.Callback` handling and for tracking attached window roots. The code resides in `io.sentry.android.replay.util.FixedWindowCallback` and `io.sentry.android.replay.Windows`. + +``` +Copyright 2021 Square Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## Apache Commons Collections (Apache 2.0) + +**Source:** https://github.com/apache/commons-collections
+**License:** Apache License 2.0
+**Copyright:** Copyright The Apache Software Foundation + +### Scope + +The Sentry Java SDK includes adapted versions of `CircularFifoQueue`, `SynchronizedCollection`, and `SynchronizedQueue` from Apache Commons Collections. The code resides in `io.sentry.CircularFifoQueue`, `io.sentry.SynchronizedCollection`, and `io.sentry.SynchronizedQueue`. + +``` +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## Matej Tymes — JavaFixes (Apache 2.0) + +**Source:** https://github.com/MatejTymes/JavaFixes (Commit: 37e74b9d0a29f7a47485c6d1bb1307f01fb93634)
+**License:** Apache License 2.0
+**Copyright:** Copyright (C) 2016 Matej Tymes + +### Scope + +The Sentry Java SDK includes an adapted version of `ReusableCountLatch` from the JavaFixes library for concurrent synchronization. The code resides in `io.sentry.transport.ReusableCountLatch`. + +``` +Copyright (C) 2016 Matej Tymes + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## Baomidou — Dynamic-Datasource (Apache 2.0) + +**Source:** https://github.com/baomidou/dynamic-datasource
+**License:** Apache License 2.0
+**Copyright:** Copyright © 2018 organization baomidou + +### Scope + +The Sentry Java SDK includes an adapted UUID generation implementation from the Dynamic-Datasource library. The code resides in `io.sentry.util.UUIDGenerator`. + +``` +Copyright © 2018 organization baomidou + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## Google Firebase — Android SDK (Apache 2.0) + +**Source:** https://github.com/firebase/firebase-android-sdk
+**License:** Apache License 2.0
+**Copyright:** Copyright 2022 Google LLC + +### Scope + +The Sentry Java SDK includes an adapted version of `FirstDrawDoneListener` from the Firebase Android SDK for detecting initial display time via `OnDrawListener`. The code resides in `io.sentry.android.core.internal.util.FirstDrawDoneListener`. + +``` +Copyright 2022 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## Android Open Source Project — Thread Dump Parsing (Apache 2.0) + +**Source:** https://cs.android.com/android/platform/superproject/+/master:development/tools/bugreport/src/com/android/bugreport/stacks/ThreadSnapshotParser.java
+**License:** Apache License 2.0
+**Copyright:** Copyright (C) 2016 The Android Open Source Project + +### Scope + +The Sentry Java SDK includes adapted thread state and stack trace parsing code from the Android Open Source Project's bugreport tools. The code resides in the `io.sentry.android.core.internal.threaddump` package and includes `ThreadDumpParser`, `Line`, and `Lines`. + +``` +Copyright (C) 2016 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## Android Open Source Project — Jetpack Compose UI (Apache 2.0) + +**Source:** https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt#L187
+**Source:** https://github.com/androidx/androidx/blob/androidx-main/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/MathHelpers.kt
+**License:** Apache License 2.0
+**Copyright:** Copyright (C) 2019, 2020 The Android Open Source Project + +### Scope + +The Sentry Android Replay SDK includes code adapted from Jetpack Compose UI, used to compute Compose node bounds while traversing the view hierarchy for masking. The code resides in `io.sentry.android.replay.util.Nodes`: the `boundsInWindow` extension function (a faster copy of `LayoutCoordinates.boundsInWindow`) and the `fastMinOf`, `fastMaxOf`, `fastCoerceIn`, `fastCoerceAtLeast`, and `fastCoerceAtMost` numeric helpers (copied from `androidx.compose.ui.util.MathHelpers`). + +``` +Copyright (C) 2019, 2020 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## OpenTelemetry (Apache 2.0) + +**Source:** https://github.com/open-telemetry/opentelemetry-java (Commit: 0aacc55d1e3f5cc6dbb4f8fa26bcb657b01a7bc9)
+**License:** Apache License 2.0
+**Copyright:** Copyright The OpenTelemetry Authors + +### Scope + +The Sentry Java SDK includes an adapted version of `ThreadLocalContextStorage` from the OpenTelemetry Java SDK for thread-local context storage. The code resides in `io.sentry.opentelemetry.SentryOtelThreadLocalStorage`. + +``` +Copyright The OpenTelemetry Authors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + +## SalomonBrys — ANR-WatchDog (MIT) + +**Source:** https://github.com/SalomonBrys/ANR-WatchDog (Commit: 1969075f75f5980e9000eaffbaa13b0daf282dcb)
+**License:** MIT License
+**Copyright:** Copyright (c) 2016 Salomon BRYS + +### Scope + +The Sentry Java SDK includes an adapted version of the ANR-WatchDog library for Application Not Responding (ANR) detection on Android. The code resides in `io.sentry.android.core.ANRWatchDog`. + +``` +MIT License + +Copyright (c) 2016 Salomon BRYS + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + +--- + +## Breadwallet — Root Detection (MIT) + +**Source:** https://github.com/Menwitz/ravencoin-android (adapted from breadwallet)
+**License:** MIT License
+**Copyright:** Copyright (c) 2016 breadwallet LLC + +### Scope + +The Sentry Java SDK includes an adapted root detection implementation from the Ravencoin Android wallet (originally from breadwallet). The code resides in `io.sentry.android.core.internal.util.RootChecker`. + +``` +MIT License + +Copyright (c) 2016 breadwallet LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + +--- + +## KilianB — PCG-Java (MIT) + +**Source:** https://github.com/KilianB/pcg-java
+**License:** MIT License
+**Copyright:** Copyright (c) 2018 + +### Scope + +The Sentry Java SDK includes an adapted PCG-based random number generator from the pcg-java library for fast sampling. The code resides in `io.sentry.util.Random`. + +``` +MIT License + +Copyright (c) 2018 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +--- + +## Jon Chambers — UUID String Utils (MIT) + +**Source:** Jon Chambers
+**License:** MIT License
+**Copyright:** Copyright (c) 2018 Jon Chambers + +### Scope + +The Sentry Java SDK includes adapted UUID string manipulation utilities. The code resides in `io.sentry.util.UUIDStringUtils`. + +``` +MIT License + +Copyright (c) 2018 Jon Chambers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +--- + +## fzyzcjy — Flutter Screen Recorder (MIT) + +**Source:** https://github.com/fzyzcjy/flutter_screen_recorder (Commit: dce41cec25c66baf42c6bac4198e95874ce3eb9d)
+**License:** MIT License
+**Copyright:** Copyright (c) 2021 fzyzcjy + +### Scope + +The Sentry Android Replay SDK includes adapted versions of the video encoding and muxing classes from the flutter_screen_recorder library, used to encode and mux replay video frames into an MP4 file. The code resides in the `io.sentry.android.replay.video` package and includes `SimpleFrameMuxer`, `SimpleMp4FrameMuxer`, and `SimpleVideoEncoder`. + +``` +Copyright (c) 2021 fzyzcjy + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +In addition to the standard MIT license, this library requires the following: The recorder itself +only saves data on user's phone locally, thus it does not have any privacy problem. However, if +you are going to get the records out of the local storage (e.g. upload the records to your +server), please explicitly ask the user for permission, and promise to only use the records to +debug your app. This is a part of the license of this library. +``` diff --git a/agents.toml b/agents.toml new file mode 100644 index 00000000000..d9770ee7df5 --- /dev/null +++ b/agents.toml @@ -0,0 +1,41 @@ +# Whenever you make changes to this file, run the following to update all generated dotagent files +# npx @sentry/dotagents install +# npx @sentry/dotagents sync + +version = 1 + +[[skills]] +name = "dotagents" +source = "getsentry/dotagents" + +[[skills]] +name = "sentry-workflow" +source = "getsentry/sentry-for-ai" + +[[skills]] +name = "sentry-fix-issues" +source = "getsentry/sentry-for-ai" + +[[skills]] +name = "sentry-code-review" +source = "getsentry/sentry-for-ai" + +[[skills]] +name = "sentry-pr-code-review" +source = "getsentry/sentry-for-ai" + +[[skills]] +name = "create-java-pr" +source = "path:.agents/skills/create-java-pr" + +[[skills]] +name = "test" +source = "path:.agents/skills/test" + +[[skills]] +name = "btrace-perfetto" +source = "path:.agents/skills/btrace-perfetto" + +[[skills]] +name = "check-code-attribution" +source = "path:.agents/skills/check-code-attribution" diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 8abe9f55283..bba758f9b79 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -7,5 +7,19 @@ repositories { } dependencies { + implementation(libs.animalsniffer.gradle.plugin) implementation(libs.spotlessLib) } + +gradlePlugin { + plugins { + register("sentryAnimalSniffer") { + id = "io.sentry.animalsniffer" + implementationClass = "io.sentry.gradle.SentryAnimalSnifferPlugin" + } + register("sentryAnimalSnifferAndroid") { + id = "io.sentry.animalsniffer.android" + implementationClass = "io.sentry.gradle.SentryAnimalSnifferAndroidPlugin" + } + } +} diff --git a/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts b/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts index e06cb677319..8fde556d751 100644 --- a/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts +++ b/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts @@ -1,11 +1,9 @@ import io.sentry.gradle.AggregateJavadoc import org.gradle.api.attributes.Category import org.gradle.api.attributes.LibraryElements -import org.gradle.kotlin.dsl.creating -import org.gradle.kotlin.dsl.getValue import org.gradle.kotlin.dsl.named -val javadocPublisher by configurations.creating { +val javadocPublisher = configurations.create("javadocPublisher") { isCanBeConsumed = false isCanBeResolved = true attributes { @@ -15,7 +13,7 @@ val javadocPublisher by configurations.creating { } subprojects { - javadocPublisher.dependencies.add(dependencies.create(this)) + javadocPublisher.dependencies.add(rootProject.dependencies.project(path)) } val javadocCollection = javadocPublisher.incoming.artifactView { lenient(true) }.files diff --git a/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts b/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts index 7eb796a02ff..21f81fec36a 100644 --- a/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts +++ b/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts @@ -1,4 +1,4 @@ -val javadocConfig: Configuration by configurations.creating { +val javadocConfig: Configuration = configurations.create("javadocConfig") { isCanBeResolved = false isCanBeConsumed = true diff --git a/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts b/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts new file mode 100644 index 00000000000..a21079e1336 --- /dev/null +++ b/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts @@ -0,0 +1,38 @@ +import io.sentry.gradle.SystemTestExtension +import org.gradle.api.tasks.ClasspathNormalizer + +val systemTest = extensions.create("sentrySystemTest") + +// The sample system tests launch the packaged app (war/shadowJar/bootJar) from build/libs as a +// separate process, so the archive is a real input even though it is not on the test classpath. +// Agent-based samples are additionally launched with -javaagent:, another runtime +// input not on the classpath. See test/system-test-runner.py. +tasks.matching { it.name == "systemTest" }.configureEach { + val archiveTask = + listOf("war", "shadowJar", "bootJar").firstOrNull { it in tasks.names } + ?: throw GradleException( + "io.sentry.systemtest is applied to $path but none of war/shadowJar/bootJar " + + "exist to provide the launched app archive for the systemTest task" + ) + // Declaring the archive as an input also wires the dependency on its producing task. + inputs + .files(tasks.named(archiveTask)) + .withPropertyName("appArchive") + .withNormalizer(ClasspathNormalizer::class.java) + + if (systemTest.usesOpenTelemetryAgent.get()) { + // The runner builds the agent and launches the app with -javaagent before invoking this task, + // so the agent jar is tracked for content only (by path, no cross-project task dependency): a + // change to it makes systemTest out of date even though it runs outside the test JVM. + val version = providers.gradleProperty("versionName").get() + inputs + .files( + rootProject.layout.projectDirectory.file( + "sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/" + + "sentry-opentelemetry-agent-$version.jar" + ) + ) + .withPropertyName("openTelemetryAgent") + .withNormalizer(ClasspathNormalizer::class.java) + } +} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SentryAnimalSnifferPlugin.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SentryAnimalSnifferPlugin.kt new file mode 100644 index 00000000000..f1bc2bafcf7 --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/SentryAnimalSnifferPlugin.kt @@ -0,0 +1,57 @@ +package io.sentry.gradle + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.MinimalExternalModuleDependency +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.provider.ListProperty +import ru.vyarus.gradle.plugin.animalsniffer.AnimalSniffer + +abstract class SentryAnimalSnifferExtension { + abstract val ignoredClasses: ListProperty + abstract val excludedClasses: ListProperty + + fun ignoreClasses(vararg classes: String) { + ignoredClasses.addAll(*classes) + } + + fun mainExcludes(vararg excludes: String) { + excludedClasses.addAll(*excludes) + } +} + +class SentryAnimalSnifferPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply("ru.vyarus.animalsniffer") + + val extension = + project.extensions.create("sentryAnimalSniffer", SentryAnimalSnifferExtension::class.java) + + project.addSignatureDependency("java8-signature") + + project.tasks.named("animalsnifferMain", AnimalSniffer::class.java).configure { + ignoreClasses = ignoreClasses + extension.ignoredClasses.get() + exclude(extension.excludedClasses.get()) + } + + project.tasks.named("check").configure { dependsOn("animalsnifferMain") } + } +} + +class SentryAnimalSnifferAndroidPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply(SentryAnimalSnifferPlugin::class.java) + + project.addSignatureDependency("gummy-bears-api21") + } +} + +private fun Project.addSignatureDependency(libraryName: String) { + val libs = extensions.getByType(VersionCatalogsExtension::class.java).named("libs") + dependencies.add("signature", signatureNotation(libs.findLibrary(libraryName).get().get())) +} + +private fun signatureNotation(dependency: MinimalExternalModuleDependency): String { + val module = "${dependency.module.group}:${dependency.module.name}" + return "$module:${dependency.versionConstraint.requiredVersion}@signature" +} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt new file mode 100644 index 00000000000..9111ce17b1f --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt @@ -0,0 +1,17 @@ +package io.sentry.gradle + +import org.gradle.api.provider.Property + +/** Configuration for the `io.sentry.systemtest` convention plugin. */ +abstract class SystemTestExtension { + /** + * Set to `true` for samples that the system-test runner launches with the Sentry OpenTelemetry + * Java agent (`-javaagent`). The agent jar is then tracked as a `systemTest` input so the task + * re-runs when the agent changes, even though it is started outside the test JVM. + */ + abstract val usesOpenTelemetryAgent: Property + + init { + usesOpenTelemetryAgent.convention(false) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 23eaca36936..a663628b467 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,19 +3,15 @@ import com.vanniktech.maven.publish.JavadocJar import com.vanniktech.maven.publish.MavenPublishBaseExtension import groovy.util.Node import io.gitlab.arturbosch.detekt.extensions.DetektExtension -import kotlinx.kover.gradle.plugin.dsl.KoverReportExtension import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { `java-library` alias(libs.plugins.spotless) apply false - jacoco alias(libs.plugins.detekt) `maven-publish` alias(libs.plugins.binary.compatibility.validator) - alias(libs.plugins.jacoco.android) apply false - alias(libs.plugins.kover) apply false alias(libs.plugins.vanniktech.maven.publish) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.multiplatform) apply false @@ -30,6 +26,7 @@ plugins { alias(libs.plugins.gradle.versions) apply false alias(libs.plugins.spring.dependency.management) apply false id("io.sentry.javadoc.aggregate") + alias(libs.plugins.sentry) apply false } buildscript { @@ -76,6 +73,7 @@ apiValidation { "sentry-samples-spring-boot-4", "sentry-samples-spring-boot-4-opentelemetry", "sentry-samples-spring-boot-4-opentelemetry-noagent", + "sentry-samples-spring-boot-4-otlp", "sentry-samples-spring-boot-4-webflux", "sentry-samples-ktor-client", "sentry-uitest-android", @@ -83,14 +81,18 @@ apiValidation { "sentry-uitest-android-critical", "test-app-plain", "test-app-sentry", - "sentry-samples-netflix-dgs" + "test-app-size", + "sentry-samples-netflix-dgs", + "sentry-samples-console-otlp", + "sentry-test-support", + "sentry-system-test-support" ) ) } allprojects { group = Config.Sentry.group - version = properties[Config.Sentry.versionNameProp].toString() + version = providers.gradleProperty(Config.Sentry.versionNameProp).get() description = Config.Sentry.description tasks { withType().configureEach { @@ -101,13 +103,9 @@ allprojects { TestLogEvent.PASSED, TestLogEvent.FAILED ) - - // Cap JVM args per test - minHeapSize = "256m" - maxHeapSize = "2g" } withType().configureEach { - options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing", "-Xlint:-try")) + options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing", "-Xlint:-try", "-Xlint:-options")) } } } @@ -115,44 +113,6 @@ allprojects { subprojects { apply { plugin("io.sentry.spotless") } - val jacocoAndroidModules = listOf( - "sentry-android-core", - "sentry-android-fragment", - "sentry-android-navigation", - "sentry-android-ndk", - "sentry-android-sqlite", - "sentry-android-replay", - "sentry-android-timber" - ) - if (jacocoAndroidModules.contains(name)) { - afterEvaluate { - jacoco { - toolVersion = "0.8.10" - } - - tasks.withType().configureEach { - configure { - isIncludeNoLocationClasses = true - excludes = listOf("jdk.internal.*") - } - } - } - } - - val koverKmpModules = listOf("sentry-compose") - if (koverKmpModules.contains(name)) { - afterEvaluate { - configure { - androidReports("release") { - xml { - // Change the report file name so the Codecov Github action can find it - setReportFile(project.layout.buildDirectory.file("reports/kover/report.xml").get().asFile) - } - } - } - } - } - plugins.withId(Config.QualityPlugins.detektPlugin) { configure { buildUponDefaultConfig = true @@ -161,7 +121,7 @@ subprojects { } } - if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-system-test-support" && this.name != "sentry-test-support" && this.name != "sentry-android-distribution") { + if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-system-test-support" && this.name != "sentry-test-support") { apply() apply() @@ -208,9 +168,28 @@ subprojects { } } - afterEvaluate { - apply() + // AGP 9 defaults Android modules to Java 11. Pin the published library modules back + // to Java 8 so their bytecode stays consumable by Java 8 projects, mirroring the + // java-library pin above. + plugins.withId("com.android.library") { + configure { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + // AGP 9 defaults the AAR metadata minCompileSdk to the library's compileSdk, + // which would force every consumer onto that compile SDK. Pin it to our minSdk + // so consumers remain free to compile against any SDK we support, as before. + defaultConfig { + aarMetadata { minCompileSdk = libs.versions.minSdk.get().toInt() } + } + } + } + apply() + + afterEvaluate { configure { assignAarTypes() } @@ -245,21 +224,17 @@ tasks.register("buildForCodeQL") { } .forEach { proj -> if (proj.plugins.hasPlugin("com.android.library")) { - this.dependsOn(proj.tasks.findByName("compileReleaseUnitTestSources")) + proj.tasks.findByName("compileReleaseUnitTestSources")?.let { testTask -> + this.dependsOn(testTask) + } } else { - this.dependsOn(proj.tasks.findByName("testClasses")) + proj.tasks.findByName("testClasses")?.let { testTask -> + this.dependsOn(testTask) + } } } } -// Workaround for https://youtrack.jetbrains.com/issue/IDEA-316081/Gradle-8-toolchain-error-Toolchain-from-executable-property-does-not-match-toolchain-from-javaLauncher-property-when-different -gradle.taskGraph.whenReady { - val task = this.allTasks.find { it.name.endsWith(".main()") } as? JavaExec - task?.let { - it.setExecutable(it.javaLauncher.get().executablePath.asFile.absolutePath) - } -} - /* * Adapted from https://github.com/androidx/androidx/blob/c799cba927a71f01ea6b421a8f83c181682633fb/buildSrc/private/src/main/kotlin/androidx/build/MavenUploadHelper.kt#L524-L549 * diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 9ffe5ac3117..09d2869988b 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -1,8 +1,6 @@ -import java.math.BigDecimal - object Config { - val AGP = System.getenv("VERSION_AGP") ?: "8.6.0" + val AGP = System.getenv("VERSION_AGP") ?: "9.2.1" val kotlinStdLib = "stdlib-jdk8" val kotlinStdLibVersionAndroid = "1.9.24" val kotlinTestJunit = "test-junit" @@ -14,8 +12,10 @@ object Config { object Android { val abiFilters = listOf("x86", "armeabi-v7a", "x86_64", "arm64-v8a") + // Debug variants are disabled everywhere. Unit tests run against the release + // variant, so building the debug variant would only add overhead. fun shouldSkipDebugVariant(name: String?): Boolean { - return System.getenv("CI")?.toBoolean() ?: false && name == "debug" + return name == "debug" } } @@ -37,11 +37,6 @@ object Config { } object QualityPlugins { - object Jacoco { - // TODO [POTEL] add tests and restore - val minimumCoverage = BigDecimal.valueOf(0.1) - } - // this can be removed when we upgrade to Gradle 8, which allows us to use a getter for the plugin ID val detektPlugin = "io.gitlab.arturbosch.detekt" } @@ -64,6 +59,8 @@ object Config { val SENTRY_SPRING_BOOT_4_STARTER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot-4-starter" val SENTRY_OPENTELEMETRY_BOOTSTRAP_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.bootstrap" val SENTRY_OPENTELEMETRY_CORE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.core" + val SENTRY_OPENTELEMETRY_OTLP_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.otlp" + val SENTRY_OPENTELEMETRY_OTLP_SPRING_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.otlp-spring" val SENTRY_OPENTELEMETRY_AGENT_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.agent" val SENTRY_OPENTELEMETRY_AGENTLESS_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.agentless" val SENTRY_OPENTELEMETRY_AGENTLESS_SPRING_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.agentless-spring" @@ -75,8 +72,13 @@ object Config { val SENTRY_GRAPHQL_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql" val SENTRY_GRAPHQL_CORE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql-core" val SENTRY_GRAPHQL22_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql22" + val SENTRY_JCACHE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.jcache" val SENTRY_QUARTZ_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.quartz" val SENTRY_JDBC_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.jdbc" + val SENTRY_KAFKA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.kafka" + val SENTRY_OPENFEATURE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.openfeature" + val SENTRY_LAUNCHDARKLY_SERVER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.launchdarkly-server" + val SENTRY_LAUNCHDARKLY_ANDROID_SDK_NAME = "$SENTRY_ANDROID_SDK_NAME.launchdarkly" val SENTRY_SERVLET_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.servlet" val SENTRY_SERVLET_JAKARTA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.servlet.jakarta" val SENTRY_COMPOSE_HELPER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.compose.helper" diff --git a/buildSrc/src/main/java/MergeSpringMetadataAction.kt b/buildSrc/src/main/java/MergeSpringMetadataAction.kt new file mode 100644 index 00000000000..2df744924cb --- /dev/null +++ b/buildSrc/src/main/java/MergeSpringMetadataAction.kt @@ -0,0 +1,292 @@ +import java.net.URI +import java.nio.file.FileSystems +import java.nio.file.Files +import java.util.LinkedHashSet +import java.util.zip.ZipFile +import org.gradle.api.Action +import org.gradle.api.Task +import org.gradle.api.file.FileCollection +import org.gradle.api.tasks.bundling.AbstractArchiveTask + +/** + * Patches a built shadow JAR by merging Spring metadata and service descriptor files from the + * runtime classpath into the final archive. + * + * Spring metadata files do not all share the same merge semantics, so this action merges + * `spring.factories` as list properties, `.imports` files as line-based metadata, and other Spring + * metadata as key/value properties. It also deduplicates service-provider configuration entries + * under `META-INF/services` so the flat executable JAR keeps the runtime registrations it needs. + */ +class MergeSpringMetadataAction( + private val runtimeClasspath: FileCollection, + private val springMetadataFiles: List, +) : Action { + companion object { + val DEFAULT_SPRING_METADATA_FILES = + listOf( + "META-INF/spring.factories", + "META-INF/spring.handlers", + "META-INF/spring.schemas", + "META-INF/spring-autoconfigure-metadata.properties", + "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports", + "META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports", + ) + } + + override fun execute(task: Task) { + val archiveTask = task as AbstractArchiveTask + val jar = archiveTask.archiveFile.get().asFile + val runtimeJars = runtimeClasspath.files.filter { it.name.endsWith(".jar") } + val uri = URI.create("jar:${jar.toURI()}") + + FileSystems.newFileSystem(uri, mapOf("create" to "false")).use { fs -> + springMetadataFiles.forEach { entryPath -> + val target = fs.getPath(entryPath) + val contents = mutableListOf() + + if (Files.exists(target)) { + contents.add(Files.readString(target)) + } + + runtimeJars.forEach { depJar -> + try { + ZipFile(depJar).use { zip -> + val entry = zip.getEntry(entryPath) + if (entry != null) { + contents.add(zip.getInputStream(entry).bufferedReader().readText()) + } + } + } catch (_: Exception) { + // Ignore non-zip files on the runtime classpath. + } + } + + val merged = + when { + entryPath == "META-INF/spring.factories" -> mergeListProperties(contents) + entryPath.endsWith(".imports") -> mergeLineBasedMetadata(contents) + else -> mergeMapProperties(contents) + } + + if (merged.isNotEmpty()) { + if (target.parent != null) { + Files.createDirectories(target.parent) + } + Files.write(target, merged.toByteArray()) + } + } + + val serviceEntries = linkedSetOf() + + runtimeJars.forEach { depJar -> + try { + ZipFile(depJar).use { zip -> + val entries = zip.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.isDirectory && entry.name.startsWith("META-INF/services/")) { + serviceEntries.add(entry.name) + } + } + } + } catch (_: Exception) { + // Ignore non-zip files on the runtime classpath. + } + } + + serviceEntries.forEach { entryPath -> + val providers = LinkedHashSet() + val target = fs.getPath(entryPath) + + if (Files.exists(target)) { + Files.newBufferedReader(target).useLines { lines -> + lines.forEach { line -> + val provider = line.trim() + if (provider.isNotEmpty() && !provider.startsWith("#")) { + providers.add(provider) + } + } + } + } + + runtimeJars.forEach { depJar -> + try { + ZipFile(depJar).use { zip -> + val entry = zip.getEntry(entryPath) + if (entry != null) { + zip.getInputStream(entry).bufferedReader().useLines { lines -> + lines.forEach { line -> + val provider = line.trim() + if (provider.isNotEmpty() && !provider.startsWith("#")) { + providers.add(provider) + } + } + } + } + } + } catch (_: Exception) { + // Ignore non-zip files on the runtime classpath. + } + } + + if (providers.isNotEmpty()) { + if (target.parent != null) { + Files.createDirectories(target.parent) + } + Files.write(target, providers.joinToString(separator = "\n", postfix = "\n").toByteArray()) + } + } + } + } + + private fun mergeLineBasedMetadata(contents: List): String { + val lines = LinkedHashSet() + + contents.forEach { content -> + content.lineSequence().forEach { rawLine -> + val line = rawLine.trim() + if (line.isNotEmpty() && !line.startsWith("#")) { + lines.add(line) + } + } + } + + return if (lines.isEmpty()) "" else lines.joinToString(separator = "\n", postfix = "\n") + } + + private fun mergeMapProperties(contents: List): String { + val merged = linkedMapOf() + + contents.forEach { content -> + parseProperties(content).forEach { (key, value) -> + merged[key] = value + } + } + + return if (merged.isEmpty()) { + "" + } else { + merged.entries.joinToString(separator = "\n", postfix = "\n") { (key, value) -> "$key=$value" } + } + } + + private fun mergeListProperties(contents: List): String { + val merged = linkedMapOf>() + + contents.forEach { content -> + parseProperties(content).forEach { (key, value) -> + val values = merged.getOrPut(key) { LinkedHashSet() } + value + .split(',') + .map(String::trim) + .filter(String::isNotEmpty) + .forEach(values::add) + } + } + + return if (merged.isEmpty()) { + "" + } else { + merged.entries.joinToString(separator = "\n", postfix = "\n") { (key, values) -> + "$key=${values.joinToString(separator = ",")}" + } + } + } + + private fun parseProperties(content: String): List> { + val logicalLines = mutableListOf() + val current = StringBuilder() + + content.lineSequence().forEach { rawLine -> + val line = rawLine.trim() + if (current.isEmpty() && (line.isEmpty() || line.startsWith("#") || line.startsWith("!"))) { + return@forEach + } + + val normalized = if (current.isEmpty()) line else line.trimStart() + current.append( + if (endsWithContinuation(rawLine)) normalized.dropLast(1) else normalized, + ) + + if (!endsWithContinuation(rawLine)) { + logicalLines.add(current.toString()) + current.setLength(0) + } + } + + if (current.isNotEmpty()) { + logicalLines.add(current.toString()) + } + + return logicalLines.map { line -> + val separatorIndex = findSeparatorIndex(line) + if (separatorIndex < 0) { + line to "" + } else { + val keyEnd = trimTrailingWhitespace(line, separatorIndex) + val valueStart = findValueStart(line, separatorIndex) + line.substring(0, keyEnd) to line.substring(valueStart).trim() + } + } + } + + private fun endsWithContinuation(line: String): Boolean { + var backslashCount = 0 + + for (index in line.length - 1 downTo 0) { + if (line[index] == '\\') { + backslashCount++ + } else { + break + } + } + + return backslashCount % 2 == 1 + } + + private fun findSeparatorIndex(line: String): Int { + var backslashCount = 0 + + line.forEachIndexed { index, char -> + if (char == '\\') { + backslashCount++ + } else { + val isEscaped = backslashCount % 2 == 1 + if (!isEscaped && (char == '=' || char == ':' || char.isWhitespace())) { + return index + } + backslashCount = 0 + } + } + + return -1 + } + + private fun trimTrailingWhitespace(line: String, endExclusive: Int): Int { + var end = endExclusive + + while (end > 0 && line[end - 1].isWhitespace()) { + end-- + } + + return end + } + + private fun findValueStart(line: String, separatorIndex: Int): Int { + var valueStart = separatorIndex + + while (valueStart < line.length && line[valueStart].isWhitespace()) { + valueStart++ + } + + if (valueStart < line.length && (line[valueStart] == '=' || line[valueStart] == ':')) { + valueStart++ + } + + while (valueStart < line.length && line[valueStart].isWhitespace()) { + valueStart++ + } + + return valueStart + } +} diff --git a/buildSrc/src/main/java/Publication.kt b/buildSrc/src/main/java/Publication.kt index 0aa717a5630..d545e6e32dc 100644 --- a/buildSrc/src/main/java/Publication.kt +++ b/buildSrc/src/main/java/Publication.kt @@ -7,10 +7,13 @@ private object Consts { val taskRegex = Regex("(.*)DistZip") } +private fun Project.versionName(): String = + providers.gradleProperty("versionName").get() + // configure distZip tasks for multiplatform fun DistributionContainer.configureForMultiplatform(project: Project) { val sep = File.separator - val version = project.properties["versionName"].toString() + val version = project.versionName() val name = project.name this.maybeCreate("android").contents { @@ -69,7 +72,7 @@ fun DistributionContainer.configureForMultiplatform(project: Project) { fun DistributionContainer.configureForJvm(project: Project) { val sep = File.separator - val version = project.properties["versionName"].toString() + val version = project.versionName() val name = project.name this.getByName("main").contents { diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 3a53b1f7b3f..00000000000 --- a/codecov.yml +++ /dev/null @@ -1,23 +0,0 @@ -comment: no -codecov: - require_ci_to_pass: no - max_report_age: off - -coverage: - status: - project: - default: - target: 78% - threshold: 4% - patch: off - range: 78...100 - precision: 3 - round: down - -ignore: - - "**/src/test/*" - - "sentry-android-integration-tests/*" - - "sentry-system-test-support/*" - - "sentry-test-support/*" - - "sentry-samples/*" - - "sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/**" diff --git a/develop-docs/README.md b/develop-docs/README.md new file mode 100644 index 00000000000..b9c2c7913ca --- /dev/null +++ b/develop-docs/README.md @@ -0,0 +1,149 @@ +# Develop Docs + +This folder holds internal developer documentation for the Sentry Java/Android SDK: +architecture notes, feature deep-dives, design decisions, and cross-module concepts +that don't belong in the public [Sentry docs](https://docs.sentry.io) or in inline +code comments. + +If you are documenting **how** or **why** something works for the people who maintain +this SDK, it goes here. If you are documenting **how to use** the SDK for end users, +it belongs in the public docs instead. + +## Rules + +These rules keep the docs consistent, easy to navigate, and easy to grep. + +### Directory structure + +Documents live in **subdirectories**, one level per level of grouping. Directories are +cheap: reach for a new one as soon as a topic has more than one document, or as soon as you +can name the group. + +Every document sits under one of these top-level categories: + +- `general/` — cross-cutting topics (e.g. `general/architecture.md`, `general/pipeline.md`) +- `feature/` — a specific SDK feature (e.g. `feature/errors/`, `feature/profiling/`) +- `integration/` — a specific integration or module (e.g. `integration/opentelemetry/`, `integration/spring/`) +- `platform/` — platform-specific concerns (e.g. `platform/android/`, `platform/jvm/`) +- `process/` — team processes and workflows (e.g. `process/release.md`) + +Add a new category only when an existing one clearly does not fit, and keep the list above +up to date. + +Below the category, nest by topic and then by sub-topic. A fully grown feature might look +like this: + +```text +develop-docs/ + README.md + general/ + pipeline.md + feature/ + profiling/ + overview.md + perfetto.md + anr.md + symbolication/ + deobfuscation.md +``` + +- Give a directory an `overview.md` once it holds several documents, and link to its + siblings from there. +- Do not create a directory that will only ever hold one document — put the document + directly in the category (`general/pipeline.md`, not `general/pipeline/pipeline.md`). + +### File naming + +- File names are **lowercase**, except for this `README.md`, which GitHub renders as the + folder's landing page. +- Use **dashes** (`-`) as separators, never underscores or spaces. For example, use + `session-replay.md`, not `session_replay.md` or `Session Replay.md`. +- Use the `.md` extension for all text documents. +- **Do not repeat the path in the file name.** The directories carry the namespace, so the + file name only needs the part that distinguishes it from its siblings: + `feature/profiling/perfetto.md`, not `feature/profiling/perfetto-profiling.md`. +- Choose short, descriptive names (`feature/replay/masking.md`, not + `feature/replay/how-masking-works.md`). + +### Images and other assets + +- When a document embeds images (or other binary assets), store them in an **`assets/` + folder next to the document**. Documents in the same directory share it: + + ```text + develop-docs/ + feature/ + profiling/ + perfetto.md + assets/ + pipeline.png + overview.svg + ``` + +- Reference assets with **relative paths**: `![Profiling pipeline](assets/pipeline.png)`. +- Asset file names follow the same rules as documents: lowercase, dashes, descriptive. +- Prefer **vector formats** (SVG) for diagrams and screenshots where practical +- Prefer **Mermaid** over a static image whenever a diagram can be expressed as one + (see below) — it lives in the document, is versioned as text, and is easy to update. + +### Writing style + +- Write in the **present tense** and the **active voice**. Describe how the system + behaves now ("The transport retries failed envelopes"), not how it will or did behave. + This way there's no need to update the docs once a feature ships. +- Keep one **top-level `# ` heading** per document (the title), and nest sections with + `##`, `###`, etc. Do not skip heading levels. +- Keep documents focused on a **single topic**. Split large topics into several documents + in a shared directory and link between them rather than growing one giant file. +- Use fenced **code blocks with a language identifier** (```kotlin `, + ` ```bash `) so syntax highlighting works. +- Prefer Kotlin snippets over Java. +- When referencing code, link to the file with a **relative path** (e.g. + `../../../sentry/src/main/java/io/sentry/Sentry.java`) rather than pasting large excerpts + that fall out of date. Count the `../` from the document's own directory. +- Avoid pinning content to a specific SDK version or date unless it is genuinely + version-specific; keep docs evergreen. +- Cross-link related documents with relative links (e.g. + `[the ingestion pipeline](../../general/pipeline.md)`). + +### Structuring a feature document + +Most feature documents answer the same four questions, and following that order makes them +easier to compare and to keep current: + +1. **Surface area** — where and when the SDK collects the data. +2. **Collection** — how the SDK collects it. +3. **Format** — what the collected data looks like on the wire. +4. **Pipeline** — how the backend ingests, stores, and serves it. + +Do not restate (4) in every document. Describe the shared path once in +[general/pipeline.md](general/pipeline.md) and cover only the deviations a feature +introduces. Omit any of the four that a feature does not have, and keep each as high-level +as the topic allows so the document stays true for longer. + +### Diagrams with Mermaid + +- Prefer [Mermaid](https://mermaid.js.org/) for diagrams. It renders directly on GitHub + and lives in the document as text, so it versions and reviews like code. +- Embed a Mermaid diagram in a fenced block tagged `mermaid`: + + ````markdown + ```mermaid + flowchart LR + Event[SentryEvent] --> Processor[EventProcessors] + Processor --> Transport + Transport --> Sentry[(Sentry)] + ``` + ```` + +- For complex diagrams, include a link to the [Mermaid Live Editor](https://mermaid.live/) + so reviewers can iterate quickly. +- Fall back to static images (stored per the asset rules above) if mermaid is not practicable. + +## Adding a new document + +1. Pick the right top-level category (or introduce a new one and document it above). +2. Pick or create the topic directory below it. +3. Create the document, naming it for what distinguishes it from its siblings. +4. If the directory now holds several documents, add or update its `overview.md`. +5. If the document embeds assets, put them in an `assets/` folder next to it. diff --git a/develop-docs/feature/profiling/perfetto.md b/develop-docs/feature/profiling/perfetto.md new file mode 100644 index 00000000000..d4168f8f2a4 --- /dev/null +++ b/develop-docs/feature/profiling/perfetto.md @@ -0,0 +1,232 @@ +# Perfetto profiling on Android + +This document describes how continuous profiling works on Android when the SDK +captures traces through the OS-level [`android.os.ProfilingManager`](https://developer.android.com/reference/android/os/ProfilingManager) +API (available on API 35+), and how a captured **profile chunk** flows all the way +from the device to a downloadable profile in Sentry. + +## What Perfetto is + +[Perfetto](https://perfetto.dev/) is Google's tracing framework for Android and Linux, and +the tooling Android itself is instrumented with. Its +[callstack sampler](https://perfetto.dev/docs/getting-started/cpu-profiling) interrupts the +app at a fixed frequency, records the native and Java call stacks of the running threads, +and writes them to a binary `.pftrace` file (a serialized +[Perfetto protobuf](https://perfetto.dev/docs/reference/trace-packet-proto)). +Starting with Android 15, apps can request such traces at +runtime via `ProfilingManager` without root or `adb`, which is what makes on-device +continuous profiling possible. + +Useful Perfetto references: + +- Perfetto docs: https://perfetto.dev/docs/ +- CPU profiling with Perfetto: https://perfetto.dev/docs/getting-started/cpu-profiling +- Trace format (`TracePacket` proto): https://perfetto.dev/docs/reference/trace-packet-proto +- Perfetto UI (to open a downloaded `.pftrace`): https://ui.perfetto.dev/ + +## Pipeline overview + +Profile chunks travel the standard ingestion path described in +[general/pipeline.md](../../general/pipeline.md) — SDK envelope, +[Relay](https://develop.sentry.dev/ingestion/relay/) (Sentry's ingestion proxy), Kafka, a +monolith processing task, then storage and a read API. Read that first; the rest of this +document covers only where Perfetto deviates from it. + +The deviations are: + +- The envelope item carries **JSON and raw binary in one payload**, subdivided by a + `meta_length` header rather than base64-encoding the trace ([details](#envelope-format-and-the-meta_length-header)). +- Relay **converts** the Perfetto trace into the existing Sample v2 profile format, and + additionally **keeps the raw `.pftrace`** in the object store so it can be downloaded + later ([details](#relay-getsentryrelay)). + +```mermaid +flowchart TD + subgraph device["Android device — sentry-java"] + PM[android.os.ProfilingManager] + PP[PerfettoProfiler] + PCP[PerfettoContinuousProfiler] + PC[ProfileChunk] + ENV["Envelope item
[JSON metadata][raw .pftrace]
header: meta_length"] + PM --> PP --> PCP --> PC --> ENV + end + + subgraph relay["Relay (processing mode)"] + SPLIT[Split payload at meta_length] + CONV[Convert Perfetto → Sample v2] + OS1[Upload raw .pftrace to object store] + KAFKA[["Kafka topic: profiles
ProfileChunkKafkaMessage
(Sample v2 + attachment stored_id)"]] + SPLIT --> CONV --> KAFKA + SPLIT --> OS1 + end + + subgraph monolith["Monolith — getsentry/sentry"] + TASK[process_profile_task] + SYM[Symbolicate / deobfuscate] + VR[vroomrs: parse + normalize] + OS2[(Object store)] + SNUBA[(Snuba: function metrics)] + DB[(ProfileChunkAttachment row)] + TASK --> SYM --> VR + VR --> OS2 + VR --> SNUBA + TASK --> DB + end + + ENV -->|envelope| relay + KAFKA --> TASK + OS1 -.stored_id.-> DB + VROOM[getsentry/vroom
serve + merge flamegraphs] + OS2 --> VROOM + SNUBA --> VROOM +``` + +## SDK (getsentry/sentry-java) + +On API 35+, [`AndroidOptionsInitializer`](../../../sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java) +wires up `PerfettoContinuousProfiler` automatically. On older devices the SDK falls back +to the legacy `Debug`-based [`AndroidContinuousProfiler`](../../../sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java), +gated by the `enableLegacyProfiling` option (manifest key +`io.sentry.profiling.enable-legacy-profiling`, defaults to `true`). Only **continuous +profiling** is supported on the Perfetto path — transaction-based and app-start profiling +are not. + +### Capturing chunks + +Continuous profiling emits a stream of independent [`ProfileChunk`](../../../sentry/src/main/java/io/sentry/ProfileChunk.java)s +rather than one profile per transaction. `PerfettoContinuousProfiler` drives a chained +loop: each chunk runs for `MAX_CHUNK_DURATION_MILLIS` (60s) via `PerfettoProfiler`, which +calls `ProfilingManager.requestProfiling(PROFILING_TYPE_STACK_SAMPLING, …)` at +`PROFILING_FREQUENCY_HZ` (101 Hz). When a chunk's trace file is ready, a new chunk starts, +so profiling runs continuously. + +A chunk keeps a stable `profilerId` across the session and a per-chunk `chunkId`. When the +OS produces the trace file, the profiler builds a `ProfileChunk` tagged with the Perfetto +content type: + +```kotlin +ProfileChunk.Builder(profilerId, chunkId, measurements, traceFile, timestamp, ProfileChunk.PLATFORM_ANDROID) + .setContentType(ProfileChunk.CONTENT_TYPE_PERFETTO) // "application/x-perfetto-trace" + .build() +``` + +The chunk is captured via `scopes.captureProfileChunk(...)` and sent as its own envelope +with item type [`SentryItemType.ProfileChunk`](../../../sentry/src/main/java/io/sentry/SentryItemType.java) +(wire name `profile_chunk`). + +### Envelope format and the `meta_length` header + +A legacy chunk base64-encodes its trace into the `ProfileChunk` JSON. A Perfetto chunk is +much larger, so [`SentryClient`](../../../sentry/src/main/java/io/sentry/SentryClient.java) instead +routes it through the new `SentryEnvelopeItem.fromPerfettoProfileChunk(...)` factory, which +avoids base64 by sending the raw binary alongside the JSON. + +The trick is a single envelope **item** whose payload concatenates the JSON metadata and +the raw `.pftrace` bytes with **no delimiter**: + +```text +[ProfileChunk JSON bytes][raw .pftrace binary bytes] +``` + +A new `meta_length` property on the [envelope item header](../../../sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java) +tells the server where the JSON ends and the binary begins. The standard envelope item +structure (header line + newline + payload) is unchanged; `meta_length` simply subdivides +the payload: + +```text +{"type":"profile_chunk","content_type":"application/x-perfetto-trace","filename":"…","length":,"meta_length":} + +``` + +- `length` — total payload size (JSON + binary), as for any envelope item. +- `meta_length` — byte length of the JSON prefix. It is only known after the payload is + serialized, so the header computes it lazily (via a `Callable`) and omits the + field entirely for non-Perfetto items, keeping the change backward compatible. + +## Relay (getsentry/relay) + +In processing mode Relay: + +1. **Splits** the compound item payload at `meta_length` into `(metadata JSON, raw profile)` + and reads `content_type: "perfetto"` from the metadata. +2. **Converts** the binary Perfetto trace into the existing **Sample v2** profile JSON + format (`relay_profiling::expand_perfetto(...)`, backed by a checked-in subset of the + Perfetto protobuf definitions). +3. **Uploads** the raw `.pftrace` blob to object store (usecase `profiles`, keyed per + org/project, with an attachment-retention TTL). +4. **Produces** a `ProfileChunkKafkaMessage` to the `profiles` Kafka topic. The message + carries the expanded Sample v2 JSON as `payload` plus an `attachments` array, where each + attachment records: + - `name` (e.g. `profile.perfetto`), + - `content_type` (e.g. `application/x-perfetto-trace`), + - `stored_id` — the object store key of the uploaded raw blob. + +```json +{ + "organization_id": 1, + "project_id": 42, + "received": 1720000000, + "retention_days": 30, + "payload": "", + "attachments": [ + { + "name": "profile.perfetto", + "content_type": "application/x-perfetto-trace", + "stored_id": "" + } + ] +} +``` + +The monolith later uses `stored_id` to fetch the raw trace back. + +## Monolith (getsentry/sentry) + +`process_profile_task` (in `src/sentry/profiles/task.py`) consumes the `profiles` topic. +Because Relay already converted the trace to Sample v2, the task treats a Perfetto chunk +like any other: deobfuscate, hand it to `vroomrs` to parse and normalize +(`vroomrs.profile_chunk_from_json_str(...)`), compress and store it, and emit function +metrics to Snuba. + +The Perfetto-specific step is the last one: for each attachment on the message the task +persists a lightweight **`ProfileChunkAttachment`** row — `project_id`, `profiler_id`, +`chunk_id`, `name`, `content_type`, and the `stored_id` object store key. The row exists so +the raw trace can be downloaded by ID without exposing the `stored_id`. + +Flamegraphs themselves are served by `getsentry/vroom`, which reads the stored chunks and +the Snuba-indexed metadata and merges several chunks into one flamegraph. The endpoint +lives in the monolith and passes the request through. + +### Perfetto format dispatch (vroom / vroomrs) + +Older Android SDKs emit the legacy Android trace format tagged as a "faulty" `version=2`, +and the pipeline historically keyed off the platform rather than the version. To +distinguish legacy from Sample v2 chunks, `ProfileChunk` carries a dedicated `version` +field, and both `vroom` and `vroomrs` now dispatch on it instead of the platform: + +- Version `""` or `2.android-trace` → legacy Android trace format. +- Any other version → Sample v2. + +## Downloading a Perfetto profile + +The monolith exposes two feature-gated endpoints: + +- **List attachments** — `GET /organizations/{org}/profiling/chunk-attachments/` + (`sentry-api-0-organization-profiling-chunk-attachments`). Requires a `project` and + `profiler_id`; resolves the visible `chunk_id`s (same logic as the flamegraph) and returns + the matching `ProfileChunkAttachment` metadata. +- **Download** — `GET /projects/{org}/{project}/profiling/chunks/{profiler_id}/{chunk_id}/attachments/{attachment_id}/?download` + (`sentry-api-0-project-profiling-chunk-attachment`). The `?download` param is required; it + streams the raw blob back from object store via the stored `stored_id`. Access requires + the org's configured attachments role, analogous to generic event attachments. + +In the flamegraph UI, a toolbar button (added for continuous profiles when the feature is +enabled and at least one attachment exists) lists and provides a way to download these traces. + +## References + +- SDK: [sentry-java#5251](https://github.com/getsentry/sentry-java/pull/5251) — Android `ProfilingManager` (Perfetto) support +- Relay: [#5659](https://github.com/getsentry/relay/pull/5659), [#5932](https://github.com/getsentry/relay/pull/5932), [#6099](https://github.com/getsentry/relay/pull/6099), [#6102](https://github.com/getsentry/relay/pull/6102) — Perfetto parsing, pipeline, and object-store routing +- vroom: [#672](https://github.com/getsentry/vroom/pull/672) — version dispatch for Android trace profiles +- vroomrs: [#93](https://github.com/getsentry/vroomrs/pull/93) — accept Android profiles in Sample v2 format +- Monolith: [sentry#118029](https://github.com/getsentry/sentry/pull/118029) (chunk attachments + endpoints), [sentry#118071](https://github.com/getsentry/sentry/pull/118071) (flamegraph download button) diff --git a/develop-docs/general/pipeline.md b/develop-docs/general/pipeline.md new file mode 100644 index 00000000000..6cb0d87f696 --- /dev/null +++ b/develop-docs/general/pipeline.md @@ -0,0 +1,121 @@ +# Ingestion pipeline + +This document describes the path data takes from an SDK to a rendered view in Sentry. It +covers the parts of different payload types, like errors, transactions, logs, replays and +profile chunks. + +## Per data category + +Every payload takes the same four hops — SDK, Relay, a consumer in the monolith, and a read +API — but the topics, processing tasks, and stores differ per category. The diagrams below +show three of them; the hops themselves are described further down. + +### Errors + +```mermaid +flowchart LR + SDK["SDK
captures + batches"] -->|envelope| RELAY + RELAY["Relay
authenticate, normalize,
route"] -->|ingest-events| KAFKA[["Kafka"]] + RELAY -.->|attachments,
minidumps| OS[("Object store")] + KAFKA --> TASK["save_event task"] + TASK --> SYM["Symbolicator
symbolicate, deobfuscate"] + SYM --> TASK + TASK --> NS[("Nodestore
full event body")] + TASK --> SNUBA[("Snuba
searchable columns")] + TASK --> PG[("Postgres
Group / GroupHash rows")] + NS --> READ["Read path
monolith API"] + SNUBA --> READ + PG --> READ + OS --> READ +``` + +### Transactions + +```mermaid +flowchart LR + SDK["SDK
captures spans"] -->|envelope| RELAY + RELAY["Relay
normalize, dynamic sampling,
metric extraction"] -->|ingest-transactions| KAFKA[["Kafka"]] + KAFKA --> CONSUMER["Transaction consumer"] + CONSUMER --> SNUBA[("Snuba
transactions + spans")] + CONSUMER --> NS[("Nodestore
full transaction body")] + SNUBA --> READ["Read path
monolith API"] + NS --> READ +``` + +### Profile chunks + +```mermaid +flowchart LR + SDK["SDK
captures profile chunks"] -->|envelope| RELAY + RELAY["Relay
convert Perfetto → Sample v2"] -->|profiles| KAFKA[["Kafka"]] + RELAY -.->|raw .pftrace blob| OS[("Object store")] + KAFKA --> TASK["process_profile_task"] + TASK --> VRS["vroomrs
parse + normalize"] + VRS --> OS + VRS --> SNUBA[("Snuba
function metrics")] + TASK --> PG[("Postgres
ProfileChunkAttachment rows")] + OS --> VROOM["vroom
serve + merge flamegraphs"] + SNUBA --> VROOM + VROOM --> READ["Read path
monolith API"] + PG --> READ +``` + +## The hops + +### 1. SDK + +The SDK captures data and wraps it in an [envelope](https://develop.sentry.dev/sdk/data-model/envelopes/): +a JSON header followed by one or more items, each with its own header declaring a `type`, +a `length`, and optionally a `content_type`. The envelope is POSTed to the project's +`/api/{project_id}/envelope/` endpoint. + +The item `type` is what routes the payload through everything downstream, so adding a new +kind of data means adding an item type, not a new endpoint. Item payloads are usually JSON; +binary payloads are allowed and are preferable to base64-encoding a large blob into JSON. + +### 2. Relay + +[Relay](https://github.com/getsentry/relay) is Sentry's ingestion proxy — it sits between +the SDK and the rest of the infrastructure and is the first service to inspect a payload. +See the [Relay chapter in develop docs](https://develop.sentry.dev/ingestion/relay/) for +the full picture. + +Relay authenticates the DSN, applies quotas and rate limits, filters and normalizes the +payload, and forwards it. Two behaviours matter when designing a new payload type: + +- Relay may **convert** a payload into a different format before publishing it, so the + format the SDK sends and the format the backend consumes are not necessarily the same. + Whatever Relay publishes is the contract every downstream service depends on. +- Relay runs in two modes. Only **processing mode** (the one Sentry operates) talks to + Kafka and the object store; a self-hosted Relay in proxy mode just forwards envelopes + upstream. + +Relay publishes to a **Kafka topic per data category**. Payloads too large to sit +comfortably in a Kafka message are uploaded to the **object store** instead, and the +message carries a reference to the stored blob rather than the bytes themselves. Event +attachments (minidumps, screenshots, view hierarchies) work this way, and so does the raw +`.pftrace` blob of a Perfetto profile chunk: Relay uploads the trace and puts only its +`stored_id` object store key on the Kafka message. + +### 3. Consumers and processing + +Each topic is consumed by the monolith ([getsentry/sentry](https://github.com/getsentry/sentry)), +which runs a processing task per message. This is where the work that needs Sentry-side +state happens — symbolication and deobfuscation against uploaded debug files, enrichment, +normalization, and quota accounting. + +A task typically writes to more than one store: + +- **Object store** — the payload itself, compressed. Cheap to keep, not queryable. +- **Snuba** — the columns that need to be searched, aggregated, or listed. +- **Postgres** — small metadata rows that the API needs to resolve a request, for example + a row per stored blob so it can be fetched by ID instead of by exposing its storage key. + +### 4. Read path + +The monolith serves the API endpoints. For some categories it does the work itself; for +others it authorizes the request and proxies it to a dedicated service that owns the +heavy read logic. Either way the endpoint is the public surface, and the storage keys and +internal services stay behind it. + +See [feature/profiling/perfetto.md](../feature/profiling/perfetto.md) for a worked example. 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/gradle.properties b/gradle.properties index 5637a2f35c0..e9bfc0e8156 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,14 +4,19 @@ org.gradle.caching=true org.gradle.parallel=true org.gradle.configureondemand=true org.gradle.configuration-cache=true +org.gradle.configuration-cache.parallel=true org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled # AndroidX required by AGP >= 3.6.x android.useAndroidX=true +# AGP 9+ migration opt-outs until we remove kotlin-android plugin and adopt built-in Kotlin. +android.builtInKotlin=false +android.newDsl=false +android.experimental.lint.version=9.2.1 # Release information -versionName=8.23.0 +versionName=8.53.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 19415e87ca2..bb4d18c7a0e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,23 @@ [versions] +animalsniffer = "2.0.1" apollo = "2.5.9" androidxLifecycle = "2.2.0" androidxNavigation = "2.4.2" androidxTestCore = "1.7.0" androidxCompose = "1.6.3" +asyncProfiler = "4.4" +camerax = "1.4.0" composeCompiler = "1.5.14" coroutines = "1.6.1" espresso = "3.7.0" feign = "11.6" -jacoco = "0.8.7" +gummyBears = "0.12.0" +java8Signature = "1.0" jackson = "2.18.3" jetbrainsCompose = "1.6.11" -kotlin = "2.2.0" -kotlinSpring7 = "2.2.0" +kotlin = "2.3.21" kotlin-compatible-version = "1.9" +ksp = "2.3.9" ktorClient = "3.0.0" logback = "1.2.9" log4j2 = "2.20.0" @@ -21,33 +25,39 @@ nopen = "1.0.1" # see https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html#kotlin-compatibility # see https://developer.android.com/jetpack/androidx/releases/compose-kotlin okhttp = "4.9.2" -otel = "1.51.0" -otelInstrumentation = "2.17.0" -otelInstrumentationAlpha = "2.17.0-alpha" +openfeature = "1.18.2" +otel = "1.63.0" +otelAlpha = "1.63.0-alpha" +otelInstrumentation = "2.29.0" +otelInstrumentationAlpha = "2.29.0-alpha" # check https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/dependencyManagement/build.gradle.kts#L49 for release version above to find a compatible version -otelSemanticConventions = "1.34.0" -otelSemanticConventionsAlpha = "1.34.0-alpha" +otelSemanticConventions = "1.42.0" +otelSemanticConventionsAlpha = "1.42.0-alpha" retrofit = "2.9.0" +room2 = "2.8.4" +room3 = "3.0.0-rc01" +sagp = "6.13.0" +sqlite = "2.6.2" +sqliteRc = "2.7.0-rc01" # Required by Room3 3.0.0-rc* slf4j = "1.7.30" +spotless = "8.8.0" springboot2 = "2.7.18" springboot3 = "3.5.0" -springboot4 = "4.0.0-M3" +springboot4 = "4.1.0" +sqldelight = "2.3.2" + # Android -targetSdk = "34" -compileSdk = "34" +targetSdk = "37" +compileSdk = "37" minSdk = "21" -spotless = "7.0.4" -gummyBears = "0.12.0" -camerax = "1.3.0" [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" } -kotlin-spring7 = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlinSpring7" } -kotlin-jvm-spring7 = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinSpring7" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } buildconfig = { id = "com.github.gmazzo.buildconfig", version = "5.6.5" } dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } dokka-javadoc = { id = "org.jetbrains.dokka-javadoc", version = "2.0.0" } @@ -56,17 +66,18 @@ errorprone = { id = "net.ltgt.errorprone", version = "3.0.1" } gradle-versions = { id = "com.github.ben-manes.versions", version = "0.42.0" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.8" } -jacoco-android = { id = "com.mxalbert.gradle.jacoco-android", version = "0.2.0" } -kover = { id = "org.jetbrains.kotlinx.kover", version = "0.7.3" } vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.30.0" } -springboot2 = { id = "org.springframework.boot", version.ref = "springboot2" } springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" } springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } -spring-dependency-management = { id = "io.spring.dependency-management", version = "1.0.11.RELEASE" } +spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } gretty = { id = "org.gretty", version = "4.0.0" } -animalsniffer = { id = "ru.vyarus.animalsniffer", version = "2.0.1" } +animalsniffer = { id = "ru.vyarus.animalsniffer", version.ref = "animalsniffer" } +sentry = { id = "io.sentry.android.gradle", version.ref = "sagp"} +shadow = { id = "com.gradleup.shadow", version = "9.4.1" } [libraries] +animalsniffer-gradle-plugin = { module = "ru.vyarus:gradle-animalsniffer-plugin", version.ref = "animalsniffer" } apache-httpclient = { module = "org.apache.httpcomponents.client5:httpclient5", version = "5.0.4" } apollo2-coroutines = { module = "com.apollographql.apollo:apollo-coroutines-support", version.ref = "apollo" } apollo2-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" } @@ -77,11 +88,13 @@ androidx-annotation = { module = "androidx.annotation:annotation", version = "1. androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.8.2" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidxCompose" } androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout", version.ref = "androidxCompose" } -androidx-compose-material3 = { module = "androidx.compose.material3:material3", version = "1.2.1" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version = "1.4.0" } +androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core", version="1.7.8" } +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version="1.7.8" } androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidxCompose" } # Note: don't change without testing forwards compatibility -androidx-compose-ui-replay = { module = "androidx.compose.ui:ui", version = "1.5.0" } -androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.1.3" } +androidx-compose-ui-replay = { module = "androidx.compose.ui:ui", version = "1.10.2" } +androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.2.1" } androidx-core = { module = "androidx.core:core", version = "1.3.2" } androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.7.0" } androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version = "1.3.5" } @@ -89,8 +102,20 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } -androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.5.2" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room2" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" } +androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } +androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteRc" } +androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteRc" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } +androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } +async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } +async-profiler-jfr-converter = { module = "tools.profiler:jfr-converter", version.ref = "asyncProfiler" } +caffeine = { module = "com.github.ben-manes.caffeine:caffeine" } +caffeine-jcache = { module = "com.github.ben-manes.caffeine:jcache", version = "3.2.0" } coil-compose = { module = "io.coil-kt:coil-compose", version = "2.6.0" } commons-compress = {module = "org.apache.commons:commons-compress", version = "1.25.0"} context-propagation = { module = "io.micrometer:context-propagation", version = "1.1.0" } @@ -106,41 +131,54 @@ jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" jetbrains-annotations = { module = "org.jetbrains:annotations", version = "23.0.0" } kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } -kotlin-test-junit-spring7 = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlinSpring7" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClient" } ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktorClient" } +launchdarkly-android = { module = "com.launchdarkly:launchdarkly-android-client-sdk", version = "5.9.2" } +launchdarkly-server = { module = "com.launchdarkly:launchdarkly-java-server-sdk", version = "7.13.4" } log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j2" } log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j2" } leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.14" } +lottie-compose = { module = "com.airbnb.android:lottie-compose", version = "6.7.1" } logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } nopen-annotations = { module = "com.jakewharton.nopen:nopen-annotations", version.ref = "nopen" } nopen-checker = { module = "com.jakewharton.nopen:nopen-checker", version.ref = "nopen" } nullaway = { module = "com.uber.nullaway:nullaway", version = "0.9.5" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" } +openfeature = { module = "dev.openfeature:sdk", version.ref = "openfeature" } otel = { module = "io.opentelemetry:opentelemetry-sdk", version.ref = "otel" } +otel-exporter-otlp = { module = "io.opentelemetry:opentelemetry-exporter-otlp", version.ref = "otel" } +otel-exporter-logging = { module = "io.opentelemetry:opentelemetry-exporter-logging", version.ref = "otel" } otel-extension-autoconfigure = { module = "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure", version.ref = "otel" } otel-extension-autoconfigure-spi = { module = "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi", version.ref = "otel" } +otel-bom = { module = "io.opentelemetry:opentelemetry-bom", version.ref = "otel" } +otel-alpha-bom = { module = "io.opentelemetry:opentelemetry-bom-alpha", version.ref = "otelAlpha" } otel-instrumentation-bom = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom", version.ref = "otelInstrumentation" } +otel-instrumentation-alpha-bom = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha", version.ref = "otelInstrumentationAlpha" } otel-javaagent = { module = "io.opentelemetry.javaagent:opentelemetry-javaagent", version.ref = "otelInstrumentation" } otel-javaagent-tooling = { module = "io.opentelemetry.javaagent:opentelemetry-javaagent-tooling", version.ref = "otelInstrumentationAlpha" } otel-javaagent-extension-api = { module = "io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api", version.ref = "otelInstrumentationAlpha" } otel-semconv = { module = "io.opentelemetry.semconv:opentelemetry-semconv", version.ref = "otelSemanticConventions" } otel-semconv-incubating = { module = "io.opentelemetry.semconv:opentelemetry-semconv-incubating", version.ref = "otelSemanticConventionsAlpha" } p6spy = { module = "p6spy:p6spy", version = "3.9.1" } +epitaph = { module = "com.abovevacant:epitaph", version = "0.1.1" } +jcache = { module = "javax.cache:cache-api", version = "1.1.1" } quartz = { module = "org.quartz-scheduler:quartz", version = "2.3.0" } reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } -sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.11.2" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.16.3" } servlet-api = { module = "javax.servlet:javax.servlet-api", version = "3.1.0" } servlet-jakarta-api = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.1.0" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-jdk14 = { module = "org.slf4j:slf4j-jdk14", version.ref = "slf4j" } slf4j2-api = { module = "org.slf4j:slf4j-api", version = "2.0.5" } spotlessLib = { module = "com.diffplug.spotless:com.diffplug.spotless.gradle.plugin", version.ref = "spotless"} +springboot2-bom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springboot2" } springboot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springboot2" } +spring-graphql = { module = "org.springframework.graphql:spring-graphql", version = "1.0.6" } springboot-starter-graphql = { module = "org.springframework.boot:spring-boot-starter-graphql", version.ref = "springboot2" } springboot-starter-quartz = { module = "org.springframework.boot:spring-boot-starter-quartz", version.ref = "springboot2" } springboot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "springboot2" } @@ -151,6 +189,7 @@ springboot-starter-aop = { module = "org.springframework.boot:spring-boot-starte springboot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "springboot2" } springboot-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc", version.ref = "springboot2" } springboot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot2" } +springboot-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot2" } springboot3-otel = { module = "io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter", version.ref = "otelInstrumentation" } springboot3-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springboot3" } springboot3-starter-graphql = { module = "org.springframework.boot:spring-boot-starter-graphql", version.ref = "springboot3" } @@ -163,7 +202,13 @@ springboot3-starter-aop = { module = "org.springframework.boot:spring-boot-start springboot3-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "springboot3" } springboot3-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc", version.ref = "springboot3" } springboot3-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot3" } +springboot3-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot3" } +spring-kafka2 = { module = "org.springframework.kafka:spring-kafka", version = "2.8.11" } +spring-kafka3 = { module = "org.springframework.kafka:spring-kafka", version = "3.3.5" } +spring-kafka4 = { module = "org.springframework.kafka:spring-kafka" } +kafka-clients = { module = "org.apache.kafka:kafka-clients", version = "3.8.1" } springboot4-otel = { module = "io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter", version.ref = "otelInstrumentation" } +springboot4-resttestclient = { module = "org.springframework.boot:spring-boot-resttestclient", version.ref = "springboot4" } springboot4-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springboot4" } springboot4-starter-graphql = { module = "org.springframework.boot:spring-boot-starter-graphql", version.ref = "springboot4" } springboot4-starter-quartz = { module = "org.springframework.boot:spring-boot-starter-quartz", version.ref = "springboot4" } @@ -177,27 +222,32 @@ springboot4-starter-restclient = { module = "org.springframework.boot:spring-boo springboot4-starter-webclient = { module = "org.springframework.boot:spring-boot-starter-webclient", version.ref = "springboot4" } springboot4-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc", version.ref = "springboot4" } springboot4-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot4" } +springboot4-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot4" } +springboot4-starter-kafka = { module = "org.springframework.boot:spring-boot-starter-kafka", version.ref = "springboot4" } +sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } timber = { module = "com.jakewharton.timber:timber", version = "4.7.1" } # Animalsniffer signature gummy-bears-api21 = { module = "com.toasttab.android:gummy-bears-api-21", version.ref = "gummyBears" } +java8-signature = { module = "org.codehaus.mojo.signature:java18", version.ref = "java8Signature" } # tomcat libraries tomcat-catalina = { module = "org.apache.tomcat:tomcat-catalina", version = "9.0.108" } tomcat-embed-jasper = { module = "org.apache.tomcat.embed:tomcat-embed-jasper", version = "9.0.108" } -tomcat-catalina-jakarta = { module = "org.apache.tomcat:tomcat-catalina", version = "11.0.10" } -tomcat-embed-jasper-jakarta = { module = "org.apache.tomcat.embed:tomcat-embed-jasper", version = "11.0.10" } +tomcat-catalina-jakarta = { module = "org.apache.tomcat:tomcat-catalina", version = "11.0.22" } +tomcat-embed-jasper-jakarta = { module = "org.apache.tomcat.embed:tomcat-embed-jasper", version = "11.0.22" } # test libraries -androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version = "1.6.8" } +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version = "1.4.1" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version = "1.9.5" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" } androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidxTestCore" } androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } androidx-test-espresso-idling-resource = { module = "androidx.test.espresso:espresso-idling-resource", version.ref = "espresso" } -androidx-test-ext-junit = { module = "androidx.test.ext:junit", version = "1.1.5" } -androidx-test-orchestrator = { module = "androidx.test:orchestrator", version = "1.5.0" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version = "1.3.0" } +androidx-test-orchestrator = { module = "androidx.test:orchestrator", version = "1.6.1" } androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTestCore" } -androidx-test-runner = { module = "androidx.test:runner", version = "1.6.2" } +androidx-test-runner = { module = "androidx.test:runner", version = "1.7.0" } awaitility-kotlin = { module = "org.awaitility:awaitility-kotlin", version = "4.1.1" } awaitility-kotlin-spring7 = { module = "org.awaitility:awaitility-kotlin", version = "4.3.0" } awaitility3-kotlin = { module = "org.awaitility:awaitility-kotlin", version = "3.1.6" } @@ -208,6 +258,7 @@ camerax-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "ca camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } camerax-view = { module = "androidx.camera:camera-view", version.ref = "camerax" } +google-truth = { module = "com.google.truth:truth", version = "1.4.5" } hsqldb = { module = "org.hsqldb:hsqldb", version = "2.6.1" } javafaker = { module = "com.github.javafaker:javafaker", version = "1.0.2" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } @@ -218,4 +269,8 @@ mockito-inline = { module = "org.mockito:mockito-inline", version = "4.8.0" } msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } -roboelectric = { module = "org.robolectric:robolectric", version = "4.14" } +roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } + +[bundles] +androidx-room2 = ["androidx-room-runtime", "androidx-room-ktx"] +androidx-sqlite-drivers = ["androidx-sqlite-bundled", "androidx-sqlite-framework"] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baab..b1b8ef56b44 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d4081da476b..a9db11550c6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 23d15a93670..249efbb032c 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index db3a6ac207e..a51ec4f5886 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,12 +19,12 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,30 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH= -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% 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/requirements.txt b/requirements.txt index 08623cdf27b..c573fa72259 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ certifi==2025.7.14 charset-normalizer==3.4.2 -idna==3.10 -requests==2.32.4 -urllib3==2.5.0 +idna==3.15 +requests==2.33.0 +urllib3==2.7.0 diff --git a/scripts/check-tombstone-proto-schema.sh b/scripts/check-tombstone-proto-schema.sh new file mode 100755 index 00000000000..ecf492af7e8 --- /dev/null +++ b/scripts/check-tombstone-proto-schema.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +TRACKED_COMMIT="981d145117e8992842cdddee555c57e60c7a220a" +REMOTE_URL='https://android.googlesource.com/platform/system/core' +REMOTE_BRANCH='main' +PROTO_PATH='debuggerd/proto/tombstone.proto' +GITILES_REF="refs/heads/${REMOTE_BRANCH}" +GITILES_LOG_URL="${REMOTE_URL}/+log/${GITILES_REF}/${PROTO_PATH}?format=JSON" + +MODE=auto +case "${1:-}" in + "") + ;; + --git-only) + MODE=git + ;; + --gitiles-only) + MODE=gitiles + ;; + *) + echo "Usage: $0 [--git-only|--gitiles-only]" >&2 + exit 2 + ;; +esac + +TEMP_FILES=() +TEMP_DIRS=() +LATEST_COMMIT="" + +error() { + echo "ERROR: $*" >&2 +} + +show_output() { + local label=$1 + local file=$2 + + if [ -s "$file" ]; then + echo "$label:" >&2 + sed 's/^/ /' "$file" >&2 + fi +} + +require_command() { + local command_name=$1 + + if ! command -v "$command_name" >/dev/null 2>&1; then + error "Required command not found: $command_name" + return 1 + fi +} + +make_temp_file() { + local file + file=$(mktemp) + TEMP_FILES+=("$file") + printf '%s\n' "$file" +} + +make_temp_dir() { + local dir + dir=$(mktemp -d) + TEMP_DIRS+=("$dir") + printf '%s\n' "$dir" +} + +cleanup() { + local path + + for path in "${TEMP_FILES[@]}"; do + rm -f "$path" + done + + for path in "${TEMP_DIRS[@]}"; do + rm -rf "$path" + done +} + +handle_unexpected_error() { + local exit_code=$? + error "Unexpected failure at line $1 while running: $2 (exit $exit_code)" + exit "$exit_code" +} + +trap 'handle_unexpected_error "$LINENO" "$BASH_COMMAND"' ERR +trap cleanup EXIT + +run_gitiles_check() { + local response_file + local stderr_file + local status + + require_command curl || return 1 + require_command jq || return 1 + + response_file=$(make_temp_file) + stderr_file=$(make_temp_file) + + if curl -fsS "$GITILES_LOG_URL" -o "$response_file" 2>"$stderr_file"; then + : + else + status=$? + error "Failed to fetch Gitiles history from:" + error " $GITILES_LOG_URL" + error "curl exited with status $status." + show_output "curl output" "$stderr_file" + return 1 + fi + + if LATEST_COMMIT=$(tail -n +2 "$response_file" | jq -er '.log[0].commit' 2>"$stderr_file"); then + : + else + status=$? + error "Failed to parse the latest commit from the Gitiles response." + error "jq exited with status $status." + show_output "jq output" "$stderr_file" + echo "Response preview:" >&2 + head -n 20 "$response_file" >&2 + return 1 + fi + + if [ -z "$LATEST_COMMIT" ]; then + error "Gitiles response did not contain a commit hash." + echo "Response preview:" >&2 + head -n 20 "$response_file" >&2 + return 1 + fi +} + +run_git_check() { + local repo_dir + local stderr_file + local status + + require_command git || return 1 + + repo_dir=$(make_temp_dir) + stderr_file=$(make_temp_file) + + if GIT_TERMINAL_PROMPT=0 git clone \ + --quiet \ + --filter=blob:none \ + --single-branch \ + --branch "$REMOTE_BRANCH" \ + --no-checkout \ + "$REMOTE_URL" "$repo_dir" 2>"$stderr_file"; then + : + else + status=$? + error "Failed to clone $REMOTE_BRANCH from:" + error " $REMOTE_URL" + error "git clone exited with status $status." + show_output "git clone output" "$stderr_file" + return 1 + fi + + if LATEST_COMMIT=$(git -C "$repo_dir" log -n 1 --format=%H HEAD -- "$PROTO_PATH" 2>"$stderr_file"); then + : + else + status=$? + error "Failed to determine the latest commit that modified:" + error " $PROTO_PATH" + error "git log exited with status $status." + show_output "git log output" "$stderr_file" + return 1 + fi + + if [ -z "$LATEST_COMMIT" ]; then + error "Git history did not contain a commit for:" + error " $PROTO_PATH" + return 1 + fi +} + +report_result() { + echo "Tracked commit: $TRACKED_COMMIT" + echo "Latest commit: $LATEST_COMMIT" + + if [ "$LATEST_COMMIT" != "$TRACKED_COMMIT" ]; then + echo "Schema has been updated! Latest: ${REMOTE_URL}/+/${LATEST_COMMIT}/${PROTO_PATH}" + exit 1 + fi + + echo "Schema is up to date." +} + +case "$MODE" in + auto) + if run_gitiles_check; then + report_result + exit 0 + fi + + echo "Falling back to git-based check." >&2 + if run_git_check; then + report_result + exit 0 + fi + + exit 1 + ;; + gitiles) + if run_gitiles_check; then + report_result + exit 0 + fi + + exit 1 + ;; + git) + if run_git_check; then + report_result + exit 0 + fi + + exit 1 + ;; +esac diff --git a/scripts/update-gradle.sh b/scripts/update-gradle.sh deleted file mode 100755 index c2bfe979224..00000000000 --- a/scripts/update-gradle.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd $(dirname "$0")/../ - -if [[ -n ${CI+x} ]]; then - export JAVA_HOME=$JAVA_HOME_17_X64 -fi - -case $1 in -get-version) - # `./gradlew` shows some info on the first run, breaking the parsing in the next step. - # Therefore, we run it once without checking any output. - ./gradlew --version >/dev/null - version="$(./gradlew --version | sed -E -n 's/.*Gradle +([0-9.]+).*/\1/p')" - - # Add trailing ".0" - gradlew outputs '7.1' instead of '7.1.0' - if [[ "$version" =~ ^[0-9]\.[0-9]$ ]]; then - version="$version.0" - fi - - echo "v$version" - ;; -get-repo) - echo "https://github.com/gradle/gradle.git" - ;; -set-version) - version=$2 - - # Remove leading "v" - if [[ "$version" == v* ]]; then - version="${version:1}" - fi - - echo "Setting gradle version to '$version'" - - # This sets version to gradle-wrapper.properties. - ./gradlew wrapper --gradle-version "$version" - - # Verify it works. - ./gradlew --version - ;; -*) - echo "Unknown argument $1" - exit 1 - ;; -esac diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0712c78ce91..65bf072f0a0 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -41,7 +41,7 @@ public final class io/sentry/android/core/ActivityLifecycleIntegration : android } public class io/sentry/android/core/AndroidContinuousProfiler : io/sentry/IContinuousProfiler, io/sentry/transport/RateLimiter$IRateLimitObserver { - public fun (Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/ISentryExecutorService;)V + public fun (Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/util/LazyEvaluator$Evaluator;)V public fun close (Z)V public fun getChunkId ()Lio/sentry/protocol/SentryId; public fun getProfilerId ()Lio/sentry/protocol/SentryId; @@ -82,15 +82,39 @@ public final class io/sentry/android/core/AndroidLogger : io/sentry/ILogger { public fun log (Lio/sentry/SentryLevel;Ljava/lang/Throwable;Ljava/lang/String;[Ljava/lang/Object;)V } +public final class io/sentry/android/core/AndroidLoggerBatchProcessor : io/sentry/logger/LoggerBatchProcessor, 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/AndroidLoggerBatchProcessorFactory : io/sentry/logger/ILoggerBatchProcessorFactory { + public fun ()V + public fun create (Lio/sentry/SentryOptions;Lio/sentry/SentryClient;)Lio/sentry/logger/ILoggerBatchProcessor; +} + public class io/sentry/android/core/AndroidMemoryCollector : io/sentry/IPerformanceSnapshotCollector { public fun ()V public fun collect (Lio/sentry/PerformanceCollectionData;)V 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 + public fun (Ljava/lang/String;ILio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/util/LazyEvaluator$Evaluator;Lio/sentry/ILogger;)V public fun close ()V public fun endAndCollect (ZLjava/util/List;)Lio/sentry/android/core/AndroidProfiler$ProfileEndData; public fun start ()Lio/sentry/android/core/AndroidProfiler$ProfileStartData; @@ -129,13 +153,6 @@ public final class io/sentry/android/core/AnrIntegrationFactory { public static fun create (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)Lio/sentry/Integration; } -public final class io/sentry/android/core/AnrV2EventProcessor : io/sentry/BackfillingEventProcessor { - public fun (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V - public fun getOrder ()Ljava/lang/Long; - public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; - public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; -} - public class io/sentry/android/core/AnrV2Integration : io/sentry/Integration, java/io/Closeable { public fun (Landroid/content/Context;)V public fun close ()V @@ -167,6 +184,30 @@ public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/In public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } +public final class io/sentry/android/core/AppStartExtension : io/sentry/IAppStartExtender { + public fun (Lio/sentry/android/core/performance/AppStartMetrics;)V + public fun clear ()V + public fun extendAppStart ()V + public fun finishExtendedAppStart ()V + public fun finishTransaction (Lio/sentry/SentryDate;)V + public fun getExtendedAppStartSpan ()Lio/sentry/ISpan; + public fun getExtendedEndTime ()Lio/sentry/SentryDate; + public fun isActive ()Z + public fun isExtended ()Z + public fun setData (Ljava/lang/String;Ljava/lang/Object;)V + public fun setExtendAppStartListener (Lio/sentry/android/core/AppStartExtension$ExtendAppStartListener;)V +} + +public abstract interface class io/sentry/android/core/AppStartExtension$ExtendAppStartListener { + public abstract fun onExtendAppStartRequested ()Lio/sentry/android/core/AppStartExtension$ExtendedAppStart; +} + +public final class io/sentry/android/core/AppStartExtension$ExtendedAppStart { + public final field span Lio/sentry/ISpan; + public final field transaction Lio/sentry/ITransaction; + public fun (Lio/sentry/ITransaction;Lio/sentry/ISpan;)V +} + public final class io/sentry/android/core/AppState : java/io/Closeable { public fun addAppStateListener (Lio/sentry/android/core/AppState$AppStateListener;)V public fun close ()V @@ -191,6 +232,13 @@ public final class io/sentry/android/core/AppState$LifecycleObserver : androidx/ public fun onStop (Landroidx/lifecycle/LifecycleOwner;)V } +public final class io/sentry/android/core/ApplicationExitInfoEventProcessor : io/sentry/BackfillingEventProcessor { + public fun (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V + public fun getOrder ()Ljava/lang/Long; + public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; + public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; +} + public final class io/sentry/android/core/BuildConfig { public static final field BUILD_TYPE Ljava/lang/String; public static final field DEBUG Z @@ -245,6 +293,22 @@ public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : i public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } +public final class io/sentry/android/core/FeedbackShakeIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, io/sentry/SentryFeedbackOptions$IShakeController, java/io/Closeable { + public fun (Landroid/app/Application;)V + public fun close ()V + public fun disableOnShake ()V + public fun enableOnShake ()V + public fun isOnShakeEnabled ()Z + public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityDestroyed (Landroid/app/Activity;)V + public fun onActivityPaused (Landroid/app/Activity;)V + public fun onActivityResumed (Landroid/app/Activity;)V + public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityStarted (Landroid/app/Activity;)V + public fun onActivityStopped (Landroid/app/Activity;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V +} + public abstract interface class io/sentry/android/core/IDebugImagesLoader { public abstract fun clearDebugImages ()V public abstract fun loadDebugImages ()Ljava/util/List; @@ -267,6 +331,19 @@ public final class io/sentry/android/core/LoadClass : io/sentry/util/LoadClass { public fun loadClass (Ljava/lang/String;Lio/sentry/ILogger;)Ljava/lang/Class; } +public final class io/sentry/android/core/NativeEventCollector { + public fun (Lio/sentry/android/core/SentryAndroidOptions;)V + public fun collect ()V + public fun deleteNativeEventFile (Lio/sentry/android/core/NativeEventCollector$NativeEventData;)Z + public fun findAndRemoveMatchingNativeEvent (J)Lio/sentry/android/core/NativeEventCollector$NativeEventData; +} + +public final class io/sentry/android/core/NativeEventCollector$NativeEventData { + public fun getEnvelope ()Lio/sentry/SentryEnvelope; + public fun getEvent ()Lio/sentry/SentryEvent; + public fun getFile ()Ljava/io/File; +} + public final class io/sentry/android/core/NdkHandlerStrategy : java/lang/Enum { public static final field SENTRY_HANDLER_STRATEGY_CHAIN_AT_START Lio/sentry/android/core/NdkHandlerStrategy; public static final field SENTRY_HANDLER_STRATEGY_DEFAULT Lio/sentry/android/core/NdkHandlerStrategy; @@ -288,8 +365,26 @@ public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sen public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } +public class io/sentry/android/core/PerfettoContinuousProfiler : io/sentry/IContinuousProfiler, io/sentry/transport/RateLimiter$IRateLimitObserver { + public fun (Lio/sentry/ILogger;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/util/LazyEvaluator$Evaluator;Ljava/util/function/Supplier;)V + public fun close (Z)V + public fun getChunkId ()Lio/sentry/protocol/SentryId; + public fun getProfilerId ()Lio/sentry/protocol/SentryId; + public fun isRunning ()Z + public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V + public fun reevaluateSampling ()V + public fun startProfiler (Lio/sentry/ProfileLifecycle;Lio/sentry/TracesSampler;)V + public fun stopProfiler (Lio/sentry/ProfileLifecycle;)V +} + +public class io/sentry/android/core/PerfettoProfiler { + public fun (Landroid/content/Context;Lio/sentry/ILogger;Lio/sentry/ISentryExecutorService;)V + public fun endAndCollect (Ljava/util/function/Consumer;)V + public fun start (J)Z +} + public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor { - public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V + public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;Z)V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; @@ -310,69 +405,95 @@ public final class io/sentry/android/core/SentryAndroidDateProvider : io/sentry/ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/SentryOptions { public fun ()V public fun enableAllAutoBreadcrumbs (Z)V + public fun getAnrProfilingSampleRate ()Ljava/lang/Double; public fun getAnrTimeoutIntervalMillis ()J public fun getBeforeScreenshotCaptureCallback ()Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback; public fun getBeforeViewHierarchyCaptureCallback ()Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback; public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader; public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector; public fun getNativeSdkName ()Ljava/lang/String; + public fun getNdkAppHangTimeoutIntervalMillis ()J public fun getNdkHandlerStrategy ()I + public fun getScreenshot ()Lio/sentry/android/core/SentryScreenshotOptions; public fun getStartupCrashDurationThresholdMillis ()J public fun isAnrEnabled ()Z + public fun isAnrProfilingEnabled ()Z public fun isAnrReportInDebug ()Z public fun isAttachAnrThreadDump ()Z + public fun isAttachRawTombstone ()Z public fun isAttachScreenshot ()Z public fun isAttachViewHierarchy ()Z public fun isCollectAdditionalContext ()Z + public fun isCollectExternalStorageContext ()Z public fun isEnableActivityLifecycleBreadcrumbs ()Z public fun isEnableActivityLifecycleTracingAutoFinish ()Z + public fun isEnableAnrFingerprinting ()Z public fun isEnableAppComponentBreadcrumbs ()Z public fun isEnableAppLifecycleBreadcrumbs ()Z public fun isEnableAutoActivityLifecycleTracing ()Z public fun isEnableAutoTraceIdGeneration ()Z public fun isEnableFramesTracking ()Z public fun isEnableNdk ()Z + public fun isEnableNdkAppHangTracking ()Z public fun isEnableNetworkEventBreadcrumbs ()Z public fun isEnablePerformanceV2 ()Z public fun isEnableRootCheck ()Z public fun isEnableScopeSync ()Z + public fun isEnableStandaloneAppStartTracing ()Z public fun isEnableSystemEventBreadcrumbs ()Z public fun isEnableSystemEventBreadcrumbsExtras ()Z public fun isReportHistoricalAnrs ()Z + public fun isReportHistoricalTombstones ()Z + public fun isTombstoneEnabled ()Z public fun setAnrEnabled (Z)V + public fun setAnrProfilingSampleRate (Ljava/lang/Double;)V public fun setAnrReportInDebug (Z)V public fun setAnrTimeoutIntervalMillis (J)V public fun setAttachAnrThreadDump (Z)V + public fun setAttachRawTombstone (Z)V public fun setAttachScreenshot (Z)V public fun setAttachViewHierarchy (Z)V public fun setBeforeScreenshotCaptureCallback (Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback;)V public fun setBeforeViewHierarchyCaptureCallback (Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback;)V public fun setCollectAdditionalContext (Z)V + public fun setCollectExternalStorageContext (Z)V public fun setDebugImagesLoader (Lio/sentry/android/core/IDebugImagesLoader;)V public fun setEnableActivityLifecycleBreadcrumbs (Z)V public fun setEnableActivityLifecycleTracingAutoFinish (Z)V + public fun setEnableAnrFingerprinting (Z)V public fun setEnableAppComponentBreadcrumbs (Z)V public fun setEnableAppLifecycleBreadcrumbs (Z)V public fun setEnableAutoActivityLifecycleTracing (Z)V public fun setEnableAutoTraceIdGeneration (Z)V public fun setEnableFramesTracking (Z)V public fun setEnableNdk (Z)V + public fun setEnableNdkAppHangTracking (Z)V public fun setEnableNetworkEventBreadcrumbs (Z)V public fun setEnablePerformanceV2 (Z)V public fun setEnableRootCheck (Z)V public fun setEnableScopeSync (Z)V + public fun setEnableStandaloneAppStartTracing (Z)V public fun setEnableSystemEventBreadcrumbs (Z)V public fun setEnableSystemEventBreadcrumbsExtras (Z)V public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V public fun setNativeHandlerStrategy (Lio/sentry/android/core/NdkHandlerStrategy;)V public fun setNativeSdkName (Ljava/lang/String;)V + public fun setNdkAppHangTimeoutIntervalMillis (J)V public fun setReportHistoricalAnrs (Z)V + public fun setReportHistoricalTombstones (Z)V + public fun setTombstoneEnabled (Z)V } public abstract interface class io/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback { public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;Z)Z } +public final class io/sentry/android/core/SentryFramesDelayResult { + public fun (DI)V + public fun getDelaySeconds ()D + public fun getFramesContributingToDelayCount ()I +} + public final class io/sentry/android/core/SentryInitProvider { public fun ()V public fun attachInfo (Landroid/content/Context;Landroid/content/pm/ProviderInfo;)V @@ -407,6 +528,25 @@ public final class io/sentry/android/core/SentryPerformanceProvider { public fun shutdown ()V } +public final class io/sentry/android/core/SentryScreenshotOptions : io/sentry/SentryMaskingOptions { + public fun ()V + public fun setMaskAllImages (Z)V + public fun trackCustomMasking ()V +} + +public final class io/sentry/android/core/SentryShakeDetector : android/hardware/SensorEventListener { + public fun (Lio/sentry/ILogger;)V + public fun close ()V + public fun onAccuracyChanged (Landroid/hardware/Sensor;I)V + public fun onSensorChanged (Landroid/hardware/SensorEvent;)V + public fun start (Landroid/content/Context;Lio/sentry/android/core/SentryShakeDetector$Listener;)V + public fun stop ()V +} + +public abstract interface class io/sentry/android/core/SentryShakeDetector$Listener { + public abstract fun onShake ()V +} + public class io/sentry/android/core/SentryUserFeedbackButton : android/widget/Button { public fun (Landroid/content/Context;)V public fun (Landroid/content/Context;Landroid/util/AttributeSet;)V @@ -415,23 +555,46 @@ public class io/sentry/android/core/SentryUserFeedbackButton : android/widget/Bu public fun setOnClickListener (Landroid/view/View$OnClickListener;)V } -public final class io/sentry/android/core/SentryUserFeedbackDialog : android/app/AlertDialog { - public fun setCancelable (Z)V - public fun setOnDismissListener (Landroid/content/DialogInterface$OnDismissListener;)V - public fun show ()V +public final class io/sentry/android/core/SentryUserFeedbackDialog : io/sentry/android/core/SentryUserFeedbackForm { } -public class io/sentry/android/core/SentryUserFeedbackDialog$Builder { +public class io/sentry/android/core/SentryUserFeedbackDialog$Builder : io/sentry/android/core/SentryUserFeedbackForm$Builder { public fun (Landroid/content/Context;)V public fun (Landroid/content/Context;I)V public fun (Landroid/content/Context;ILio/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration;)V public fun (Landroid/content/Context;Lio/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration;)V public fun associatedEventId (Lio/sentry/protocol/SentryId;)Lio/sentry/android/core/SentryUserFeedbackDialog$Builder; + public synthetic fun associatedEventId (Lio/sentry/protocol/SentryId;)Lio/sentry/android/core/SentryUserFeedbackForm$Builder; public fun configurator (Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)Lio/sentry/android/core/SentryUserFeedbackDialog$Builder; + public synthetic fun configurator (Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)Lio/sentry/android/core/SentryUserFeedbackForm$Builder; public fun create ()Lio/sentry/android/core/SentryUserFeedbackDialog; + public synthetic fun create ()Lio/sentry/android/core/SentryUserFeedbackForm; +} + +public abstract interface class io/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration : io/sentry/android/core/SentryUserFeedbackForm$OptionsConfiguration { +} + +public class io/sentry/android/core/SentryUserFeedbackForm : android/app/AlertDialog { + protected fun onCreate (Landroid/os/Bundle;)V + public fun onDetachedFromWindow ()V + protected fun onStart ()V + protected fun onStop ()V + public fun setCancelable (Z)V + public fun setOnDismissListener (Landroid/content/DialogInterface$OnDismissListener;)V + public fun show ()V } -public abstract interface class io/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration { +public class io/sentry/android/core/SentryUserFeedbackForm$Builder { + public fun (Landroid/content/Context;)V + public fun (Landroid/content/Context;I)V + public fun (Landroid/content/Context;ILio/sentry/android/core/SentryUserFeedbackForm$OptionsConfiguration;)V + public fun (Landroid/content/Context;Lio/sentry/android/core/SentryUserFeedbackForm$OptionsConfiguration;)V + public fun associatedEventId (Lio/sentry/protocol/SentryId;)Lio/sentry/android/core/SentryUserFeedbackForm$Builder; + public fun configurator (Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)Lio/sentry/android/core/SentryUserFeedbackForm$Builder; + public fun create ()Lio/sentry/android/core/SentryUserFeedbackForm; +} + +public abstract interface class io/sentry/android/core/SentryUserFeedbackForm$OptionsConfiguration { public abstract fun configure (Landroid/content/Context;Lio/sentry/SentryFeedbackOptions;)V } @@ -446,6 +609,7 @@ public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerfo public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : io/sentry/Integration, io/sentry/android/core/AppState$AppStateListener, java/io/Closeable { public fun (Landroid/content/Context;)V + public fun (Landroid/content/Context;Landroid/os/Handler;)V public fun (Landroid/content/Context;Ljava/util/List;)V public fun close ()V public static fun getDefaultActions ()Ljava/util/List; @@ -454,6 +618,29 @@ public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : i public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } +public class io/sentry/android/core/TombstoneIntegration : io/sentry/Integration, java/io/Closeable { + public fun (Landroid/content/Context;)V + public fun close ()V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V +} + +public final class io/sentry/android/core/TombstoneIntegration$TombstoneHint : io/sentry/hints/BlockingFlushHint, io/sentry/hints/Backfillable, io/sentry/hints/NativeCrashExit { + public fun (JLio/sentry/ILogger;JZ)V + public fun isFlushable (Lio/sentry/protocol/SentryId;)Z + public fun setFlushable (Lio/sentry/protocol/SentryId;)V + public fun shouldEnrich ()Z + public fun timestamp ()Ljava/lang/Long; +} + +public class io/sentry/android/core/TombstoneIntegration$TombstonePolicy : io/sentry/android/core/ApplicationExitInfoHistoryDispatcher$ApplicationExitInfoPolicy { + public fun (Lio/sentry/android/core/SentryAndroidOptions;Landroid/content/Context;)V + public fun buildReport (Landroid/app/ApplicationExitInfo;Z)Lio/sentry/android/core/ApplicationExitInfoHistoryDispatcher$Report; + public fun getLabel ()Ljava/lang/String; + public fun getLastReportedTimestamp ()Ljava/lang/Long; + public fun getTargetReason ()I + public fun shouldReportHistorical ()Z +} + public final class io/sentry/android/core/UserInteractionIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable { public fun (Landroid/app/Application;Lio/sentry/util/LoadClass;)V public fun close ()V @@ -479,12 +666,89 @@ public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentr public static fun snapshotViewHierarchyAsData (Landroid/app/Activity;Lio/sentry/util/thread/IThreadChecker;Lio/sentry/ISerializer;Lio/sentry/ILogger;)[B } +public class io/sentry/android/core/anr/AggregatedStackTrace { + public fun ([Ljava/lang/StackTraceElement;IIJF)V + public fun addOccurrence (J)V + public fun getStack ()[Ljava/lang/StackTraceElement; +} + +public class io/sentry/android/core/anr/AnrCulpritIdentifier { + public fun ()V + public static fun identify (Ljava/util/List;)Lio/sentry/android/core/anr/AggregatedStackTrace; + public static fun isSystemFrame (Ljava/lang/String;)Z +} + +public class io/sentry/android/core/anr/AnrProfile { + public final field endTimeMs J + public final field stacks Ljava/util/List; + public final field startTimeMs J + public fun (Ljava/util/List;)V +} + +public class io/sentry/android/core/anr/AnrProfileManager : java/lang/AutoCloseable { + public fun (Lio/sentry/SentryOptions;)V + public fun (Lio/sentry/SentryOptions;Ljava/io/File;)V + public fun add (Lio/sentry/android/core/anr/AnrStackTrace;)V + public fun clear ()V + public fun close ()V + public fun load ()Lio/sentry/android/core/anr/AnrProfile; +} + +public class io/sentry/android/core/anr/AnrProfileRotationHelper { + public fun ()V + public static fun deleteLastFile (Ljava/io/File;)Z + public static fun getFileForRecording (Ljava/io/File;)Ljava/io/File; + public static fun getLastFile (Ljava/io/File;)Ljava/io/File; + public static fun rotate ()V +} + +public class io/sentry/android/core/anr/AnrProfilingIntegration : io/sentry/Integration, io/sentry/android/core/AppState$AppStateListener, java/io/Closeable, java/lang/Runnable { + public static final field POLLING_INTERVAL_MS J + public static final field THRESHOLD_ANR_MS J + public fun ()V + protected fun checkMainThread (Ljava/lang/Thread;)V + public fun close ()V + protected fun getProfileManager ()Lio/sentry/android/core/anr/AnrProfileManager; + protected fun getState ()Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; + public fun onBackground ()V + public fun onForeground ()V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V + public fun run ()V +} + +protected final class io/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState : java/lang/Enum { + public static final field ANR_DETECTED Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; + public static final field IDLE Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; + public static final field SUSPICIOUS Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; + public static fun values ()[Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; +} + +public final class io/sentry/android/core/anr/AnrStackTrace : java/lang/Comparable { + public final field stack [Ljava/lang/StackTraceElement; + public final field timestampMs J + public fun (J[Ljava/lang/StackTraceElement;)V + public fun compareTo (Lio/sentry/android/core/anr/AnrStackTrace;)I + public synthetic fun compareTo (Ljava/lang/Object;)I + public static fun deserialize (Ljava/io/DataInputStream;)Lio/sentry/android/core/anr/AnrStackTrace; + public fun serialize (Ljava/io/DataOutputStream;)V +} + +public final class io/sentry/android/core/anr/StackTraceConverter { + public fun ()V + public static fun convert (Lio/sentry/android/core/anr/AnrProfile;)Lio/sentry/protocol/profiling/SentryProfile; +} + public final class io/sentry/android/core/cache/AndroidEnvelopeCache : io/sentry/cache/EnvelopeCache { + public static final field LAST_ANR_MARKER_LABEL Ljava/lang/String; public static final field LAST_ANR_REPORT Ljava/lang/String; + public static final field LAST_TOMBSTONE_MARKER_LABEL Ljava/lang/String; + public static final field LAST_TOMBSTONE_REPORT Ljava/lang/String; public fun (Lio/sentry/android/core/SentryAndroidOptions;)V public fun getDirectory ()Ljava/io/File; public static fun hasStartupCrashMarker (Lio/sentry/SentryOptions;)Z public static fun lastReportedAnr (Lio/sentry/SentryOptions;)Ljava/lang/Long; + public static fun lastReportedTombstone (Lio/sentry/SentryOptions;)Ljava/lang/Long; public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z } @@ -526,14 +790,22 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static final field staticLock Lio/sentry/util/AutoClosableReentrantLock; public fun ()V public fun addActivityLifecycleTimeSpans (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)V + public fun canExtendAppStart ()Z public fun clear ()V public fun createProcessInitSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getActivityLifecycleTimeSpans ()Ljava/util/List; + public fun getAppStartBaggageHeader ()Ljava/lang/String; public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler; + public fun getAppStartEndTime ()Lio/sentry/SentryDate; + public fun getAppStartExtension ()Lio/sentry/android/core/AppStartExtension; public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler; + public fun getAppStartReason ()Ljava/lang/String; public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision; + public fun getAppStartSentryTraceHeader ()Ljava/lang/String; public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTimeSpanForHeadless ()Lio/sentry/android/core/performance/TimeSpan; public fun getAppStartTimeSpanWithFallback (Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTraceId ()Lio/sentry/protocol/SentryId; public fun getAppStartType ()Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; public fun getApplicationOnCreateTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getClassLoadedUptimeMs ()J @@ -554,12 +826,19 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static fun onContentProviderPostCreate (Landroid/content/ContentProvider;)V public fun registerLifecycleCallbacks (Landroid/app/Application;)V public fun setAppLaunchedInForeground (Z)V + public fun setAppStartBaggageHeader (Ljava/lang/String;)V public fun setAppStartContinuousProfiler (Lio/sentry/IContinuousProfiler;)V + public fun setAppStartEndTime (Lio/sentry/SentryDate;)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V + public fun setAppStartSentryTraceHeader (Ljava/lang/String;)V + public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V + public fun setCachedStartInfo (Landroid/app/ApplicationStartInfo;)V public fun setClassLoadedUptimeMs (J)V + public fun setHeadlessAppStartListener (Lio/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener;)V public fun shouldSendStartMeasurements ()Z + public fun shouldSendStartMeasurements (Z)Z } public final class io/sentry/android/core/performance/AppStartMetrics$AppStartType : java/lang/Enum { @@ -570,6 +849,10 @@ public final class io/sentry/android/core/performance/AppStartMetrics$AppStartTy public static fun values ()[Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; } +public abstract interface class io/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener { + public abstract fun onHeadlessAppStart ()V +} + public class io/sentry/android/core/performance/TimeSpan : java/lang/Comparable { public fun ()V public fun compareTo (Lio/sentry/android/core/performance/TimeSpan;)I diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 99d6b5115c8..0e3708a89bf 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -1,11 +1,11 @@ import net.ltgt.gradle.errorprone.errorprone import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -34,13 +34,23 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + + kotlin { compilerOptions.jvmTarget = JVM_1_8 } testOptions { animationsDisabled = true unitTests.apply { isReturnDefaultValues = true isIncludeAndroidResources = true + // Robolectric loads the android-all jar into each test JVM, which needs more heap + // than the default. + all { + it.minHeapSize = "256m" + it.maxHeapSize = "2g" + } } } @@ -69,6 +79,13 @@ tasks.withType().configureEach { } } +// Snapshot PNGs are written by ScreenshotEventProcessorTest at runtime but must be declared as +// outputs so Gradle's build cache restores them on cache hits (otherwise the CLI upload step +// finds an empty directory). +tasks + .matching { it.name == "testReleaseUnitTest" } + .configureEach { outputs.dir(layout.buildDirectory.dir("test-snapshots")) } + dependencies { api(projects.sentry) compileOnly(libs.jetbrains.annotations) @@ -83,6 +100,7 @@ dependencies { implementation(libs.androidx.lifecycle.common.java8) implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.core) + implementation(libs.epitaph) errorprone(libs.errorprone.core) errorprone(libs.nopen.checker) @@ -97,15 +115,22 @@ dependencies { testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.test.runner) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(projects.sentryTestSupport) + testImplementation(projects.sentrySpotlight) testImplementation(projects.sentryAndroidFragment) testImplementation(projects.sentryAndroidTimber) testImplementation(projects.sentryAndroidReplay) testImplementation(projects.sentryCompose) testImplementation(projects.sentryAndroidNdk) - testRuntimeOnly(libs.androidx.compose.ui) + + testImplementation(libs.androidx.activity.compose) + testImplementation(libs.androidx.compose.ui) + testImplementation(libs.androidx.compose.foundation) + testImplementation(libs.androidx.compose.foundation.layout) + testImplementation(libs.androidx.compose.material3) testRuntimeOnly(libs.androidx.fragment.ktx) testRuntimeOnly(libs.timber) } diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 5ebad5ac0c8..a66e472b07c 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -1,7 +1,6 @@ ##---------------Begin: proguard configuration for android-core ---------- ##---------------Begin: proguard configuration for androidx.core ---------- --keep class androidx.core.view.GestureDetectorCompat { (...); } -keep class androidx.core.app.FrameMetricsAggregator { (...); } -keep interface androidx.core.view.ScrollingView { *; } ##---------------End: proguard configuration for androidx.core ---------- @@ -30,6 +29,11 @@ # https://developer.android.com/studio/build/shrink-code#decode-stack-trace -keepattributes LineNumberTable,SourceFile +# Preserve distinct runtime identities for custom Throwables. R8 horizontal class merging can +# otherwise merge unrelated exception classes, causing the runtime type and retraced frames to +# disagree. Unused Throwables may still be removed, and retained Throwables may still be obfuscated. +-keep,allowshrinking,allowobfuscation class * extends java.lang.Throwable + # Keep Classnames for integrations -keepnames class * implements io.sentry.Integration @@ -54,6 +58,7 @@ -keepnames class io.sentry.android.core.ApplicationNotResponding + ##---------------End: proguard configuration for android-core ---------- ##---------------Begin: proguard configuration for sentry-apollo-3 ---------- @@ -76,6 +81,10 @@ ##---------------Begin: proguard configuration for sentry-android-replay ---------- -dontwarn io.sentry.android.replay.ReplayIntegration -dontwarn io.sentry.android.replay.DefaultReplayBreadcrumbConverter +-dontwarn io.sentry.android.replay.util.MaskRenderer +-dontwarn io.sentry.android.replay.util.ViewsKt +-dontwarn io.sentry.android.replay.viewhierarchy.ViewHierarchyNode$Companion +-dontwarn io.sentry.android.replay.viewhierarchy.ViewHierarchyNode -keepnames class io.sentry.android.replay.ReplayIntegration ##---------------End: proguard configuration for sentry-android-replay ---------- @@ -83,3 +92,8 @@ -dontwarn io.sentry.android.distribution.DistributionIntegration -keepnames class io.sentry.android.distribution.DistributionIntegration ##---------------End: proguard configuration for sentry-android-distribution ---------- + +##---------------Begin: proguard configuration for sentry-spotlight ---------- +-dontwarn io.sentry.spotlight.SpotlightIntegration +-keepnames class io.sentry.spotlight.SpotlightIntegration +##---------------End: proguard configuration for sentry-spotlight ---------- diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityFramesTracker.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityFramesTracker.java index ade8fdd37c7..3895819fc94 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityFramesTracker.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityFramesTracker.java @@ -10,6 +10,7 @@ import io.sentry.protocol.MeasurementValue; import io.sentry.protocol.SentryId; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.LazyEvaluator; import java.util.HashMap; import java.util.Map; import java.util.WeakHashMap; @@ -30,7 +31,7 @@ */ public final class ActivityFramesTracker { - private @Nullable FrameMetricsAggregator frameMetricsAggregator = null; + private @NotNull LazyEvaluator frameMetricsAggregator; private @NotNull final SentryAndroidOptions options; private final @NotNull Map> @@ -41,17 +42,18 @@ public final class ActivityFramesTracker { private final @NotNull MainLooperHandler handler; protected @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private final @NotNull LazyEvaluator androidXAvailable; + public ActivityFramesTracker( final @NotNull io.sentry.util.LoadClass loadClass, final @NotNull SentryAndroidOptions options, final @NotNull MainLooperHandler handler) { - final boolean androidXAvailable = - loadClass.isClassAvailable("androidx.core.app.FrameMetricsAggregator", options.getLogger()); + androidXAvailable = + loadClass.isClassAvailableLazy( + "androidx.core.app.FrameMetricsAggregator", options.getLogger()); + frameMetricsAggregator = new LazyEvaluator<>(() -> new FrameMetricsAggregator()); - if (androidXAvailable) { - frameMetricsAggregator = new FrameMetricsAggregator(); - } this.options = options; this.handler = handler; } @@ -67,15 +69,15 @@ public ActivityFramesTracker( final @NotNull io.sentry.util.LoadClass loadClass, final @NotNull SentryAndroidOptions options, final @NotNull MainLooperHandler handler, - final @Nullable FrameMetricsAggregator frameMetricsAggregator) { + final @NotNull FrameMetricsAggregator frameMetricsAggregator) { this(loadClass, options, handler); - this.frameMetricsAggregator = frameMetricsAggregator; + this.frameMetricsAggregator = new LazyEvaluator<>(() -> frameMetricsAggregator); } @VisibleForTesting public boolean isFrameMetricsAggregatorAvailable() { - return frameMetricsAggregator != null + return androidXAvailable.getValue() && options.isEnableFramesTracking() && !options.isEnablePerformanceV2(); } @@ -87,7 +89,8 @@ public void addActivity(final @NotNull Activity activity) { return; } - runSafelyOnUiThread(() -> frameMetricsAggregator.add(activity), "FrameMetricsAggregator.add"); + runSafelyOnUiThread( + () -> frameMetricsAggregator.getValue().add(activity), "FrameMetricsAggregator.add"); snapshotFrameCountsAtStart(activity); } } @@ -104,11 +107,11 @@ private void snapshotFrameCountsAtStart(final @NotNull Activity activity) { return null; } - if (frameMetricsAggregator == null) { + if (!androidXAvailable.getValue()) { return null; } - final @Nullable SparseIntArray[] framesRates = frameMetricsAggregator.getMetrics(); + final @Nullable SparseIntArray[] framesRates = frameMetricsAggregator.getValue().getMetrics(); int totalFrames = 0; int slowFrames = 0; @@ -153,7 +156,7 @@ public void setMetrics(final @NotNull Activity activity, final @NotNull SentryId // there was no // Observers, See // https://android.googlesource.com/platform/frameworks/base/+/140ff5ea8e2d99edc3fbe63a43239e459334c76b - runSafelyOnUiThread(() -> frameMetricsAggregator.remove(activity), null); + runSafelyOnUiThread(() -> frameMetricsAggregator.getValue().remove(activity), null); final @Nullable FrameCounts frameCounts = diffFrameCountsAtEnd(activity); @@ -215,8 +218,9 @@ public void setMetrics(final @NotNull Activity activity, final @NotNull SentryId public void stop() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (isFrameMetricsAggregatorAvailable()) { - runSafelyOnUiThread(() -> frameMetricsAggregator.stop(), "FrameMetricsAggregator.stop"); - frameMetricsAggregator.reset(); + runSafelyOnUiThread( + () -> frameMetricsAggregator.getValue().stop(), "FrameMetricsAggregator.stop"); + frameMetricsAggregator.getValue().reset(); } activityMeasurements.clear(); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 9d748e5a27a..f416df6a988 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -9,6 +9,8 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import io.sentry.Baggage; +import io.sentry.BaggageHeader; import io.sentry.FullyDisplayedReporter; import io.sentry.IScope; import io.sentry.IScopes; @@ -18,6 +20,7 @@ import io.sentry.Instrumenter; import io.sentry.Integration; import io.sentry.NoOpTransaction; +import io.sentry.PropagationContext; import io.sentry.SentryDate; import io.sentry.SentryLevel; import io.sentry.SentryNanotimeDate; @@ -33,6 +36,7 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.MeasurementValue; +import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -40,7 +44,7 @@ import java.io.Closeable; import java.io.IOException; import java.lang.ref.WeakReference; -import java.util.Date; +import java.util.Collections; import java.util.Map; import java.util.WeakHashMap; import java.util.concurrent.Future; @@ -55,12 +59,22 @@ public final class ActivityLifecycleIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { static final String UI_LOAD_OP = "ui.load"; + static final String STANDALONE_APP_START_OP = "app.start"; + private static final String STANDALONE_APP_START_NAME = "App Start"; static final String APP_START_WARM = "app.start.warm"; static final String APP_START_COLD = "app.start.cold"; static final String TTID_OP = "ui.load.initial_display"; static final String TTFD_OP = "ui.load.full_display"; + static final String APP_START_EXTENDED_OP = "app.start.extended"; + static final String APP_START_EXTENDED_DESC = "Extended App Start"; static final long TTFD_TIMEOUT_MILLIS = 25000; + // If a headless app start and the following activity's ui.load are more than this far apart, they + // are treated as unrelated and not connected into the same trace. + static final long APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS = TimeUnit.MINUTES.toNanos(1); private static final String TRACE_ORIGIN = "auto.ui.activity"; + static final String APP_START_SCREEN_DATA = "app.vitals.start.screen"; + static final String APP_START_REASON_DATA = "app.vitals.start.reason"; + static final String APP_START_TRACE_ORIGIN = "auto.app.start"; private final @NotNull Application application; private final @NotNull BuildInfoProvider buildInfoProvider; @@ -77,11 +91,12 @@ public final class ActivityLifecycleIntegration private @Nullable FullyDisplayedReporter fullyDisplayedReporter = null; private @Nullable ISpan appStartSpan; + private @Nullable ITransaction appStartTransaction; private final @NotNull WeakHashMap ttidSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap ttfdSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap activitySpanHelpers = new WeakHashMap<>(); - private @NotNull SentryDate lastPausedTime = new SentryNanotimeDate(new Date(0), 0); + private @NotNull SentryDate lastPausedTime = new SentryNanotimeDate(0, 0); private @Nullable Future ttfdAutoCloseFuture = null; // WeakHashMap isn't thread safe but ActivityLifecycleCallbacks is only called from the @@ -124,6 +139,14 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions timeToFullDisplaySpanEnabled = this.options.isEnableTimeToFullDisplayTracing(); application.registerActivityLifecycleCallbacks(this); + + if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + metrics.setHeadlessAppStartListener(this::onHeadlessAppStart); + metrics.getAppStartExtension().setExtendAppStartListener(this::onExtendAppStartRequested); + addIntegrationToSdkVersion("StandaloneAppStart"); + } + this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); addIntegrationToSdkVersion("ActivityLifecycle"); } @@ -135,6 +158,9 @@ private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options @Override public void close() throws IOException { application.unregisterActivityLifecycleCallbacks(this); + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + metrics.setHeadlessAppStartListener(null); + metrics.getAppStartExtension().setExtendAppStartListener(null); if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration removed."); @@ -239,33 +265,111 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); setSpanOrigin(transactionOptions); - // we can only bind to the scope if there's no running transaction - ITransaction transaction = - scopes.startTransaction( - new TransactionContext( - activityName, - TransactionNameSource.COMPONENT, - UI_LOAD_OP, - appStartSamplingDecision), - transactionOptions); + // Guards the headless-start check below with !isExtensionActive so the eager extension's + // stored trace id isn't mistaken for a finished headless start. + final boolean isExtensionActive = + AppStartMetrics.getInstance().getAppStartExtension().isActive(); + + final @Nullable SentryId storedAppStartTraceId = + AppStartMetrics.getInstance().getAppStartTraceId(); + final boolean isFollowingHeadlessAppStart = + !isExtensionActive && (storedAppStartTraceId != null); + + final boolean isAppStart = + !(firstActivityCreated || appStartTime == null || coldStart == null); + final boolean createStandaloneAppStart = + isAppStart + && options.isEnableStandaloneAppStartTracing() + && !isFollowingHeadlessAppStart + && !isExtensionActive; + + if (createStandaloneAppStart) { + final TransactionOptions appStartTransactionOptions = new TransactionOptions(); + appStartTransactionOptions.setBindToScope(false); + appStartTransactionOptions.setStartTimestamp(appStartTime); + appStartTransactionOptions.setAppStartTransaction(appStartSamplingDecision != null); + appStartTransactionOptions.setOrigin(APP_START_TRACE_ORIGIN); + + appStartTransaction = + scopes.startTransaction( + new TransactionContext( + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + appStartSamplingDecision), + appStartTransactionOptions); + appStartTransaction.setData(APP_START_SCREEN_DATA, activityName); + final @Nullable String appStartReason = AppStartMetrics.getInstance().getAppStartReason(); + if (appStartReason != null) { + appStartTransaction.setData(APP_START_REASON_DATA, appStartReason); + } + } + + // Continue either the foreground app.start above or an earlier headless app.start. + final @Nullable String continueSentryTrace; + final @Nullable String continueBaggage; + if (createStandaloneAppStart) { + continueSentryTrace = appStartTransaction.toSentryTrace().getValue(); + final @Nullable BaggageHeader baggageHeader = appStartTransaction.toBaggageHeader(null); + continueBaggage = baggageHeader == null ? null : baggageHeader.getValue(); + } else if (isExtensionActive + || (isFollowingHeadlessAppStart && isWithinAppStartContinuationWindow(ttidStartTime))) { + continueSentryTrace = AppStartMetrics.getInstance().getAppStartSentryTraceHeader(); + continueBaggage = AppStartMetrics.getInstance().getAppStartBaggageHeader(); + } else { + continueSentryTrace = null; + continueBaggage = null; + } + + if (isExtensionActive && isAppStart) { + // Only the launch activity sets the screen, so a later activity can't overwrite it. A + // screen also keeps the processor from classifying the eager app.start as headless. + AppStartMetrics.getInstance() + .getAppStartExtension() + .setData(APP_START_SCREEN_DATA, activityName); + } + + final @Nullable TransactionContext continuedContext = + continueSentryTrace == null + ? null + : continueUiLoadTrace(continueSentryTrace, continueBaggage, activityName); + + final ITransaction transaction; + if (continuedContext != null) { + transaction = scopes.startTransaction(continuedContext, transactionOptions); + } else { + transaction = + scopes.startTransaction( + new TransactionContext( + activityName, + TransactionNameSource.COMPONENT, + UI_LOAD_OP, + appStartSamplingDecision), + transactionOptions); + } + + if (isFollowingHeadlessAppStart || isExtensionActive) { + // Consume the stored app-start trace so a later activity doesn't reuse it. + AppStartMetrics.getInstance().setAppStartTraceId(null); + AppStartMetrics.getInstance().setAppStartSentryTraceHeader(null); + AppStartMetrics.getInstance().setAppStartBaggageHeader(null); + } final SpanOptions spanOptions = new SpanOptions(); setSpanOrigin(spanOptions); - // in case appStartTime isn't available, we don't create a span for it. - if (!(firstActivityCreated || appStartTime == null || coldStart == null)) { - // start specific span for app start - appStartSpan = - transaction.startChild( - getAppStartOp(coldStart), - getAppStartDesc(coldStart), - appStartTime, - Instrumenter.SENTRY, - spanOptions); - - // in case there's already an end time (e.g. due to deferred SDK init) - // we can finish the app-start span - finishAppStartSpan(); + if (isAppStart) { + if (!createStandaloneAppStart && !options.isEnableStandaloneAppStartTracing()) { + appStartSpan = + transaction.startChild( + getAppStartOp(coldStart), + getAppStartDesc(coldStart), + appStartTime, + Instrumenter.SENTRY, + spanOptions); + + finishAppStartSpan(); + } } final @NotNull ISpan ttidSpan = transaction.startChild( @@ -316,6 +420,61 @@ private void setSpanOrigin(final @NotNull SpanOptions spanOptions) { spanOptions.setOrigin(TRACE_ORIGIN); } + /** + * Whether the ui.load starting at {@code uiLoadStartTime} is close enough in time to the headless + * app start to belong to the same trace. If they are more than {@link + * #APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS} apart, they are treated as unrelated. When + * the headless end time is unknown, we keep the previous behaviour and continue the trace. + */ + private boolean isWithinAppStartContinuationWindow(final @NotNull SentryDate uiLoadStartTime) { + final @Nullable SentryDate appStartEndTime = AppStartMetrics.getInstance().getAppStartEndTime(); + if (appStartEndTime == null) { + return true; + } + return uiLoadStartTime.diff(appStartEndTime) <= APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS; + } + + /** + * Builds a {@link TransactionContext} for the ui.load transaction that shares the standalone + * app.start trace (same traceId and sampleRand) while staying a sibling (no parentSpanId), rather + * than a child. The continued baggage keeps sampling decisions on the same sampleRand. Returns + * null if the trace cannot be continued, so callers can fall back. + */ + private @Nullable TransactionContext continueUiLoadTrace( + final @NotNull String sentryTrace, + final @Nullable String baggage, + final @NotNull String activityName) { + if (options == null || !options.isTracingEnabled()) { + return null; + } + final @NotNull PropagationContext propagationContext = + PropagationContext.fromHeaders( + options.getLogger(), + sentryTrace, + baggage == null ? null : Collections.singletonList(baggage), + options); + final @Nullable Boolean parentSampled = propagationContext.isSampled(); + final @NotNull Baggage continuedBaggage = propagationContext.getBaggage(); + final @Nullable TracesSamplingDecision parentSamplingDecision = + parentSampled == null + ? null + : new TracesSamplingDecision( + parentSampled, + continuedBaggage.getSampleRate(), + propagationContext.getSampleRand()); + final @NotNull TransactionContext context = + new TransactionContext( + propagationContext.getTraceId(), + propagationContext.getSpanId(), + null, + parentSamplingDecision, + continuedBaggage); + context.setName(activityName); + context.setTransactionNameSource(TransactionNameSource.COMPONENT); + context.setOperation(UI_LOAD_OP); + return context; + } + @VisibleForTesting void applyScope(final @NotNull IScope scope, final @NotNull ITransaction transaction) { scope.withTransaction( @@ -440,8 +599,7 @@ public void onActivityPostCreated( final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnCreateSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnCreateSpan(getAppStartParent(activity)); } } @@ -479,11 +637,11 @@ public void onActivityStarted(final @NotNull Activity activity) { public void onActivityPostStarted(final @NotNull Activity activity) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnStartSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnStartSpan(getAppStartParent(activity)); // Needed to handle hybrid SDKs helper.saveSpanToAppStartMetrics(); } + finishAppStartSpan(); } @Override @@ -559,6 +717,9 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid // memory leak finishSpan(appStartSpan, SpanStatus.CANCELLED); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.CANCELLED); + } // we finish the ttidSpan as cancelled in case it isn't completed yet final ISpan ttidSpan = ttidSpanMap.get(activity); @@ -575,6 +736,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // set it to null in case its been just finished as cancelled appStartSpan = null; + appStartTransaction = null; ttidSpanMap.remove(activity); ttfdSpanMap.remove(activity); } @@ -592,7 +754,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { private void clear() { firstActivityCreated = false; - lastPausedTime = new SentryNanotimeDate(new Date(0), 0); + lastPausedTime = new SentryNanotimeDate(0, 0); activitySpanHelpers.clear(); } @@ -637,22 +799,23 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); final @NotNull TimeSpan appStartTimeSpan = appStartMetrics.getAppStartTimeSpan(); final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); + final @Nullable SentryDate firstFrameEndDate = + options != null ? options.getDateProvider().now() : null; // and we need to set the end time of the app start here, after the first frame is drawn. if (appStartTimeSpan.hasStarted() && appStartTimeSpan.hasNotStopped()) { - appStartTimeSpan.stop(); + stopTimeSpanAtDate(appStartTimeSpan, firstFrameEndDate); } if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { - sdkInitTimeSpan.stop(); + stopTimeSpanAtDate(sdkInitTimeSpan, firstFrameEndDate); } - finishAppStartSpan(); + finishAppStartSpan(firstFrameEndDate); // Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization // with first frame drawn try (final @NotNull ISentryLifecycleToken ignored = fullyDisplayedLock.acquire()) { - if (options != null && ttidSpan != null) { - final SentryDate endDate = options.getDateProvider().now(); - final long durationNanos = endDate.diff(ttidSpan.getStartDate()); + if (options != null && ttidSpan != null && firstFrameEndDate != null) { + final long durationNanos = firstFrameEndDate.diff(ttidSpan.getStartDate()); final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); ttidSpan.setMeasurement( MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY, durationMillis, MILLISECOND); @@ -664,10 +827,10 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); ttfdSpan.setMeasurement( MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); - finishSpan(ttfdSpan, endDate); + finishSpan(ttfdSpan, firstFrameEndDate); } - finishSpan(ttidSpan, endDate); + finishSpan(ttidSpan, firstFrameEndDate); } else { finishSpan(ttidSpan); if (fullyDisplayedCalled) { @@ -677,6 +840,17 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I } } + private void stopTimeSpanAtDate( + final @NotNull TimeSpan timeSpan, final @Nullable SentryDate endDate) { + final @Nullable SentryDate startDate = timeSpan.getStartTimestamp(); + if (endDate != null && startDate != null) { + final long durationMillis = TimeUnit.NANOSECONDS.toMillis(endDate.diff(startDate)); + timeSpan.setStoppedAt(timeSpan.getStartUptimeMs() + durationMillis); + } else { + timeSpan.stop(); + } + } + private void onFullFrameDrawn(final @NotNull ISpan ttidSpan, final @NotNull ISpan ttfdSpan) { cancelTtfdAutoClose(); // Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization @@ -779,6 +953,16 @@ WeakHashMap getTtfdSpanMap() { } } + private @Nullable ISpan getAppStartParent(final @NotNull Activity activity) { + if (appStartTransaction != null) { + return appStartTransaction; + } + if (appStartSpan != null) { + return appStartSpan; + } + return activitiesWithOngoingTransactions.get(activity); + } + private @NotNull String getAppStartOp(final boolean coldStart) { if (coldStart) { return APP_START_COLD; @@ -788,12 +972,166 @@ WeakHashMap getTtfdSpanMap() { } private void finishAppStartSpan() { + finishAppStartSpan(null); + } + + private void finishAppStartSpan(final @Nullable SentryDate endDate) { final @Nullable SentryDate appStartEndTime = - AppStartMetrics.getInstance() - .getAppStartTimeSpanWithFallback(options) - .getProjectedStopTimestamp(); + endDate != null + ? endDate + : AppStartMetrics.getInstance() + .getAppStartTimeSpanWithFallback(options) + .getProjectedStopTimestamp(); if (performanceEnabled && appStartEndTime != null) { finishSpan(appStartSpan, appStartEndTime); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.OK, appStartEndTime); + } + // Finish the eager extended transaction at the natural first-frame end. waitForChildren keeps + // it open until the extended span finishes; no-op if the app start was not extended. + AppStartMetrics.getInstance().getAppStartExtension().finishTransaction(appStartEndTime); + } + } + + private void onHeadlessAppStart() { + if (scopes == null || options == null || !performanceEnabled) { + return; + } + + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + // Profilers are stopped for headless starts; clear the decision so it doesn't + // leak to a later ui.load transaction if an activity eventually opens. + metrics.setAppStartSamplingDecision(null); + + // For headless starts, appLaunchedInForeground is false, so we can't use + // getAppStartTimeSpanWithFallback (which gates on foreground). + final @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpanForHeadless(); + + if (!appStartTimeSpan.hasStarted() || !appStartTimeSpan.hasStopped()) { + return; + } + + final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp(); + final @Nullable SentryDate endTime = appStartTimeSpan.getProjectedStopTimestamp(); + if (startTime == null || endTime == null) { + return; + } + + // Persist the end time so a later ui.load can tell whether it is close enough to continue this + // trace; without it the continuation window is unbounded. + metrics.setAppStartEndTime(endTime); + + final @NotNull AppStartExtension extension = metrics.getAppStartExtension(); + if (extension.isActive()) { + extension.finishTransaction(endTime); + return; + } + if (!metrics.shouldSendStartMeasurements(true)) { + return; + } + + final @NotNull ITransaction transaction = + createStandaloneAppStartTransaction(startTime, null, false); + transaction.finish(SpanStatus.OK, endTime); + } + + /** + * Creates the standalone {@code app.start} transaction (not bound to the scope) and persists its + * trace headers so a later {@code ui.load} can share the same trace. Shared by the headless path + * and the eager extension path. When {@code holdOpenForExtension} is true, the transaction waits + * for its children and gets a deadline so it stays open until the extended span finishes. + */ + private @NotNull ITransaction createStandaloneAppStartTransaction( + final @NotNull SentryDate startTime, + final @Nullable TracesSamplingDecision samplingDecision, + final boolean holdOpenForExtension) { + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + + final TransactionOptions txnOptions = new TransactionOptions(); + txnOptions.setBindToScope(false); + txnOptions.setStartTimestamp(startTime); + txnOptions.setOrigin(APP_START_TRACE_ORIGIN); + txnOptions.setAppStartTransaction(samplingDecision != null); + if (holdOpenForExtension) { + txnOptions.setWaitForChildren(true); + final long deadlineTimeoutMillis = options.getDeadlineTimeout(); + txnOptions.setDeadlineTimeout(deadlineTimeoutMillis <= 0 ? null : deadlineTimeoutMillis); + // Persist the end time (covering every finish path: user finish, first frame, deadline) so a + // later ui.load can tell whether it is close enough to continue this trace; without it the + // continuation window is unbounded. + txnOptions.setTransactionFinishedCallback( + finishedTransaction -> + AppStartMetrics.getInstance() + .setAppStartEndTime(finishedTransaction.getFinishDate())); } + + final @NotNull TransactionContext txnContext = + new TransactionContext( + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + samplingDecision); + + final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); + final @Nullable String appStartReason = metrics.getAppStartReason(); + if (appStartReason != null) { + transaction.setData(APP_START_REASON_DATA, appStartReason); + } + metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId()); + // Persist trace headers so a later ui.load can share traceId and sampleRand. + metrics.setAppStartSentryTraceHeader(transaction.toSentryTrace().getValue()); + final @Nullable BaggageHeader baggageHeader = transaction.toBaggageHeader(null); + metrics.setAppStartBaggageHeader(baggageHeader == null ? null : baggageHeader.getValue()); + return transaction; + } + + /** + * Handles {@code Sentry.extendAppStart()}: eagerly creates the standalone app.start transaction + * and the extended child span (we have scopes here), then hands both to the {@link + * AppStartExtension}, which owns them. The transaction is held open ({@code waitForChildren}) + * until the user calls {@code Sentry.finishExtendedAppStart()} or the deadline forces it. + * Standalone-only: this is only registered as a listener when standalone app start tracing is + * enabled. + */ + private @Nullable AppStartExtension.ExtendedAppStart onExtendAppStartRequested() { + if (scopes == null + || options == null + || !performanceEnabled + || !options.isEnableStandaloneAppStartTracing()) { + return null; + } + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + + final @NotNull TimeSpan appStartTimeSpan = + metrics.getAppStartTimeSpan().hasStarted() + ? metrics.getAppStartTimeSpan() + : metrics.getSdkInitTimeSpan(); + final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp(); + if (startTime == null) { + return null; + } + + // The app start sampling decision was pre-rolled on the previous run so the app start + // profiler could start before Sentry.init. It forces the trace sampling of the eager + // app.start transaction created below (no re-roll, staying consistent with whether the + // profiler actually started) and lets it bind the app start profiler. It's single-use: + // we clear it so the first ui.load can't also claim it. + final @Nullable TracesSamplingDecision samplingDecision = metrics.getAppStartSamplingDecision(); + metrics.setAppStartSamplingDecision(null); + + final @NotNull ITransaction transaction = + createStandaloneAppStartTransaction(startTime, samplingDecision, true); + + final SpanOptions spanOptions = new SpanOptions(); + setSpanOrigin(spanOptions); + final @NotNull ISpan extendedSpan = + transaction.startChild( + APP_START_EXTENDED_OP, + APP_START_EXTENDED_DESC, + AndroidDateUtils.getCurrentSentryDateTime(), + Instrumenter.SENTRY, + spanOptions); + + return new AppStartExtension.ExtendedAppStart(transaction, extendedSpan); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java index 3087c876a94..a1c0c097cb9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java @@ -26,6 +26,7 @@ import io.sentry.protocol.SentryId; import io.sentry.transport.RateLimiter; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.LazyEvaluator; import io.sentry.util.SentryRandom; import java.util.ArrayList; import java.util.List; @@ -37,6 +38,11 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.VisibleForTesting; +/** + * Legacy Android implementation of {@link IContinuousProfiler}, using Android's {@code + * Debug.startMethodTracingSampling} See {@link PerfettoContinuousProfiler} for the new + * implementation using {@code ProfilingManager}, available on API 35+. + */ @ApiStatus.Internal public class AndroidContinuousProfiler implements IContinuousProfiler, RateLimiter.IRateLimitObserver { @@ -45,7 +51,7 @@ public class AndroidContinuousProfiler private final @NotNull ILogger logger; private final @Nullable String profilingTracesDirPath; private final int profilingTracesHz; - private final @NotNull ISentryExecutorService executorService; + private final @NotNull LazyEvaluator.Evaluator executorServiceSupplier; private final @NotNull BuildInfoProvider buildInfoProvider; private boolean isInitialized = false; private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; @@ -73,13 +79,13 @@ public AndroidContinuousProfiler( final @NotNull ILogger logger, final @Nullable String profilingTracesDirPath, final int profilingTracesHz, - final @NotNull ISentryExecutorService executorService) { + final @NotNull LazyEvaluator.Evaluator executorServiceSupplier) { this.logger = logger; this.frameMetricsCollector = frameMetricsCollector; this.buildInfoProvider = buildInfoProvider; this.profilingTracesDirPath = profilingTracesDirPath; this.profilingTracesHz = profilingTracesHz; - this.executorService = executorService; + this.executorServiceSupplier = executorServiceSupplier; } private void init() { @@ -190,6 +196,7 @@ private void start() { } // If device is offline, we don't start the profiler, to avoid flooding the cache + // TODO .getConnectionStatus() may be blocking, investigate if this can be done async if (scopes.getOptions().getConnectionStatusProvider().getConnectionStatus() == DISCONNECTED) { logger.log(SentryLevel.WARNING, "Device is offline. Stopping profiler."); // Let's stop and reset profiler id, as the profile is now broken anyway @@ -221,7 +228,8 @@ private void start() { } try { - stopFuture = executorService.schedule(() -> stop(true), MAX_CHUNK_DURATION_MILLIS); + stopFuture = + executorServiceSupplier.evaluate().schedule(() -> stop(true), MAX_CHUNK_DURATION_MILLIS); } catch (RejectedExecutionException e) { logger.log( SentryLevel.ERROR, 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..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 @@ -1,56 +1,42 @@ 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 +// 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 { + 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 +60,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; } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerBatchProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerBatchProcessor.java new file mode 100644 index 00000000000..13b12dc702a --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerBatchProcessor.java @@ -0,0 +1,47 @@ +package io.sentry.android.core; + +import io.sentry.ISentryClient; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.logger.LoggerBatchProcessor; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public final class AndroidLoggerBatchProcessor extends LoggerBatchProcessor + implements AppState.AppStateListener { + + public AndroidLoggerBatchProcessor( + @NotNull SentryOptions options, @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(LoggerBatchProcessor.FLUSH_AFTER_MS); + } + }); + } catch (Throwable t) { + options.getLogger().log(SentryLevel.ERROR, t, "Failed to submit log 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/AndroidLoggerBatchProcessorFactory.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerBatchProcessorFactory.java new file mode 100644 index 00000000000..694f94c7f7b --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerBatchProcessorFactory.java @@ -0,0 +1,15 @@ +package io.sentry.android.core; + +import io.sentry.SentryClient; +import io.sentry.SentryOptions; +import io.sentry.logger.ILoggerBatchProcessor; +import io.sentry.logger.ILoggerBatchProcessorFactory; +import org.jetbrains.annotations.NotNull; + +public final class AndroidLoggerBatchProcessorFactory implements ILoggerBatchProcessorFactory { + @Override + public @NotNull ILoggerBatchProcessor create( + @NotNull SentryOptions options, @NotNull SentryClient client) { + return new AndroidLoggerBatchProcessor(options, client); + } +} 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 70c76c72824..a0547a78b34 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 @@ -2,9 +2,11 @@ import static io.sentry.android.core.NdkIntegration.SENTRY_NDK_CLASS_NAME; +import android.annotation.SuppressLint; import android.app.Application; import android.content.Context; import android.content.pm.PackageInfo; +import android.os.Build; import io.sentry.CompositePerformanceCollector; import io.sentry.DeduplicateMultithreadedEventProcessor; import io.sentry.DefaultCompositePerformanceCollector; @@ -16,6 +18,7 @@ import io.sentry.NoOpCompositePerformanceCollector; import io.sentry.NoOpConnectionStatusProvider; import io.sentry.NoOpContinuousProfiler; +import io.sentry.NoOpReplayBreadcrumbConverter; import io.sentry.NoOpSocketTagger; import io.sentry.NoOpTransactionProfiler; import io.sentry.NoopVersionDetector; @@ -24,6 +27,8 @@ import io.sentry.SendFireAndForgetOutboxSender; import io.sentry.SentryLevel; import io.sentry.SentryOpenTelemetryMode; +import io.sentry.android.core.anr.AnrProfileRotationHelper; +import io.sentry.android.core.anr.AnrProfilingIntegration; import io.sentry.android.core.cache.AndroidEnvelopeCache; import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader; import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator; @@ -108,7 +113,7 @@ static void loadDefaultAndMetadataOptions( final @NotNull BuildInfoProvider buildInfoProvider) { Objects.requireNonNull(context, "The context is required."); - context = ContextUtils.getApplicationContext(context); + @NotNull final Context finalContext = ContextUtils.getApplicationContext(context); Objects.requireNonNull(options, "The options object is required."); Objects.requireNonNull(logger, "The ILogger object is required."); @@ -120,18 +125,24 @@ static void loadDefaultAndMetadataOptions( options.setDefaultScopeType(ScopeType.CURRENT); options.setOpenTelemetryMode(SentryOpenTelemetryMode.OFF); options.setDateProvider(new SentryAndroidDateProvider()); + 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); options.setFrameMetricsCollector( - new SentryFrameMetricsCollector(context, logger, buildInfoProvider)); + new SentryFrameMetricsCollector(finalContext, logger, buildInfoProvider)); - ManifestMetadataReader.applyMetadata(context, options, buildInfoProvider); - options.setCacheDirPath(getCacheDir(context).getAbsolutePath()); + ManifestMetadataReader.applyMetadata(finalContext, options, buildInfoProvider); - readDefaultOptionValues(options, context, buildInfoProvider); + options.setCacheDirPath(getCacheDir(finalContext).getAbsolutePath()); + + AnrProfileRotationHelper.rotate(); + + readDefaultOptionValues(options, finalContext, buildInfoProvider); AppState.getInstance().registerLifecycleObserver(options); + options.activate(); } @TestOnly @@ -139,13 +150,15 @@ static void initializeIntegrationsAndProcessors( final @NotNull SentryAndroidOptions options, final @NotNull Context context, final @NotNull io.sentry.util.LoadClass loadClass, - final @NotNull ActivityFramesTracker activityFramesTracker) { + final @NotNull ActivityFramesTracker activityFramesTracker, + final boolean isReplayAvailable) { initializeIntegrationsAndProcessors( options, context, new BuildInfoProvider(new AndroidLogger()), loadClass, - activityFramesTracker); + activityFramesTracker, + isReplayAvailable); } static void initializeIntegrationsAndProcessors( @@ -153,7 +166,8 @@ static void initializeIntegrationsAndProcessors( final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull io.sentry.util.LoadClass loadClass, - final @NotNull ActivityFramesTracker activityFramesTracker) { + final @NotNull ActivityFramesTracker activityFramesTracker, + final boolean isReplayAvailable) { if (options.getCacheDirPath() != null && options.getEnvelopeDiskCache() instanceof NoOpEnvelopeCache) { @@ -169,23 +183,31 @@ static void initializeIntegrationsAndProcessors( if (options.getCacheDirPath() != null) { options.addScopeObserver(new PersistingScopeObserver(options)); options.addOptionsObserver(new PersistingOptionsObserver(options)); + final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); + if (packageInfo != null && packageInfo.lastUpdateTime > 0) { + options.addOptionsObserver( + new PersistingOptionsCacheGenerationObserver(options, packageInfo.lastUpdateTime)); + } } options.addEventProcessor(new DeduplicateMultithreadedEventProcessor(options)); options.addEventProcessor( new DefaultAndroidEventProcessor(context, buildInfoProvider, options)); options.addEventProcessor(new PerformanceAndroidEventProcessor(options, activityFramesTracker)); - options.addEventProcessor(new ScreenshotEventProcessor(options, buildInfoProvider)); + options.addEventProcessor( + new ScreenshotEventProcessor(options, buildInfoProvider, isReplayAvailable)); options.addEventProcessor(new ViewHierarchyEventProcessor(options)); - options.addEventProcessor(new AnrV2EventProcessor(context, options, buildInfoProvider)); + options.addEventProcessor( + new ApplicationExitInfoEventProcessor(context, options, buildInfoProvider)); if (options.getTransportGate() instanceof NoOpTransportGate) { options.setTransportGate(new AndroidTransportGate(options)); } final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + options.setAppStartExtender(appStartMetrics.getAppStartExtension()); if (options.getModulesLoader() instanceof NoOpModulesLoader) { - options.setModulesLoader(new AssetsModulesLoader(context, options.getLogger())); + options.setModulesLoader(new AssetsModulesLoader(context, options)); } if (options.getDebugMetaLoader() instanceof NoOpDebugMetaLoader) { options.setDebugMetaLoader(new AssetsDebugMetaLoader(context, options.getLogger())); @@ -194,8 +216,8 @@ static void initializeIntegrationsAndProcessors( options.setVersionDetector(new DefaultVersionDetector(options)); } - final boolean isAndroidXScrollViewAvailable = - loadClass.isClassAvailable("androidx.core.view.ScrollingView", options); + final @NotNull LazyEvaluator isAndroidXScrollViewAvailable = + loadClass.isClassAvailableLazy("androidx.core.view.ScrollingView", options); final boolean isComposeUpstreamAvailable = loadClass.isClassAvailable(COMPOSE_CLASS_NAME, options); @@ -230,6 +252,7 @@ static void initializeIntegrationsAndProcessors( if (options.getSocketTagger() instanceof NoOpSocketTagger) { options.setSocketTagger(AndroidSocketTagger.getInstance()); } + if (options.getPerformanceCollectors().isEmpty()) { options.addPerformanceCollector(new AndroidMemoryCollector()); options.addPerformanceCollector(new AndroidCpuCollector(options.getLogger())); @@ -247,6 +270,14 @@ static void initializeIntegrationsAndProcessors( options.setCompositePerformanceCollector(new DefaultCompositePerformanceCollector(options)); } + if (isReplayAvailable + && options.getReplayController().getBreadcrumbConverter() + instanceof NoOpReplayBreadcrumbConverter) { + options + .getReplayController() + .setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter(options)); + } + // Check if the profiler was already instantiated in the app start. // We use the Android profiler, that uses a global start/stop api, so we need to preserve the // state of the profiler, and it's only possible retaining the instance. @@ -269,6 +300,7 @@ static void initializeIntegrationsAndProcessors( } /** Setup the correct profiler (transaction or continuous) based on the options. */ + @SuppressLint("NewApi") private static void setupProfiler( final @NotNull SentryAndroidOptions options, final @NotNull Context context, @@ -278,6 +310,28 @@ private static void setupProfiler( final @NotNull CompositePerformanceCollector performanceCollector) { if (options.isProfilingEnabled() || options.getProfilesSampleRate() != null) { options.setContinuousProfiler(NoOpContinuousProfiler.getInstance()); + // Transaction-based profiling always relies on the legacy Debug-based profiler, so it is + // disabled together with legacy profiling. Perfetto profiling only supports continuous + // profiling. + if (!options.isEnableLegacyProfiling()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Transaction-based profiling (profilesSampleRate/profilesSampler) is disabled " + + "because enableLegacyProfiling is false. Transaction-based profiling always " + + "uses the legacy profiler and is not supported by Perfetto. No profiling " + + "data will be collected. Use profileSessionSampleRate for continuous " + + "profiling instead."); + options.setTransactionProfiler(NoOpTransactionProfiler.getInstance()); + if (appStartTransactionProfiler != null) { + appStartTransactionProfiler.close(); + } + if (appStartContinuousProfiler != null) { + appStartContinuousProfiler.close(true); + } + return; + } // This is a safeguard, but it should never happen, as the app start profiler should be the // continuous one. if (appStartContinuousProfiler != null) { @@ -311,16 +365,36 @@ private static void setupProfiler( performanceCollector.start(chunkId.toString()); } } else { - options.setContinuousProfiler( - new AndroidContinuousProfiler( - buildInfoProvider, - Objects.requireNonNull( - options.getFrameMetricsCollector(), - "options.getFrameMetricsCollector is required"), - options.getLogger(), - options.getProfilingTracesDirPath(), - options.getProfilingTracesHz(), - options.getExecutorService())); + final @NotNull SentryFrameMetricsCollector frameMetricsCollector = + Objects.requireNonNull( + options.getFrameMetricsCollector(), "options.getFrameMetricsCollector is required"); + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + final @NotNull Context appContext = ContextUtils.getApplicationContext(context); + options.setContinuousProfiler( + new PerfettoContinuousProfiler( + options.getLogger(), + frameMetricsCollector, + () -> options.getExecutorService(), + () -> + new PerfettoProfiler( + appContext, options.getLogger(), options.getExecutorService()))); + } else if (options.isEnableLegacyProfiling()) { + options.setContinuousProfiler( + new AndroidContinuousProfiler( + buildInfoProvider, + frameMetricsCollector, + options.getLogger(), + options.getProfilingTracesDirPath(), + options.getProfilingTracesHz(), + () -> options.getExecutorService())); + } else { + options + .getLogger() + .log( + SentryLevel.WARNING, + "enableLegacyProfiling is disabled and device is below API 35. " + + "No profiling data will be collected."); + } } } } @@ -354,6 +428,10 @@ static void installDefaultIntegrations( final Class sentryNdkClass = loadClass.loadClass(SENTRY_NDK_CLASS_NAME, options.getLogger()); options.addIntegration(new NdkIntegration(sentryNdkClass)); + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.S) { + options.addIntegration(new TombstoneIntegration(context)); + } + // this integration uses android.os.FileObserver, we can't move to sentry // before creating a pure java impl. options.addIntegration(EnvelopeFileObserverIntegration.getOutboxFileObserver()); @@ -373,6 +451,8 @@ static void installDefaultIntegrations( // it to set the replayId in case of an ANR options.addIntegration(AnrIntegrationFactory.create(context, buildInfoProvider)); + options.addIntegration(new AnrProfilingIntegration()); + // registerActivityLifecycleCallbacks is only available if Context is an AppContext if (context instanceof Application) { options.addIntegration( @@ -380,6 +460,7 @@ static void installDefaultIntegrations( (Application) context, buildInfoProvider, activityFramesTracker)); options.addIntegration(new ActivityBreadcrumbsIntegration((Application) context)); options.addIntegration(new UserInteractionIntegration((Application) context, loadClass)); + options.addIntegration(new FeedbackShakeIntegration((Application) context)); if (isFragmentAvailable) { options.addIntegration(new FragmentLifecycleIntegration((Application) context, true, true)); } @@ -400,7 +481,6 @@ static void installDefaultIntegrations( if (isReplayAvailable) { final ReplayIntegration replay = new ReplayIntegration(context, CurrentDateProvider.getInstance()); - replay.setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter()); options.addIntegration(replay); options.setReplayController(replay); } @@ -411,7 +491,7 @@ static void installDefaultIntegrations( } options .getFeedbackOptions() - .setDialogHandler(new SentryAndroidOptions.AndroidUserFeedbackIDialogHandler()); + .setFormHandler(new SentryAndroidOptions.AndroidUserFeedbackFormHandler()); } /** diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java index c6772529816..3f569df5378 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java @@ -16,6 +16,7 @@ import io.sentry.profilemeasurements.ProfileMeasurement; import io.sentry.profilemeasurements.ProfileMeasurementValue; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.LazyEvaluator; import io.sentry.util.Objects; import java.io.File; import java.util.ArrayDeque; @@ -92,23 +93,25 @@ public ProfileEndData( private final @NotNull ArrayDeque frozenFrameRenderMeasurements = new ArrayDeque<>(); private final @NotNull Map measurementsMap = new HashMap<>(); - private final @Nullable ISentryExecutorService timeoutExecutorService; + private final @Nullable LazyEvaluator.Evaluator + timeoutExecutorServiceSupplier; private final @NotNull ILogger logger; - private boolean isRunning = false; + private volatile boolean isRunning = false; protected final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); public AndroidProfiler( final @NotNull String tracesFilesDirPath, final int intervalUs, final @NotNull SentryFrameMetricsCollector frameMetricsCollector, - final @Nullable ISentryExecutorService timeoutExecutorService, + final @Nullable LazyEvaluator.Evaluator + timeoutExecutorServiceSupplier, final @NotNull ILogger logger) { this.traceFilesDir = new File(Objects.requireNonNull(tracesFilesDirPath, "TracesFilesDirPath is required")); this.intervalUs = intervalUs; this.logger = Objects.requireNonNull(logger, "Logger is required"); // Timeout executor is nullable, as timeouts will not be there for continuous profiling - this.timeoutExecutorService = timeoutExecutorService; + this.timeoutExecutorServiceSupplier = timeoutExecutorServiceSupplier; this.frameMetricsCollector = Objects.requireNonNull(frameMetricsCollector, "SentryFrameMetricsCollector is required"); } @@ -185,10 +188,11 @@ public void onFrameMetricCollected( // We stop profiling after a timeout to avoid huge profiles to be sent try { - if (timeoutExecutorService != null) { + if (timeoutExecutorServiceSupplier != null) { scheduledFinish = - timeoutExecutorService.schedule( - () -> endAndCollect(true, null), PROFILING_TIMEOUT_MILLIS); + timeoutExecutorServiceSupplier + .evaluate() + .schedule(() -> endAndCollect(true, null), PROFILING_TIMEOUT_MILLIS); } } catch (RejectedExecutionException e) { logger.log( @@ -318,21 +322,21 @@ private void putPerformanceCollectionDataInMeasurements( for (final @NotNull PerformanceCollectionData data : performanceCollectionData) { final long nanoTimestamp = data.getNanoTimestamp(); final long relativeStartNs = nanoTimestamp + timestampDiff; - final @Nullable Double cpuUsagePercentage = data.getCpuUsagePercentage(); - final @Nullable Long usedHeapMemory = data.getUsedHeapMemory(); - final @Nullable Long usedNativeMemory = data.getUsedNativeMemory(); - if (cpuUsagePercentage != null) { + if (data.hasCpuUsagePercentage()) { cpuUsageMeasurements.add( - new ProfileMeasurementValue(relativeStartNs, cpuUsagePercentage, nanoTimestamp)); + new ProfileMeasurementValue( + relativeStartNs, data.getCpuUsagePercentage(), nanoTimestamp)); } - if (usedHeapMemory != null) { + if (data.hasUsedHeapMemory()) { memoryUsageMeasurements.add( - new ProfileMeasurementValue(relativeStartNs, usedHeapMemory, nanoTimestamp)); + new ProfileMeasurementValue( + relativeStartNs, data.getUsedHeapMemory(), nanoTimestamp)); } - if (usedNativeMemory != null) { + if (data.hasUsedNativeMemory()) { nativeMemoryUsageMeasurements.add( - new ProfileMeasurementValue(relativeStartNs, usedNativeMemory, nanoTimestamp)); + new ProfileMeasurementValue( + relativeStartNs, data.getUsedNativeMemory(), nanoTimestamp)); } } } @@ -354,4 +358,8 @@ private void putPerformanceCollectionDataInMeasurements( } } } + + boolean isRunning() { + return isRunning; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java index 0aa678d5195..e44ed746a08 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java @@ -5,8 +5,6 @@ import android.annotation.SuppressLint; import android.content.Context; import android.os.Build; -import android.os.Process; -import android.os.SystemClock; import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.ISentryExecutorService; @@ -22,13 +20,14 @@ import io.sentry.android.core.internal.util.CpuInfoUtils; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.LazyEvaluator; import io.sentry.util.Objects; import java.util.ArrayList; import java.util.Date; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; final class AndroidTransactionProfiler implements ITransactionProfiler { private final @NotNull Context context; @@ -36,13 +35,19 @@ final class AndroidTransactionProfiler implements ITransactionProfiler { private final @Nullable String profilingTracesDirPath; private final boolean isProfilingEnabled; private final int profilingTracesHz; - private final @NotNull ISentryExecutorService executorService; + private final @NotNull LazyEvaluator.Evaluator executorServiceSupplier; private final @NotNull BuildInfoProvider buildInfoProvider; private boolean isInitialized = false; - private int transactionsCounter = 0; + private final @NotNull AtomicBoolean isRunning = new AtomicBoolean(false); private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; - private @Nullable ProfilingTransactionData currentProfilingTransactionData; - private @Nullable AndroidProfiler profiler = null; + private volatile @Nullable ProfilingTransactionData currentProfilingTransactionData; + + /** + * The underlying profiler instance. It is thread safe to call it after checking if it's not null, + * because we never nullify it after instantiation. + */ + private volatile @Nullable AndroidProfiler profiler = null; + private long profileStartNanos; private long profileStartCpuMillis; private @NotNull Date profileStartTimestamp; @@ -61,7 +66,7 @@ public AndroidTransactionProfiler( sentryAndroidOptions.getProfilingTracesDirPath(), sentryAndroidOptions.isProfilingEnabled(), sentryAndroidOptions.getProfilingTracesHz(), - sentryAndroidOptions.getExecutorService()); + () -> sentryAndroidOptions.getExecutorService()); } public AndroidTransactionProfiler( @@ -73,6 +78,26 @@ public AndroidTransactionProfiler( final boolean isProfilingEnabled, final int profilingTracesHz, final @NotNull ISentryExecutorService executorService) { + this( + context, + buildInfoProvider, + frameMetricsCollector, + logger, + profilingTracesDirPath, + isProfilingEnabled, + profilingTracesHz, + () -> executorService); + } + + public AndroidTransactionProfiler( + final @NotNull Context context, + final @NotNull BuildInfoProvider buildInfoProvider, + final @NotNull SentryFrameMetricsCollector frameMetricsCollector, + final @NotNull ILogger logger, + final @Nullable String profilingTracesDirPath, + final boolean isProfilingEnabled, + final int profilingTracesHz, + final @NotNull LazyEvaluator.Evaluator executorServiceSupplier) { this.context = Objects.requireNonNull( ContextUtils.getApplicationContext(context), "The application context is required"); @@ -84,8 +109,9 @@ public AndroidTransactionProfiler( this.profilingTracesDirPath = profilingTracesDirPath; this.isProfilingEnabled = isProfilingEnabled; this.profilingTracesHz = profilingTracesHz; - this.executorService = - Objects.requireNonNull(executorService, "The ISentryExecutorService is required."); + this.executorServiceSupplier = + Objects.requireNonNull( + executorServiceSupplier, "A supplier for ISentryExecutorService is required."); this.profileStartTimestamp = DateUtils.getCurrentDateTime(); } @@ -95,6 +121,7 @@ private void init() { return; } isInitialized = true; + if (!isProfilingEnabled) { logger.log(SentryLevel.INFO, "Profiling is disabled in options."); return; @@ -118,28 +145,36 @@ private void init() { profilingTracesDirPath, (int) SECONDS.toMicros(1) / profilingTracesHz, frameMetricsCollector, - executorService, + executorServiceSupplier, logger); } @Override public void start() { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - // Debug.startMethodTracingSampling() is only available since Lollipop, but Android Profiler - // causes crashes on api 21 -> https://github.com/getsentry/sentry-java/issues/3392 - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP_MR1) return; + // Debug.startMethodTracingSampling() is only available since Lollipop, but Android Profiler + // causes crashes on api 21 -> https://github.com/getsentry/sentry-java/issues/3392 + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP_MR1) return; + // When the first transaction is starting, we can start profiling + if (!isRunning.getAndSet(true)) { // Let's initialize trace folder and profiling interval init(); - transactionsCounter++; - // When the first transaction is starting, we can start profiling - if (transactionsCounter == 1 && onFirstStart()) { + if (onFirstStart()) { logger.log(SentryLevel.DEBUG, "Profiler started."); } else { - transactionsCounter--; - logger.log( - SentryLevel.WARNING, "A profile is already running. This profile will be ignored."); + // If profiler is not null and is running, it means that a profile is already running + if (profiler != null && profiler.isRunning()) { + logger.log( + SentryLevel.WARNING, "A profile is already running. This profile will be ignored."); + } else { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + // Ensure we unbind any transaction data, just in case of concurrent starts + currentProfilingTransactionData = null; + } + // Otherwise we update the flag, because it means the profiler is not running + isRunning.set(false); + } } } } @@ -164,11 +199,14 @@ private boolean onFirstStart() { @Override public void bindTransaction(final @NotNull ITransaction transaction) { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - // If the profiler is running, but no profilingTransactionData is set, we bind it here - if (transactionsCounter > 0 && currentProfilingTransactionData == null) { - currentProfilingTransactionData = - new ProfilingTransactionData(transaction, profileStartNanos, profileStartCpuMillis); + // If the profiler is running, but no profilingTransactionData is set, we bind it here + if (isRunning.get() && currentProfilingTransactionData == null) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + // If the profiler is running, but no profilingTransactionData is set, we bind it here + if (isRunning.get() && currentProfilingTransactionData == null) { + currentProfilingTransactionData = + new ProfilingTransactionData(transaction, profileStartNanos, profileStartCpuMillis); + } } } } @@ -178,15 +216,13 @@ public void bindTransaction(final @NotNull ITransaction transaction) { final @NotNull ITransaction transaction, final @Nullable List performanceCollectionData, final @NotNull SentryOptions options) { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - return onTransactionFinish( - transaction.getName(), - transaction.getEventId().toString(), - transaction.getSpanContext().getTraceId().toString(), - false, - performanceCollectionData, - options); - } + return onTransactionFinish( + transaction.getName(), + transaction.getEventId().toString(), + transaction.getSpanContext().getTraceId().toString(), + false, + performanceCollectionData, + options); } @SuppressLint("NewApi") @@ -197,20 +233,23 @@ public void bindTransaction(final @NotNull ITransaction transaction) { final boolean isTimeout, final @Nullable List performanceCollectionData, final @NotNull SentryOptions options) { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - // check if profiler was created - if (profiler == null) { - return null; - } - // onTransactionStart() is only available since Lollipop_MR1 - // and SystemClock.elapsedRealtimeNanos() since Jelly Bean - // and SUPPORTED_ABIS since KITKAT - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP_MR1) return null; + // onTransactionStart() is only available since Lollipop_MR1 + // and SystemClock.elapsedRealtimeNanos() since Jelly Bean + // and SUPPORTED_ABIS since KITKAT + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP_MR1) return null; + + // check if profiler was created + if (profiler == null) { + return null; + } + + final ProfilingTransactionData txData; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + txData = currentProfilingTransactionData; // Transaction finished, but it's not in the current profile - if (currentProfilingTransactionData == null - || !currentProfilingTransactionData.getId().equals(transactionId)) { + if (txData == null || !txData.getId().equals(transactionId)) { // A transaction is finishing, but it's not profiled. We can skip it logger.log( SentryLevel.INFO, @@ -219,118 +258,90 @@ public void bindTransaction(final @NotNull ITransaction transaction) { traceId); return null; } + currentProfilingTransactionData = null; + } - if (transactionsCounter > 0) { - transactionsCounter--; - } + logger.log(SentryLevel.DEBUG, "Transaction %s (%s) finished.", transactionName, traceId); - logger.log(SentryLevel.DEBUG, "Transaction %s (%s) finished.", transactionName, traceId); + final AndroidProfiler.ProfileEndData endData = + profiler.endAndCollect(false, performanceCollectionData); - if (transactionsCounter != 0) { - // We notify the data referring to this transaction that it finished - if (currentProfilingTransactionData != null) { - currentProfilingTransactionData.notifyFinish( - SystemClock.elapsedRealtimeNanos(), - profileStartNanos, - Process.getElapsedCpuTime(), - profileStartCpuMillis); - } - return null; - } - - final AndroidProfiler.ProfileEndData endData = - profiler.endAndCollect(false, performanceCollectionData); - // check if profiler end successfully - if (endData == null) { - return null; - } + isRunning.set(false); - long transactionDurationNanos = endData.endNanos - profileStartNanos; + // check if profiler end successfully + if (endData == null) { + return null; + } - List transactionList = new ArrayList<>(1); - final ProfilingTransactionData txData = currentProfilingTransactionData; - if (txData != null) { - transactionList.add(txData); - } - currentProfilingTransactionData = null; - // We clear the counter in case of a timeout - transactionsCounter = 0; - - String totalMem = "0"; - final @Nullable Long memory = - (options instanceof SentryAndroidOptions) - ? DeviceInfoUtil.getInstance(context, (SentryAndroidOptions) options).getTotalMemory() - : null; - if (memory != null) { - totalMem = Long.toString(memory); - } - String[] abis = Build.SUPPORTED_ABIS; + long transactionDurationNanos = endData.endNanos - profileStartNanos; - // We notify all transactions data that all transactions finished. - // Some may not have been really finished, in case of a timeout - for (ProfilingTransactionData t : transactionList) { - t.notifyFinish( - endData.endNanos, profileStartNanos, endData.endCpuMillis, profileStartCpuMillis); - } + final @NotNull List transactionList = new ArrayList<>(1); + transactionList.add(txData); + txData.notifyFinish( + endData.endNanos, profileStartNanos, endData.endCpuMillis, profileStartCpuMillis); - // cpu max frequencies are read with a lambda because reading files is involved, so it will be - // done in the background when the trace file is read - return new ProfilingTraceData( - endData.traceFile, - profileStartTimestamp, - transactionList, - transactionName, - transactionId, - traceId, - Long.toString(transactionDurationNanos), - buildInfoProvider.getSdkInfoVersion(), - abis != null && abis.length > 0 ? abis[0] : "", - () -> CpuInfoUtils.getInstance().readMaxFrequencies(), - buildInfoProvider.getManufacturer(), - buildInfoProvider.getModel(), - buildInfoProvider.getVersionRelease(), - buildInfoProvider.isEmulator(), - totalMem, - options.getProguardUuid(), - options.getRelease(), - options.getEnvironment(), - (endData.didTimeout || isTimeout) - ? ProfilingTraceData.TRUNCATION_REASON_TIMEOUT - : ProfilingTraceData.TRUNCATION_REASON_NORMAL, - endData.measurementsMap); + String totalMem = "0"; + final @Nullable Long memory = + (options instanceof SentryAndroidOptions) + ? DeviceInfoUtil.getInstance(context, (SentryAndroidOptions) options).getTotalMemory() + : null; + if (memory != null) { + totalMem = Long.toString(memory); } + final String[] abis = Build.SUPPORTED_ABIS; + + // cpu max frequencies are read with a lambda because reading files is involved, so it will be + // done in the background when the trace file is read + return new ProfilingTraceData( + endData.traceFile, + profileStartTimestamp, + transactionList, + transactionName, + transactionId, + traceId, + Long.toString(transactionDurationNanos), + buildInfoProvider.getSdkInfoVersion(), + abis != null && abis.length > 0 ? abis[0] : "", + () -> CpuInfoUtils.getInstance().readMaxFrequencies(), + buildInfoProvider.getManufacturer(), + buildInfoProvider.getModel(), + buildInfoProvider.getVersionRelease(), + buildInfoProvider.isEmulator(), + totalMem, + options.getProguardUuid(), + options.getRelease(), + options.getEnvironment(), + (endData.didTimeout || isTimeout) + ? ProfilingTraceData.TRUNCATION_REASON_TIMEOUT + : ProfilingTraceData.TRUNCATION_REASON_NORMAL, + endData.measurementsMap); } @Override public boolean isRunning() { - return transactionsCounter != 0; + return isRunning.get(); } @Override public void close() { + final @Nullable ProfilingTransactionData txData = currentProfilingTransactionData; // we stop profiling - if (currentProfilingTransactionData != null) { + if (txData != null) { onTransactionFinish( - currentProfilingTransactionData.getName(), - currentProfilingTransactionData.getId(), - currentProfilingTransactionData.getTraceId(), + txData.getName(), + txData.getId(), + txData.getTraceId(), true, null, ScopesAdapter.getInstance().getOptions()); - } else if (transactionsCounter != 0) { - // in case the app start profiling is running, and it's not bound to a transaction, we still - // stop profiling, but we also have to manually update the counter. - transactionsCounter--; } + // in case the app start profiling is running, and it's not bound to a transaction, we still + // stop profiling, but we also have to manually update the flag. + isRunning.set(false); // we have to first stop profiling otherwise we would lost the last profile if (profiler != null) { profiler.close(); } } - - @TestOnly - int getTransactionsCounter() { - return transactionsCounter; - } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java index 8243493a50b..f37d433d308 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java @@ -139,11 +139,18 @@ void reportANR( message = "Background " + message; } - final ApplicationNotResponding error = new ApplicationNotResponding(message, anr.getThread()); + final @Nullable Thread thread = anr.getThread(); + final @NotNull ApplicationNotResponding error; + if (thread == null) { + error = new ApplicationNotResponding(message); + } else { + error = new ApplicationNotResponding(message, thread); + } + final Mechanism mechanism = new Mechanism(); mechanism.setType("ANR"); - return new ExceptionMechanismException(mechanism, error, error.getThread(), true); + return new ExceptionMechanismException(mechanism, error, thread, true); } @TestOnly diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java deleted file mode 100644 index 4710b2506da..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ /dev/null @@ -1,704 +0,0 @@ -package io.sentry.android.core; - -import static io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME; -import static io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME; -import static io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME; -import static io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME; -import static io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME; -import static io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME; -import static io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME; -import static io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME; -import static io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME; -import static io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME; -import static io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME; -import static io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME; -import static io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME; -import static io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME; -import static io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME; -import static io.sentry.cache.PersistingScopeObserver.USER_FILENAME; -import static io.sentry.protocol.Contexts.REPLAY_ID; - -import android.annotation.SuppressLint; -import android.app.ActivityManager; -import android.content.Context; -import android.content.pm.PackageInfo; -import android.os.Build; -import android.util.DisplayMetrics; -import androidx.annotation.WorkerThread; -import io.sentry.BackfillingEventProcessor; -import io.sentry.Breadcrumb; -import io.sentry.Hint; -import io.sentry.IpAddressUtils; -import io.sentry.SentryBaseEvent; -import io.sentry.SentryEvent; -import io.sentry.SentryExceptionFactory; -import io.sentry.SentryLevel; -import io.sentry.SentryOptions; -import io.sentry.SentryStackTraceFactory; -import io.sentry.SpanContext; -import io.sentry.android.core.internal.util.CpuInfoUtils; -import io.sentry.cache.PersistingOptionsObserver; -import io.sentry.cache.PersistingScopeObserver; -import io.sentry.hints.AbnormalExit; -import io.sentry.hints.Backfillable; -import io.sentry.protocol.App; -import io.sentry.protocol.Contexts; -import io.sentry.protocol.DebugImage; -import io.sentry.protocol.DebugMeta; -import io.sentry.protocol.Device; -import io.sentry.protocol.Mechanism; -import io.sentry.protocol.OperatingSystem; -import io.sentry.protocol.Request; -import io.sentry.protocol.SdkVersion; -import io.sentry.protocol.SentryStackTrace; -import io.sentry.protocol.SentryThread; -import io.sentry.protocol.SentryTransaction; -import io.sentry.protocol.User; -import io.sentry.util.HintUtils; -import io.sentry.util.SentryRandom; -import java.io.File; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** - * AnrV2Integration processes events on a background thread, hence the event processors will also be - * invoked on the same background thread, so we can safely read data from disk synchronously. - */ -@ApiStatus.Internal -@WorkerThread -public final class AnrV2EventProcessor implements BackfillingEventProcessor { - - private final @NotNull Context context; - - private final @NotNull SentryAndroidOptions options; - - private final @NotNull BuildInfoProvider buildInfoProvider; - - private final @NotNull SentryExceptionFactory sentryExceptionFactory; - - private final @Nullable PersistingScopeObserver persistingScopeObserver; - - public AnrV2EventProcessor( - final @NotNull Context context, - final @NotNull SentryAndroidOptions options, - final @NotNull BuildInfoProvider buildInfoProvider) { - this.context = ContextUtils.getApplicationContext(context); - this.options = options; - this.buildInfoProvider = buildInfoProvider; - this.persistingScopeObserver = options.findPersistingScopeObserver(); - - final SentryStackTraceFactory sentryStackTraceFactory = - new SentryStackTraceFactory(this.options); - - sentryExceptionFactory = new SentryExceptionFactory(sentryStackTraceFactory); - } - - @Override - public @NotNull SentryTransaction process( - @NotNull SentryTransaction transaction, @NotNull Hint hint) { - // that's only necessary because on newer versions of Unity, if not overriding this method, it's - // throwing 'java.lang.AbstractMethodError: abstract method' and the reason is probably - // compilation mismatch - return transaction; - } - - @Override - public @Nullable SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { - final Object unwrappedHint = HintUtils.getSentrySdkHint(hint); - if (!(unwrappedHint instanceof Backfillable)) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "The event is not Backfillable, but has been passed to BackfillingEventProcessor, skipping."); - return event; - } - - // we always set exception values, platform, os and device even if the ANR is not enrich-able - // even though the OS context may change in the meantime (OS update), we consider this an - // edge-case - setExceptions(event, unwrappedHint); - setPlatform(event); - mergeOS(event); - setDevice(event); - - if (!((Backfillable) unwrappedHint).shouldEnrich()) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "The event is Backfillable, but should not be enriched, skipping."); - return event; - } - - backfillScope(event, unwrappedHint); - - backfillOptions(event, unwrappedHint); - - setStaticValues(event); - - return event; - } - - // region scope persisted values - private void backfillScope(final @NotNull SentryEvent event, final @NotNull Object hint) { - setRequest(event); - setUser(event); - setScopeTags(event); - setBreadcrumbs(event); - setExtras(event); - setContexts(event); - setTransaction(event); - setFingerprints(event, hint); - setLevel(event); - setTrace(event); - setReplayId(event); - } - - private boolean sampleReplay(final @NotNull SentryEvent event) { - final @Nullable String replayErrorSampleRate = - PersistingOptionsObserver.read(options, REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class); - - if (replayErrorSampleRate == null) { - return false; - } - - try { - // we have to sample here with the old sample rate, because it may change between app launches - final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate); - if (replayErrorSampleRateDouble < SentryRandom.current().nextDouble()) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Not capturing replay for ANR %s due to not being sampled.", - event.getEventId()); - return false; - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error parsing replay sample rate.", e); - return false; - } - - return true; - } - - private void setReplayId(final @NotNull SentryEvent event) { - @Nullable String persistedReplayId = readFromDisk(options, REPLAY_FILENAME, String.class); - final @NotNull File replayFolder = - new File(options.getCacheDirPath(), "replay_" + persistedReplayId); - if (!replayFolder.exists()) { - if (!sampleReplay(event)) { - return; - } - // if the replay folder does not exist (e.g. running in buffer mode), we need to find the - // latest replay folder that was modified before the ANR event. - persistedReplayId = null; - long lastModified = Long.MIN_VALUE; - final File[] dirs = new File(options.getCacheDirPath()).listFiles(); - if (dirs != null) { - for (File dir : dirs) { - if (dir.isDirectory() && dir.getName().startsWith("replay_")) { - if (dir.lastModified() > lastModified - && dir.lastModified() <= event.getTimestamp().getTime()) { - lastModified = dir.lastModified(); - persistedReplayId = dir.getName().substring("replay_".length()); - } - } - } - } - } - - if (persistedReplayId == null) { - return; - } - - // store the relevant replayId so ReplayIntegration can pick it up and finalize that replay - PersistingScopeObserver.store(options, persistedReplayId, REPLAY_FILENAME); - event.getContexts().put(REPLAY_ID, persistedReplayId); - } - - private void setTrace(final @NotNull SentryEvent event) { - final SpanContext spanContext = readFromDisk(options, TRACE_FILENAME, SpanContext.class); - if (event.getContexts().getTrace() == null) { - if (spanContext != null - && spanContext.getSpanId() != null - && spanContext.getTraceId() != null) { - event.getContexts().setTrace(spanContext); - } - } - } - - private void setLevel(final @NotNull SentryEvent event) { - final SentryLevel level = readFromDisk(options, LEVEL_FILENAME, SentryLevel.class); - if (event.getLevel() == null) { - event.setLevel(level); - } - } - - @SuppressWarnings("unchecked") - private void setFingerprints(final @NotNull SentryEvent event, final @NotNull Object hint) { - final List fingerprint = - (List) readFromDisk(options, FINGERPRINT_FILENAME, List.class); - if (event.getFingerprints() == null) { - event.setFingerprints(fingerprint); - } - - // sentry does not yet have a capability to provide default server-side fingerprint rules, - // so we're doing this on the SDK side to group background and foreground ANRs separately - // even if they have similar stacktraces - final boolean isBackgroundAnr = isBackgroundAnr(hint); - if (event.getFingerprints() == null) { - event.setFingerprints( - Arrays.asList("{{ default }}", isBackgroundAnr ? "background-anr" : "foreground-anr")); - } - } - - private void setTransaction(final @NotNull SentryEvent event) { - final String transaction = readFromDisk(options, TRANSACTION_FILENAME, String.class); - if (event.getTransaction() == null) { - event.setTransaction(transaction); - } - } - - private void setContexts(final @NotNull SentryBaseEvent event) { - final Contexts persistedContexts = readFromDisk(options, CONTEXTS_FILENAME, Contexts.class); - if (persistedContexts == null) { - return; - } - final Contexts eventContexts = event.getContexts(); - for (Map.Entry entry : new Contexts(persistedContexts).entrySet()) { - final Object value = entry.getValue(); - if (SpanContext.TYPE.equals(entry.getKey()) && value instanceof SpanContext) { - // we fill it in setTrace later on - continue; - } - if (!eventContexts.containsKey(entry.getKey())) { - eventContexts.put(entry.getKey(), value); - } - } - } - - @SuppressWarnings("unchecked") - private void setExtras(final @NotNull SentryBaseEvent event) { - final Map extras = - (Map) readFromDisk(options, EXTRAS_FILENAME, Map.class); - if (extras == null) { - return; - } - if (event.getExtras() == null) { - event.setExtras(new HashMap<>(extras)); - } else { - for (Map.Entry item : extras.entrySet()) { - if (!event.getExtras().containsKey(item.getKey())) { - event.getExtras().put(item.getKey(), item.getValue()); - } - } - } - } - - @SuppressWarnings("unchecked") - private void setBreadcrumbs(final @NotNull SentryBaseEvent event) { - final List breadcrumbs = - (List) readFromDisk(options, BREADCRUMBS_FILENAME, List.class); - if (breadcrumbs == null) { - return; - } - if (event.getBreadcrumbs() == null) { - event.setBreadcrumbs(breadcrumbs); - } else { - event.getBreadcrumbs().addAll(breadcrumbs); - } - } - - @SuppressWarnings("unchecked") - private void setScopeTags(final @NotNull SentryBaseEvent event) { - final Map tags = - (Map) - readFromDisk(options, PersistingScopeObserver.TAGS_FILENAME, Map.class); - if (tags == null) { - return; - } - if (event.getTags() == null) { - event.setTags(new HashMap<>(tags)); - } else { - for (Map.Entry item : tags.entrySet()) { - if (!event.getTags().containsKey(item.getKey())) { - event.setTag(item.getKey(), item.getValue()); - } - } - } - } - - private void setUser(final @NotNull SentryBaseEvent event) { - if (event.getUser() == null) { - final User user = readFromDisk(options, USER_FILENAME, User.class); - event.setUser(user); - } - } - - private void setRequest(final @NotNull SentryBaseEvent event) { - if (event.getRequest() == null) { - final Request request = readFromDisk(options, REQUEST_FILENAME, Request.class); - event.setRequest(request); - } - } - - private @Nullable T readFromDisk( - final @NotNull SentryOptions options, - final @NotNull String fileName, - final @NotNull Class clazz) { - if (persistingScopeObserver == null) { - return null; - } - - return persistingScopeObserver.read(options, fileName, clazz); - } - - // endregion - - // region options persisted values - private void backfillOptions(final @NotNull SentryEvent event, final @NotNull Object hint) { - setRelease(event); - setEnvironment(event); - setDist(event); - setDebugMeta(event); - setSdk(event); - setApp(event, hint); - setOptionsTags(event); - } - - private void setApp(final @NotNull SentryBaseEvent event, final @NotNull Object hint) { - App app = event.getContexts().getApp(); - if (app == null) { - app = new App(); - } - app.setAppName(ContextUtils.getApplicationName(context)); - // TODO: not entirely correct, because we define background ANRs as not the ones of - // IMPORTANCE_FOREGROUND, but this doesn't mean the app was in foreground when an ANR happened - // but it's our best effort for now. We could serialize AppState in theory. - app.setInForeground(!isBackgroundAnr(hint)); - - final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); - if (packageInfo != null) { - app.setAppIdentifier(packageInfo.packageName); - } - - // backfill versionName and versionCode from the persisted release string - final String release = - event.getRelease() != null - ? event.getRelease() - : PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); - if (release != null) { - try { - final String versionName = - release.substring(release.indexOf('@') + 1, release.indexOf('+')); - final String versionCode = release.substring(release.indexOf('+') + 1); - app.setAppVersion(versionName); - app.setAppBuild(versionCode); - } catch (Throwable e) { - options - .getLogger() - .log(SentryLevel.WARNING, "Failed to parse release from scope cache: %s", release); - } - } - - try { - final ContextUtils.SplitApksInfo splitApksInfo = - DeviceInfoUtil.getInstance(context, options).getSplitApksInfo(); - if (splitApksInfo != null) { - app.setSplitApks(splitApksInfo.isSplitApks()); - if (splitApksInfo.getSplitNames() != null) { - app.setSplitNames(Arrays.asList(splitApksInfo.getSplitNames())); - } - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting split apks info.", e); - } - - event.getContexts().setApp(app); - } - - private void setRelease(final @NotNull SentryBaseEvent event) { - if (event.getRelease() == null) { - final String release = - PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); - event.setRelease(release); - } - } - - private void setEnvironment(final @NotNull SentryBaseEvent event) { - if (event.getEnvironment() == null) { - final String environment = - PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String.class); - event.setEnvironment(environment != null ? environment : options.getEnvironment()); - } - } - - private void setDebugMeta(final @NotNull SentryBaseEvent event) { - DebugMeta debugMeta = event.getDebugMeta(); - - if (debugMeta == null) { - debugMeta = new DebugMeta(); - } - if (debugMeta.getImages() == null) { - debugMeta.setImages(new ArrayList<>()); - } - List images = debugMeta.getImages(); - if (images != null) { - final String proguardUuid = - PersistingOptionsObserver.read(options, PROGUARD_UUID_FILENAME, String.class); - - if (proguardUuid != null) { - final DebugImage debugImage = new DebugImage(); - debugImage.setType(DebugImage.PROGUARD); - debugImage.setUuid(proguardUuid); - images.add(debugImage); - } - event.setDebugMeta(debugMeta); - } - } - - private void setDist(final @NotNull SentryBaseEvent event) { - if (event.getDist() == null) { - final String dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String.class); - event.setDist(dist); - } - // if there's no user-set dist, fall back to versionCode from the persisted release string - if (event.getDist() == null) { - final String release = - PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); - if (release != null) { - try { - final String versionCode = release.substring(release.indexOf('+') + 1); - event.setDist(versionCode); - } catch (Throwable e) { - options - .getLogger() - .log(SentryLevel.WARNING, "Failed to parse release from scope cache: %s", release); - } - } - } - } - - private void setSdk(final @NotNull SentryBaseEvent event) { - if (event.getSdk() == null) { - final SdkVersion sdkVersion = - PersistingOptionsObserver.read(options, SDK_VERSION_FILENAME, SdkVersion.class); - event.setSdk(sdkVersion); - } - } - - @SuppressWarnings("unchecked") - private void setOptionsTags(final @NotNull SentryBaseEvent event) { - final Map tags = - (Map) - PersistingOptionsObserver.read( - options, PersistingOptionsObserver.TAGS_FILENAME, Map.class); - if (tags == null) { - return; - } - if (event.getTags() == null) { - event.setTags(new HashMap<>(tags)); - } else { - for (Map.Entry item : tags.entrySet()) { - if (!event.getTags().containsKey(item.getKey())) { - event.setTag(item.getKey(), item.getValue()); - } - } - } - } - - // endregion - - @Override - public @Nullable Long getOrder() { - return 12000L; - } - - // region static values - private void setStaticValues(final @NotNull SentryEvent event) { - mergeUser(event); - setSideLoadedInfo(event); - } - - private void setPlatform(final @NotNull SentryBaseEvent event) { - if (event.getPlatform() == null) { - // this actually means JVM related. - event.setPlatform(SentryBaseEvent.DEFAULT_PLATFORM); - } - } - - @Nullable - private SentryThread findMainThread(final @Nullable List threads) { - if (threads != null) { - for (SentryThread thread : threads) { - final String name = thread.getName(); - if (name != null && name.equals("main")) { - return thread; - } - } - } - return null; - } - - // by default we assume that the ANR is foreground, unless abnormalMechanism is "anr_background" - private boolean isBackgroundAnr(final @NotNull Object hint) { - if (hint instanceof AbnormalExit) { - final String abnormalMechanism = ((AbnormalExit) hint).mechanism(); - return "anr_background".equals(abnormalMechanism); - } - return false; - } - - private void setExceptions(final @NotNull SentryEvent event, final @NotNull Object hint) { - // AnrV2 threads contain a thread dump from the OS, so we just search for the main thread dump - // and make an exception out of its stacktrace - final Mechanism mechanism = new Mechanism(); - if (!((Backfillable) hint).shouldEnrich()) { - // we only enrich the latest ANR in the list, so this is historical - mechanism.setType("HistoricalAppExitInfo"); - } else { - mechanism.setType("AppExitInfo"); - } - - final boolean isBackgroundAnr = isBackgroundAnr(hint); - String message = "ANR"; - if (isBackgroundAnr) { - message = "Background " + message; - } - final ApplicationNotResponding anr = - new ApplicationNotResponding(message, Thread.currentThread()); - - SentryThread mainThread = findMainThread(event.getThreads()); - if (mainThread == null) { - // if there's no main thread in the event threads, we just create a dummy thread so the - // exception is properly created as well, but without stacktrace - mainThread = new SentryThread(); - mainThread.setStacktrace(new SentryStackTrace()); - } - event.setExceptions( - sentryExceptionFactory.getSentryExceptionsFromThread(mainThread, mechanism, anr)); - } - - private void mergeUser(final @NotNull SentryBaseEvent event) { - @Nullable User user = event.getUser(); - if (user == null) { - user = new User(); - event.setUser(user); - } - - // userId should be set even if event is Cached as the userId is static and won't change anyway. - if (user.getId() == null) { - user.setId(getDeviceId()); - } - if (user.getIpAddress() == null && options.isSendDefaultPii()) { - user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); - } - } - - private @Nullable String getDeviceId() { - try { - return Installation.id(context); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting installationId.", e); - } - return null; - } - - private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { - try { - final ContextUtils.SideLoadedInfo sideLoadedInfo = - DeviceInfoUtil.getInstance(context, options).getSideLoadedInfo(); - if (sideLoadedInfo != null) { - final @NotNull Map tags = sideLoadedInfo.asTags(); - for (Map.Entry entry : tags.entrySet()) { - event.setTag(entry.getKey(), entry.getValue()); - } - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting side loaded info.", e); - } - } - - private void setDevice(final @NotNull SentryBaseEvent event) { - if (event.getContexts().getDevice() == null) { - event.getContexts().setDevice(getDevice()); - } - } - - // only use static data that does not change between app launches (e.g. timezone, boottime, - // battery level will change) - @SuppressLint("NewApi") - private @NotNull Device getDevice() { - Device device = new Device(); - device.setManufacturer(Build.MANUFACTURER); - device.setBrand(Build.BRAND); - device.setFamily(ContextUtils.getFamily(options.getLogger())); - device.setModel(Build.MODEL); - device.setModelId(Build.ID); - device.setArchs(ContextUtils.getArchitectures()); - - final ActivityManager.MemoryInfo memInfo = - ContextUtils.getMemInfo(context, options.getLogger()); - if (memInfo != null) { - // in bytes - device.setMemorySize(getMemorySize(memInfo)); - } - - device.setSimulator(buildInfoProvider.isEmulator()); - - DisplayMetrics displayMetrics = ContextUtils.getDisplayMetrics(context, options.getLogger()); - if (displayMetrics != null) { - device.setScreenWidthPixels(displayMetrics.widthPixels); - device.setScreenHeightPixels(displayMetrics.heightPixels); - device.setScreenDensity(displayMetrics.density); - device.setScreenDpi(displayMetrics.densityDpi); - } - - if (device.getId() == null) { - device.setId(getDeviceId()); - } - - final @NotNull List cpuFrequencies = CpuInfoUtils.getInstance().readMaxFrequencies(); - if (!cpuFrequencies.isEmpty()) { - device.setProcessorFrequency(Collections.max(cpuFrequencies).doubleValue()); - device.setProcessorCount(cpuFrequencies.size()); - } - - return device; - } - - private @NotNull Long getMemorySize(final @NotNull ActivityManager.MemoryInfo memInfo) { - return memInfo.totalMem; - } - - private void mergeOS(final @NotNull SentryBaseEvent event) { - final OperatingSystem currentOS = event.getContexts().getOperatingSystem(); - final OperatingSystem androidOS = - DeviceInfoUtil.getInstance(context, options).getOperatingSystem(); - - // make Android OS the main OS using the 'os' key - event.getContexts().setOperatingSystem(androidOS); - - if (currentOS != null) { - // add additional OS which was already part of the SentryEvent (eg Linux read from NDK) - String osNameKey = currentOS.getName(); - if (osNameKey != null && !osNameKey.isEmpty()) { - osNameKey = "os_" + osNameKey.trim().toLowerCase(Locale.ROOT); - } else { - osNameKey = "os_1"; - } - event.getContexts().put(osNameKey, currentOS); - } - } - // endregion -} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index c6d47cadcb4..285c3b77ade 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -18,11 +18,11 @@ import io.sentry.android.core.cache.AndroidEnvelopeCache; import io.sentry.android.core.internal.threaddump.Lines; import io.sentry.android.core.internal.threaddump.ThreadDumpParser; -import io.sentry.cache.EnvelopeCache; -import io.sentry.cache.IEnvelopeCache; +import io.sentry.android.core.internal.util.NativeEventUtils; import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; import io.sentry.hints.BlockingFlushHint; +import io.sentry.protocol.ArtContext; import io.sentry.protocol.DebugImage; import io.sentry.protocol.DebugMeta; import io.sentry.protocol.Message; @@ -34,15 +34,11 @@ import io.sentry.util.Objects; import java.io.BufferedReader; import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; -import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -50,9 +46,6 @@ @SuppressLint("NewApi") // we check this in AnrIntegrationFactory public class AnrV2Integration implements Integration, Closeable { - // using 91 to avoid timezone change hassle, 90 days is how long Sentry keeps the events - static final long NINETY_DAYS_THRESHOLD = TimeUnit.DAYS.toMillis(91); - private final @NotNull Context context; private final @NotNull ICurrentDateProvider dateProvider; private @Nullable SentryAndroidOptions options; @@ -92,9 +85,11 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { try { options .getExecutorService() - .submit(new AnrProcessor(context, scopes, this.options, dateProvider)); + .submit( + new ApplicationExitInfoHistoryDispatcher( + context, scopes, this.options, dateProvider, new AnrV2Policy(this.options))); } catch (Throwable e) { - options.getLogger().log(SentryLevel.DEBUG, "Failed to start AnrProcessor.", e); + options.getLogger().log(SentryLevel.DEBUG, "Failed to start ANR processor.", e); } options.getLogger().log(SentryLevel.DEBUG, "AnrV2Integration installed."); addIntegrationToSdkVersion("AnrV2"); @@ -108,132 +103,37 @@ public void close() throws IOException { } } - static class AnrProcessor implements Runnable { + private static final class AnrV2Policy + implements ApplicationExitInfoHistoryDispatcher.ApplicationExitInfoPolicy { - private final @NotNull Context context; - private final @NotNull IScopes scopes; private final @NotNull SentryAndroidOptions options; - private final long threshold; - - AnrProcessor( - final @NotNull Context context, - final @NotNull IScopes scopes, - final @NotNull SentryAndroidOptions options, - final @NotNull ICurrentDateProvider dateProvider) { - this.context = context; - this.scopes = scopes; + + AnrV2Policy(final @NotNull SentryAndroidOptions options) { this.options = options; - this.threshold = dateProvider.getCurrentTimeMillis() - NINETY_DAYS_THRESHOLD; } - @SuppressLint("NewApi") // we check this in AnrIntegrationFactory @Override - public void run() { - final ActivityManager activityManager = - (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); - - final List applicationExitInfoList = - activityManager.getHistoricalProcessExitReasons(null, 0, 0); - if (applicationExitInfoList.size() == 0) { - options.getLogger().log(SentryLevel.DEBUG, "No records in historical exit reasons."); - return; - } - - final IEnvelopeCache cache = options.getEnvelopeDiskCache(); - if (cache instanceof EnvelopeCache) { - if (options.isEnableAutoSessionTracking() - && !((EnvelopeCache) cache).waitPreviousSessionFlush()) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "Timed out waiting to flush previous session to its own file."); - - // if we timed out waiting here, we can already flush the latch, because the timeout is - // big - // enough to wait for it only once and we don't have to wait again in - // PreviousSessionFinalizer - ((EnvelopeCache) cache).flushPreviousSession(); - } - } - - // making a deep copy as we're modifying the list - final List exitInfos = new ArrayList<>(applicationExitInfoList); - final @Nullable Long lastReportedAnrTimestamp = AndroidEnvelopeCache.lastReportedAnr(options); - - // search for the latest ANR to report it separately as we're gonna enrich it. The latest - // ANR will be first in the list, as it's filled last-to-first in order of appearance - ApplicationExitInfo latestAnr = null; - for (ApplicationExitInfo applicationExitInfo : exitInfos) { - if (applicationExitInfo.getReason() == ApplicationExitInfo.REASON_ANR) { - latestAnr = applicationExitInfo; - // remove it, so it's not reported twice - exitInfos.remove(applicationExitInfo); - break; - } - } - - if (latestAnr == null) { - options - .getLogger() - .log(SentryLevel.DEBUG, "No ANRs have been found in the historical exit reasons list."); - return; - } - - if (latestAnr.getTimestamp() < threshold) { - options - .getLogger() - .log(SentryLevel.DEBUG, "Latest ANR happened too long ago, returning early."); - return; - } - - if (lastReportedAnrTimestamp != null - && latestAnr.getTimestamp() <= lastReportedAnrTimestamp) { - options - .getLogger() - .log(SentryLevel.DEBUG, "Latest ANR has already been reported, returning early."); - return; - } + public @NotNull String getLabel() { + return "ANR"; + } - if (options.isReportHistoricalAnrs()) { - // report the remainder without enriching - reportNonEnrichedHistoricalAnrs(exitInfos, lastReportedAnrTimestamp); - } + @Override + public int getTargetReason() { + return ApplicationExitInfo.REASON_ANR; + } - // report the latest ANR with enriching, if contexts are available, otherwise report it - // non-enriched - reportAsSentryEvent(latestAnr, true); + @Override + public boolean shouldReportHistorical() { + return options.isReportHistoricalAnrs(); } - private void reportNonEnrichedHistoricalAnrs( - final @NotNull List exitInfos, final @Nullable Long lastReportedAnr) { - // we reverse the list, because the OS puts errors in order of appearance, last-to-first - // and we want to write a marker file after each ANR has been processed, so in case the app - // gets killed meanwhile, we can proceed from the last reported ANR and not process the entire - // list again - Collections.reverse(exitInfos); - for (ApplicationExitInfo applicationExitInfo : exitInfos) { - if (applicationExitInfo.getReason() == ApplicationExitInfo.REASON_ANR) { - if (applicationExitInfo.getTimestamp() < threshold) { - options - .getLogger() - .log(SentryLevel.DEBUG, "ANR happened too long ago %s.", applicationExitInfo); - continue; - } - - if (lastReportedAnr != null && applicationExitInfo.getTimestamp() <= lastReportedAnr) { - options - .getLogger() - .log(SentryLevel.DEBUG, "ANR has already been reported %s.", applicationExitInfo); - continue; - } - - reportAsSentryEvent(applicationExitInfo, false); // do not enrich past events - } - } + @Override + public @Nullable Long getLastReportedTimestamp() { + return AndroidEnvelopeCache.lastReportedAnr(options); } - private void reportAsSentryEvent( + @Override + public @Nullable ApplicationExitInfoHistoryDispatcher.Report buildReport( final @NotNull ApplicationExitInfo exitInfo, final boolean shouldEnrich) { final long anrTimestamp = exitInfo.getTimestamp(); final boolean isBackground = @@ -247,7 +147,7 @@ private void reportAsSentryEvent( SentryLevel.WARNING, "Not reporting ANR event as there was no thread dump for the ANR %s", exitInfo.toString()); - return; + return null; } final AnrV2Hint anrHint = new AnrV2Hint( @@ -274,6 +174,9 @@ private void reportAsSentryEvent( debugMeta.setImages(result.debugImages); event.setDebugMeta(debugMeta); } + if (result.artContext != null) { + event.getContexts().setArt(result.artContext); + } } event.setLevel(SentryLevel.FATAL); event.setTimestamp(DateUtils.getDateTime(anrTimestamp)); @@ -284,19 +187,7 @@ private void reportAsSentryEvent( } } - final @NotNull SentryId sentryId = scopes.captureEvent(event, hint); - final boolean isEventDropped = sentryId.equals(SentryId.EMPTY_ID); - if (!isEventDropped) { - // Block until the event is flushed to disk and the last_reported_anr marker is updated - if (!anrHint.waitFlush()) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "Timed out waiting to flush ANR event to disk. Event: %s", - event.getEventId()); - } - } + return new ApplicationExitInfoHistoryDispatcher.Report(event, hint, anrHint); } private @NotNull ParseResult parseThreadDump( @@ -307,7 +198,7 @@ private void reportAsSentryEvent( if (trace == null) { return new ParseResult(ParseResult.Type.NO_DUMP); } - dump = getDumpBytes(trace); + dump = NativeEventUtils.readBytes(trace); } catch (Throwable e) { options.getLogger().log(SentryLevel.WARNING, "Failed to read ANR thread dump", e); return new ParseResult(ParseResult.Type.NO_DUMP); @@ -322,6 +213,7 @@ private void reportAsSentryEvent( final @NotNull List threads = threadDumpParser.getThreads(); final @NotNull List debugImages = threadDumpParser.getDebugImages(); + final @Nullable ArtContext artContext = threadDumpParser.getArtContext(); if (threads.isEmpty()) { // if the list is empty this means the system failed to capture a proper thread dump of @@ -330,26 +222,12 @@ private void reportAsSentryEvent( // fall back to not reporting them return new ParseResult(ParseResult.Type.NO_DUMP); } - return new ParseResult(ParseResult.Type.DUMP, dump, threads, debugImages); + return new ParseResult(ParseResult.Type.DUMP, dump, threads, debugImages, artContext); } catch (Throwable e) { options.getLogger().log(SentryLevel.WARNING, "Failed to parse ANR thread dump", e); return new ParseResult(ParseResult.Type.ERROR, dump); } } - - private byte[] getDumpBytes(final @NotNull InputStream trace) throws IOException { - try (final ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { - - int nRead; - final byte[] data = new byte[1024]; - - while ((nRead = trace.read(data, 0, data.length)) != -1) { - buffer.write(data, 0, nRead); - } - - return buffer.toByteArray(); - } - } } @ApiStatus.Internal @@ -379,6 +257,7 @@ public boolean ignoreCurrentThread() { return false; } + @NotNull @Override public Long timestamp() { return timestamp; @@ -412,15 +291,17 @@ enum Type { } final Type type; - final byte[] dump; + final @Nullable byte[] dump; final @Nullable List threads; final @Nullable List debugImages; + final @Nullable ArtContext artContext; ParseResult(final @NotNull Type type) { this.type = type; this.dump = null; this.threads = null; this.debugImages = null; + this.artContext = null; } ParseResult(final @NotNull Type type, final byte[] dump) { @@ -428,17 +309,20 @@ enum Type { this.dump = dump; this.threads = null; this.debugImages = null; + this.artContext = null; } ParseResult( final @NotNull Type type, final byte[] dump, final @Nullable List threads, - final @Nullable List debugImages) { + final @Nullable List debugImages, + final @Nullable ArtContext artContext) { this.type = type; this.dump = dump; this.threads = threads; this.debugImages = debugImages; + this.artContext = artContext; } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java index 196c9f32205..43ed3422cd9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java @@ -98,6 +98,7 @@ public void onConfigurationChanged(@NotNull Configuration newConfig) { executeInBackground(() -> captureConfigurationChangedBreadcrumb(now, newConfig)); } + @SuppressWarnings("deprecation") @Override public void onLowMemory() { // we do this in onTrimMemory below already, this is legacy API (14 or below) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java new file mode 100644 index 00000000000..3583474cfa7 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java @@ -0,0 +1,184 @@ +package io.sentry.android.core; + +import io.sentry.IAppStartExtender; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ISpan; +import io.sentry.ITransaction; +import io.sentry.Sentry; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SpanStatus; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.util.AutoClosableReentrantLock; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class AppStartExtension implements IAppStartExtender { + + public static final class ExtendedAppStart { + public final @NotNull ITransaction transaction; + public final @NotNull ISpan span; + + public ExtendedAppStart(final @NotNull ITransaction transaction, final @NotNull ISpan span) { + this.transaction = transaction; + this.span = span; + } + } + + public interface ExtendAppStartListener { + @Nullable + ExtendedAppStart onExtendAppStartRequested(); + } + + private final @NotNull AppStartMetrics metrics; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + + private @Nullable ExtendAppStartListener extendAppStartListener; + // We hold onto both the span and its transaction because they mean different things and finish + // at different times: + // + // - extendedSpan is what the app developer works with: they get it from + // getExtendedAppStartSpan(), add their own child spans to it, and finish it by calling + // finishExtendedAppStart(). Its end time is what extends the app start measurement. + // + // - extendedTransaction is the standalone "app.start" transaction that actually gets sent to + // Sentry. It carries the span and the screen name. The SDK asks it to finish at the first + // frame (or headless end), but because it uses waitForChildren it stays open until the span + // finishes (or the deadline is hit). + // + // A span doesn't expose its transaction, and pulling the span back out of the transaction would + // be fragile, so we just keep a reference to each. + private @Nullable ISpan extendedSpan; + private @Nullable ITransaction extendedTransaction; + + public AppStartExtension(final @NotNull AppStartMetrics metrics) { + this.metrics = metrics; + } + + public void setExtendAppStartListener(final @Nullable ExtendAppStartListener listener) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + this.extendAppStartListener = listener; + } + } + + @Override + public void extendAppStart() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (extendedSpan != null) { + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log(SentryLevel.WARNING, "App start is already being extended."); + return; + } + if (!metrics.canExtendAppStart()) { + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Cannot extend app start: the app start window has already passed."); + return; + } + final @Nullable ExtendAppStartListener listener = extendAppStartListener; + if (listener != null) { + final @Nullable ExtendedAppStart extended = listener.onExtendAppStartRequested(); + if (extended != null) { + this.extendedTransaction = extended.transaction; + this.extendedSpan = extended.span; + } + } + } + } + + /** + * Sets data on the owned (eager) transaction if it is still open. Used to attach the screen name + * once the first activity is known, since the transaction is created in {@code onCreate} before + * any activity exists. + */ + public void setData(final @NotNull String key, final @Nullable Object value) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (extendedTransaction != null && !extendedTransaction.isFinished()) { + extendedTransaction.setData(key, value); + } + } + } + + @Override + public void finishExtendedAppStart() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ISpan span = extendedSpan; + if (span != null && !span.isFinished()) { + span.finish(SpanStatus.OK); + } + } + } + + @Override + public @Nullable ISpan getExtendedAppStartSpan() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ISpan span = extendedSpan; + // Mirrors getExtendedEndTime(): the finish date is set before isFinished() flips. + if (span != null && span.getFinishDate() == null) { + return span; + } + return null; + } + } + + public boolean isActive() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return extendedTransaction != null && !extendedTransaction.isFinished(); + } + } + + /** + * Whether this app start was extended at all, regardless of finish or deadline state. Used by the + * event processor to decide whether to apply the never-shorten vital logic. + */ + public boolean isExtended() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return extendedSpan != null; + } + } + + public void finishTransaction(final @NotNull SentryDate endTimestamp) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ITransaction transaction = extendedTransaction; + if (transaction != null && !transaction.isFinished()) { + final @Nullable ISpan span = extendedSpan; + final @Nullable SentryDate spanEnd = span == null ? null : span.getFinishDate(); + final @NotNull SentryDate end = + spanEnd != null && spanEnd.isAfter(endTimestamp) ? spanEnd : endTimestamp; + transaction.finish(SpanStatus.OK, end); + } + } + } + + public @Nullable SentryDate getExtendedEndTime() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ISpan span = extendedSpan; + if (span == null) { + return null; + } + // A deadline timeout would report an artificially inflated duration; suppress the vital + // instead. + if (span.getStatus() == SpanStatus.DEADLINE_EXCEEDED) { + return null; + } + // Read the finish date, not isFinished(): finishing the extended span completes the + // waitForChildren transaction and runs the event processor re-entrantly before the span's + // finished flag is set, but the finish timestamp is already in place. Null until finished. + return span.getFinishDate(); + } + } + + public void clear() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + extendedSpan = null; + extendedTransaction = null; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java new file mode 100644 index 00000000000..3182828a024 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -0,0 +1,1105 @@ +package io.sentry.android.core; + +import static io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME; +import static io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME; +import static io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME; +import static io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME; +import static io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME; +import static io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.USER_FILENAME; +import static io.sentry.protocol.Contexts.REPLAY_ID; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.content.Context; +import android.content.pm.PackageInfo; +import android.os.Build; +import android.util.DisplayMetrics; +import androidx.annotation.WorkerThread; +import io.sentry.BackfillingEventProcessor; +import io.sentry.Breadcrumb; +import io.sentry.Hint; +import io.sentry.IpAddressUtils; +import io.sentry.ProfileChunk; +import io.sentry.ProfileContext; +import io.sentry.Sentry; +import io.sentry.SentryBaseEvent; +import io.sentry.SentryEvent; +import io.sentry.SentryExceptionFactory; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.SentryStackTraceFactory; +import io.sentry.SpanContext; +import io.sentry.android.core.anr.AggregatedStackTrace; +import io.sentry.android.core.anr.AnrCulpritIdentifier; +import io.sentry.android.core.anr.AnrProfile; +import io.sentry.android.core.anr.AnrProfileManager; +import io.sentry.android.core.anr.AnrProfileRotationHelper; +import io.sentry.android.core.anr.StackTraceConverter; +import io.sentry.android.core.internal.util.CpuInfoUtils; +import io.sentry.cache.PersistingOptionsObserver; +import io.sentry.cache.PersistingScopeObserver; +import io.sentry.exception.ExceptionMechanismException; +import io.sentry.hints.AbnormalExit; +import io.sentry.hints.Backfillable; +import io.sentry.hints.NativeCrashExit; +import io.sentry.protocol.App; +import io.sentry.protocol.Contexts; +import io.sentry.protocol.DebugImage; +import io.sentry.protocol.DebugMeta; +import io.sentry.protocol.Device; +import io.sentry.protocol.Mechanism; +import io.sentry.protocol.OperatingSystem; +import io.sentry.protocol.Request; +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; +import io.sentry.protocol.SentryTransaction; +import io.sentry.protocol.User; +import io.sentry.protocol.profiling.SentryProfile; +import io.sentry.util.HintUtils; +import io.sentry.util.SentryRandom; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Processes cached ApplicationExitInfo events (ANRs, tombstones) on a background thread, so we can + * safely read data from disk synchronously. + */ +@ApiStatus.Internal +@WorkerThread +public final class ApplicationExitInfoEventProcessor implements BackfillingEventProcessor { + + private final @NotNull Context context; + + private final @NotNull SentryAndroidOptions options; + + private final @NotNull BuildInfoProvider buildInfoProvider; + + private final @NotNull SentryExceptionFactory sentryExceptionFactory; + + private final @Nullable PersistingScopeObserver persistingScopeObserver; + + // Only ANRv2 events are currently enriched with hint-specific content. + // This can be extended to other hints like NativeCrashExit. + private final @NotNull List hintEnrichers = + Collections.singletonList(new AnrHintEnricher()); + + public ApplicationExitInfoEventProcessor( + final @NotNull Context context, + final @NotNull SentryAndroidOptions options, + final @NotNull BuildInfoProvider buildInfoProvider) { + this.context = ContextUtils.getApplicationContext(context); + this.options = options; + this.buildInfoProvider = buildInfoProvider; + this.persistingScopeObserver = options.findPersistingScopeObserver(); + + final SentryStackTraceFactory sentryStackTraceFactory = + new SentryStackTraceFactory(this.options); + + sentryExceptionFactory = new SentryExceptionFactory(sentryStackTraceFactory); + } + + private @Nullable HintEnricher findEnricher(final @NotNull Object hint) { + for (HintEnricher enricher : hintEnrichers) { + if (enricher.supports(hint)) { + return enricher; + } + } + return null; + } + + @Override + public @NotNull SentryTransaction process( + @NotNull SentryTransaction transaction, @NotNull Hint hint) { + // that's only necessary because on newer versions of Unity, if not overriding this method, it's + // throwing 'java.lang.AbstractMethodError: abstract method' and the reason is probably + // compilation mismatch + return transaction; + } + + @Override + public @Nullable SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { + final Object unwrappedHint = HintUtils.getSentrySdkHint(hint); + if (!(unwrappedHint instanceof Backfillable)) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "The event is not Backfillable, but has been passed to BackfillingEventProcessor, skipping."); + return event; + } + final @NotNull Backfillable backfillable = (Backfillable) unwrappedHint; + final @Nullable HintEnricher hintEnricher = findEnricher(unwrappedHint); + + if (hintEnricher != null) { + hintEnricher.applyPreEnrichment(event, backfillable, unwrappedHint); + } + + // We always set os and device even if the ApplicationExitInfo event is not enrich-able. + // The OS context may change in the meantime (OS update); we consider this an edge-case. + mergeOS(event); + setDevice(event); + + final OptionsSource optionsSource = getOptionsSource(backfillable); + + if (!backfillable.shouldEnrich()) { + setRelease(event, optionsSource); + setEnvironment(event, optionsSource); + setDist(event, optionsSource); + setAppVersionAndBuild(event); + options + .getLogger() + .log( + SentryLevel.DEBUG, + "The event is Backfillable, but should not be enriched, skipping."); + return event; + } + + backfillScope(event, optionsSource); + + backfillOptions(event, optionsSource); + + setStaticValues(event); + + if (hintEnricher != null) { + hintEnricher.applyPostEnrichment(event, backfillable, unwrappedHint, optionsSource); + } + + return event; + } + + // region scope persisted values + private void backfillScope( + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { + setRequest(event); + setUser(event); + setScopeTags(event); + setBreadcrumbs(event); + setExtras(event); + setContexts(event); + setTransaction(event); + setFingerprints(event); + setLevel(event); + setTrace(event); + setReplayId(event, optionsSource); + } + + private boolean sampleReplay( + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { + final @Nullable Double currentSampleRate = options.getSessionReplay().getOnErrorSampleRate(); + final @Nullable String replayErrorSampleRate = + getLaunchOption( + REPLAY_ERROR_SAMPLE_RATE_FILENAME, + String.class, + currentSampleRate == null ? null : currentSampleRate.toString(), + optionsSource); + + if (replayErrorSampleRate == null) { + return false; + } + + try { + // Sample with the rate from the relevant launch because it may change between launches. + final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate); + if (replayErrorSampleRateDouble < SentryRandom.current().nextDouble()) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Not capturing replay for ANR %s due to not being sampled.", + event.getEventId()); + return false; + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error parsing replay sample rate.", e); + return false; + } + + return true; + } + + private void setReplayId( + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { + @Nullable String persistedReplayId = readFromDisk(options, REPLAY_FILENAME, String.class); + @Nullable String cacheDirPath = options.getCacheDirPath(); + if (cacheDirPath == null) { + return; + } + final @NotNull File replayFolder = new File(cacheDirPath, "replay_" + persistedReplayId); + if (!replayFolder.exists()) { + if (!sampleReplay(event, optionsSource)) { + return; + } + // if the replay folder does not exist (e.g. running in buffer mode), we need to find the + // latest replay folder that was modified before the ANR event. + persistedReplayId = null; + long lastModified = Long.MIN_VALUE; + final File[] dirs = new File(cacheDirPath).listFiles(); + if (dirs != null) { + for (File dir : dirs) { + if (dir.isDirectory() && dir.getName().startsWith("replay_")) { + if (dir.lastModified() > lastModified + && dir.lastModified() <= event.getTimestamp().getTime()) { + lastModified = dir.lastModified(); + persistedReplayId = dir.getName().substring("replay_".length()); + } + } + } + } + } + + if (persistedReplayId == null) { + return; + } + + // store the relevant replayId so ReplayIntegration can pick it up and finalize that replay + PersistingScopeObserver.store(options, persistedReplayId, REPLAY_FILENAME); + event.getContexts().put(REPLAY_ID, persistedReplayId); + } + + private void setTrace(final @NotNull SentryEvent event) { + final SpanContext spanContext = readFromDisk(options, TRACE_FILENAME, SpanContext.class); + if (event.getContexts().getTrace() == null) { + if (spanContext != null) { + event.getContexts().setTrace(spanContext); + } + } + } + + private void setLevel(final @NotNull SentryEvent event) { + final SentryLevel level = readFromDisk(options, LEVEL_FILENAME, SentryLevel.class); + if (event.getLevel() == null) { + event.setLevel(level); + } + } + + @SuppressWarnings("unchecked") + private void setFingerprints(final @NotNull SentryEvent event) { + final List fingerprint = + (List) readFromDisk(options, FINGERPRINT_FILENAME, List.class); + if (event.getFingerprints() == null) { + event.setFingerprints(fingerprint); + } + } + + private void setTransaction(final @NotNull SentryEvent event) { + final String transaction = readFromDisk(options, TRANSACTION_FILENAME, String.class); + if (event.getTransaction() == null) { + event.setTransaction(transaction); + } + } + + private void setContexts(final @NotNull SentryBaseEvent event) { + final Contexts persistedContexts = readFromDisk(options, CONTEXTS_FILENAME, Contexts.class); + if (persistedContexts == null) { + return; + } + final Contexts eventContexts = event.getContexts(); + for (Map.Entry entry : new Contexts(persistedContexts).entrySet()) { + final Object value = entry.getValue(); + if (SpanContext.TYPE.equals(entry.getKey()) && value instanceof SpanContext) { + // we fill it in setTrace later on + continue; + } + if (!eventContexts.containsKey(entry.getKey())) { + eventContexts.put(entry.getKey(), value); + } + } + } + + @SuppressWarnings("unchecked") + private void setExtras(final @NotNull SentryBaseEvent event) { + final Map extras = + (Map) readFromDisk(options, EXTRAS_FILENAME, Map.class); + if (extras == null) { + return; + } + if (event.getExtras() == null) { + event.setExtras(new HashMap<>(extras)); + } else { + for (Map.Entry item : extras.entrySet()) { + if (!event.getExtras().containsKey(item.getKey())) { + event.getExtras().put(item.getKey(), item.getValue()); + } + } + } + } + + @SuppressWarnings("unchecked") + private void setBreadcrumbs(final @NotNull SentryBaseEvent event) { + final List breadcrumbs = + (List) readFromDisk(options, BREADCRUMBS_FILENAME, List.class); + if (breadcrumbs == null) { + return; + } + if (event.getBreadcrumbs() == null) { + event.setBreadcrumbs(breadcrumbs); + } else { + event.getBreadcrumbs().addAll(breadcrumbs); + } + } + + @SuppressWarnings("unchecked") + private void setScopeTags(final @NotNull SentryBaseEvent event) { + final Map tags = + (Map) + readFromDisk(options, PersistingScopeObserver.TAGS_FILENAME, Map.class); + if (tags == null) { + return; + } + if (event.getTags() == null) { + event.setTags(new HashMap<>(tags)); + } else { + for (Map.Entry item : tags.entrySet()) { + if (!event.getTags().containsKey(item.getKey())) { + event.setTag(item.getKey(), item.getValue()); + } + } + } + } + + private void setUser(final @NotNull SentryBaseEvent event) { + if (event.getUser() == null) { + final User user = readFromDisk(options, USER_FILENAME, User.class); + event.setUser(user); + } + } + + private void setRequest(final @NotNull SentryBaseEvent event) { + if (event.getRequest() == null) { + final Request request = readFromDisk(options, REQUEST_FILENAME, Request.class); + event.setRequest(request); + } + } + + private @Nullable T readFromDisk( + final @NotNull SentryOptions options, + final @NotNull String fileName, + final @NotNull Class clazz) { + if (persistingScopeObserver == null) { + return null; + } + + return persistingScopeObserver.read(options, fileName, clazz); + } + + // endregion + + // region options persisted values + private void backfillOptions( + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { + setRelease(event, optionsSource); + setEnvironment(event, optionsSource); + setDist(event, optionsSource); + setDebugMeta(event, optionsSource); + setSdk(event, optionsSource); + setApp(event); + setOptionsTags(event, optionsSource); + } + + private void setApp(final @NotNull SentryBaseEvent event) { + App app = event.getContexts().getApp(); + if (app == null) { + app = new App(); + } + app.setAppName(ContextUtils.getApplicationName(context)); + + final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); + if (packageInfo != null) { + app.setAppIdentifier(packageInfo.packageName); + } + + try { + final ContextUtils.SplitApksInfo splitApksInfo = + DeviceInfoUtil.getInstance(context, options).getSplitApksInfo(); + if (splitApksInfo != null) { + app.setSplitApks(splitApksInfo.isSplitApks()); + if (splitApksInfo.getSplitNames() != null) { + app.setSplitNames(Arrays.asList(splitApksInfo.getSplitNames())); + } + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting split apks info.", e); + } + + event.getContexts().setApp(app); + setAppVersionAndBuild(event); + } + + private void setAppVersionAndBuild(final @NotNull SentryBaseEvent event) { + final String release = event.getRelease(); + if (release != null) { + try { + @Nullable App app = event.getContexts().getApp(); + if (app == null) { + app = new App(); + } + final String versionName = + release.substring(release.indexOf('@') + 1, release.indexOf('+')); + final String versionCode = release.substring(release.indexOf('+') + 1); + app.setAppVersion(versionName); + app.setAppBuild(versionCode); + event.getContexts().setApp(app); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.WARNING, "Failed to parse release from scope cache: %s", release); + } + } + } + + private void setRelease( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { + if (event.getRelease() == null) { + event.setRelease( + getLaunchOption(RELEASE_FILENAME, String.class, options.getRelease(), optionsSource)); + } + } + + private void setEnvironment( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { + if (event.getEnvironment() == null) { + event.setEnvironment( + getLaunchOption( + ENVIRONMENT_FILENAME, String.class, options.getEnvironment(), optionsSource)); + } + } + + private void setDebugMeta( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { + DebugMeta debugMeta = event.getDebugMeta(); + + if (debugMeta == null) { + debugMeta = new DebugMeta(); + } + if (debugMeta.getImages() == null) { + debugMeta.setImages(new ArrayList<>()); + } + List images = debugMeta.getImages(); + if (images != null) { + final String proguardUuid = + getBuildOption( + PROGUARD_UUID_FILENAME, String.class, options.getProguardUuid(), optionsSource); + + if (proguardUuid != null) { + images.add(createProguardDebugImage(proguardUuid)); + } + event.setDebugMeta(debugMeta); + } + } + + private void setDist( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { + if (event.getDist() == null) { + event.setDist(getLaunchOption(DIST_FILENAME, String.class, options.getDist(), optionsSource)); + } + // if there's no user-set dist, fall back to versionCode from the release string + if (event.getDist() == null) { + final String release = event.getRelease(); + if (release != null) { + try { + final String versionCode = release.substring(release.indexOf('+') + 1); + event.setDist(versionCode); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.WARNING, "Failed to parse release from scope cache: %s", release); + } + } + } + } + + /** + * Resolves an option that may change between launches of the same build, such as environment or + * tags. A matching persisted value is preferred; the current value is used only when the source + * identifies the current app generation or permits a fallback for a missing persisted value. + */ + private @Nullable T getLaunchOption( + final @NotNull String fileName, + final @NotNull Class clazz, + final @Nullable T currentValue, + final @NotNull OptionsSource optionsSource) { + if (optionsSource == OptionsSource.CURRENT) { + return currentValue; + } else if (optionsSource == OptionsSource.NONE) { + return null; + } + + final T persistedValue = PersistingOptionsObserver.read(options, fileName, clazz); + return persistedValue != null || optionsSource == OptionsSource.PERSISTED + ? persistedValue + : currentValue; + } + + /** + * Resolves metadata that cannot change between launches of the same build, such as the ProGuard + * UUID or SDK version. Current metadata is used for exits from the current app generation, while + * persisted metadata is reserved for historical exits. + */ + private @Nullable T getBuildOption( + final @NotNull String fileName, + final @NotNull Class clazz, + final @Nullable T currentValue, + final @NotNull OptionsSource optionsSource) { + if (optionsSource == OptionsSource.CURRENT + || optionsSource == OptionsSource.PERSISTED_WITH_CURRENT_FALLBACK) { + return currentValue; + } else if (optionsSource == OptionsSource.NONE) { + return null; + } + return PersistingOptionsObserver.read(options, fileName, clazz); + } + + /** + * Chooses the options snapshot that can safely describe an exit by comparing its timestamp with + * the current app update time and the persisted cache generation. A markerless legacy cache is + * accepted for compatibility; {@link OptionsSource#NONE} is returned when neither current nor + * persisted options can be matched to the exit. + */ + private @NotNull OptionsSource getOptionsSource(final @NotNull Backfillable hint) { + final @Nullable Long timestamp; + if (hint instanceof AbnormalExit) { + timestamp = ((AbnormalExit) hint).timestamp(); + } else if (hint instanceof NativeCrashExit) { + timestamp = ((NativeCrashExit) hint).timestamp(); + } else { + timestamp = null; + } + final Long cachedLastUpdateTime = PersistingOptionsCacheGenerationObserver.read(options); + final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); + final long currentLastUpdateTime = packageInfo == null ? 0 : packageInfo.lastUpdateTime; + + if (timestamp != null && currentLastUpdateTime > 0 && currentLastUpdateTime <= timestamp) { + return cachedLastUpdateTime != null && cachedLastUpdateTime == currentLastUpdateTime + ? OptionsSource.PERSISTED_WITH_CURRENT_FALLBACK + : OptionsSource.CURRENT; + } + if (cachedLastUpdateTime == null) { + return OptionsSource.PERSISTED; + } + // A cache generation created after the exit cannot describe that exit. + if (timestamp != null && cachedLastUpdateTime > 0 && cachedLastUpdateTime <= timestamp) { + return OptionsSource.PERSISTED; + } + return OptionsSource.NONE; + } + + private void setSdk( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { + if (event.getSdk() == null) { + final SdkVersion sdkVersion = + getBuildOption( + SDK_VERSION_FILENAME, SdkVersion.class, options.getSdkVersion(), optionsSource); + event.setSdk(sdkVersion); + } + } + + @SuppressWarnings("unchecked") + private void setOptionsTags( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { + final Map tags = + (Map) + getLaunchOption( + PersistingOptionsObserver.TAGS_FILENAME, + Map.class, + options.getTags(), + optionsSource); + if (tags == null) { + return; + } + if (event.getTags() == null) { + event.setTags(new HashMap<>(tags)); + } else { + for (Map.Entry item : tags.entrySet()) { + if (!event.getTags().containsKey(item.getKey())) { + event.setTag(item.getKey(), item.getValue()); + } + } + } + } + + // endregion + + private enum OptionsSource { + CURRENT, + PERSISTED, + PERSISTED_WITH_CURRENT_FALLBACK, + NONE + } + + @Override + public @Nullable Long getOrder() { + return 12000L; + } + + // region static values + private void setStaticValues(final @NotNull SentryEvent event) { + mergeUser(event); + setSideLoadedInfo(event); + } + + private void setDefaultPlatform(final @NotNull SentryBaseEvent event) { + if (event.getPlatform() == null) { + // this actually means JVM related. + event.setPlatform(SentryBaseEvent.DEFAULT_PLATFORM); + } + } + + private void mergeUser(final @NotNull SentryBaseEvent event) { + @Nullable User user = event.getUser(); + if (user == null) { + user = new User(); + event.setUser(user); + } + + // userId should be set even if event is Cached as the userId is static and won't change anyway. + if (user.getId() == null) { + user.setId(getDeviceId()); + } + if (user.getIpAddress() == null && options.isSendDefaultPii()) { + user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); + } + } + + private @Nullable String getDeviceId() { + try { + return Installation.id(context); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting installationId.", e); + } + return null; + } + + private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { + try { + final ContextUtils.SideLoadedInfo sideLoadedInfo = + DeviceInfoUtil.getInstance(context, options).getSideLoadedInfo(); + if (sideLoadedInfo != null) { + final @NotNull Map tags = sideLoadedInfo.asTags(); + for (Map.Entry entry : tags.entrySet()) { + event.setTag(entry.getKey(), entry.getValue()); + } + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting side loaded info.", e); + } + } + + private void setDevice(final @NotNull SentryBaseEvent event) { + if (event.getContexts().getDevice() == null) { + event.getContexts().setDevice(getDevice()); + } + } + + // only use static data that does not change between app launches (e.g. timezone, boottime, + // battery level will change) + @SuppressLint("NewApi") + private @NotNull Device getDevice() { + Device device = new Device(); + device.setManufacturer(Build.MANUFACTURER); + device.setBrand(Build.BRAND); + device.setFamily(ContextUtils.getFamily(options.getLogger())); + device.setModel(Build.MODEL); + device.setModelId(Build.ID); + device.setArchs(ContextUtils.getArchitectures()); + + final ActivityManager.MemoryInfo memInfo = + ContextUtils.getMemInfo(context, options.getLogger()); + if (memInfo != null) { + // in bytes + device.setMemorySize(getMemorySize(memInfo)); + } + + device.setSimulator(buildInfoProvider.isEmulator()); + + DisplayMetrics displayMetrics = ContextUtils.getDisplayMetrics(context, options.getLogger()); + if (displayMetrics != null) { + device.setScreenWidthPixels(displayMetrics.widthPixels); + device.setScreenHeightPixels(displayMetrics.heightPixels); + device.setScreenDensity(displayMetrics.density); + device.setScreenDpi(displayMetrics.densityDpi); + } + + if (device.getId() == null) { + device.setId(getDeviceId()); + } + + final @NotNull List cpuFrequencies = CpuInfoUtils.getInstance().readMaxFrequencies(); + if (!cpuFrequencies.isEmpty()) { + device.setProcessorFrequency(Collections.max(cpuFrequencies).doubleValue()); + device.setProcessorCount(cpuFrequencies.size()); + } + + return device; + } + + private @NotNull Long getMemorySize(final @NotNull ActivityManager.MemoryInfo memInfo) { + return memInfo.totalMem; + } + + private void mergeOS(final @NotNull SentryBaseEvent event) { + final OperatingSystem currentOS = event.getContexts().getOperatingSystem(); + final OperatingSystem androidOS = + DeviceInfoUtil.getInstance(context, options).getOperatingSystem(); + + // make Android OS the main OS using the 'os' key + event.getContexts().setOperatingSystem(androidOS); + + if (currentOS != null) { + // add additional OS which was already part of the SentryEvent (eg Linux read from NDK) + String osNameKey = currentOS.getName(); + if (osNameKey != null && !osNameKey.isEmpty()) { + osNameKey = "os_" + osNameKey.trim().toLowerCase(Locale.ROOT); + } else { + osNameKey = "os_1"; + } + event.getContexts().put(osNameKey, currentOS); + } + // endregion + } + + private interface HintEnricher { + boolean supports(@NotNull Object hint); + + void applyPreEnrichment( + @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint); + + void applyPostEnrichment( + @NotNull SentryEvent event, + @NotNull Backfillable hint, + @NotNull Object rawHint, + @NotNull OptionsSource optionsSource); + } + + private final class AnrHintEnricher implements HintEnricher { + + @Override + public boolean supports(@NotNull Object hint) { + // While this is specifically an ANR enricher we discriminate enrichment application + // on the broader AbnormalExit hints for now. + return hint instanceof AbnormalExit; + } + + // by default we assume that the ANR is foreground, unless abnormalMechanism is "anr_background" + private boolean isBackgroundAnr(final @NotNull Object hint) { + if (hint instanceof AbnormalExit) { + final String abnormalMechanism = ((AbnormalExit) hint).mechanism(); + return "anr_background".equals(abnormalMechanism); + } + return false; + } + + @Override + public void applyPreEnrichment( + @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint) { + final boolean isBackgroundAnr = isBackgroundAnr(rawHint); + // we always set exception values and default platform even if the ANR is not enrich-able + setDefaultPlatform(event); + setAnrExceptions(event, hint, isBackgroundAnr); + } + + @Override + public void applyPostEnrichment( + @NotNull SentryEvent event, + @NotNull Backfillable hint, + @NotNull Object rawHint, + @NotNull OptionsSource optionsSource) { + final boolean isBackgroundAnr = isBackgroundAnr(rawHint); + + if (options.isAnrProfilingEnabled()) { + applyAnrProfile(event, hint, isBackgroundAnr, optionsSource); + } + + setDefaultAnrFingerprint(event, isBackgroundAnr); + + // Set app foreground state + setAppForeground(event, !isBackgroundAnr); + } + + private void setDefaultAnrFingerprint( + final @NotNull SentryEvent event, final boolean isBackgroundAnr) { + // sentry does not yet have a capability to provide default server-side fingerprint rules, + // so we're doing this on the SDK side to group background and foreground ANRs separately + // even if they have similar stacktraces. + if (event.getFingerprints() != null) { + return; + } + + if (options.isEnableAnrFingerprinting() && hasOnlySystemFrames(event)) { + // If the stacktrace only contains system frames, we want to statically group these events + // to avoid ANR noise due to {{ default }} stacktrace grouping + event.setFingerprints( + Arrays.asList( + "system-frames-only-anr", isBackgroundAnr ? "background-anr" : "foreground-anr")); + } else { + event.setFingerprints( + Arrays.asList("{{ default }}", isBackgroundAnr ? "background-anr" : "foreground-anr")); + } + } + + private void setAppForeground( + final @NotNull SentryBaseEvent event, final boolean inForeground) { + App app = event.getContexts().getApp(); + if (app == null) { + app = new App(); + event.getContexts().setApp(app); + } + // TODO: not entirely correct, because we define background ANRs as not the ones of + // IMPORTANCE_FOREGROUND, but this doesn't mean the app was in foreground when an ANR + // happened but it's our best effort for now. We could serialize AppState in theory. + if (app.getInForeground() == null) { + app.setInForeground(inForeground); + } + } + + @Nullable + private SentryThread findMainThread(final @Nullable List threads) { + if (threads != null) { + for (SentryThread thread : threads) { + final String name = thread.getName(); + if (name != null && name.equals("main")) { + return thread; + } + } + } + return null; + } + + private void setAnrExceptions( + final @NotNull SentryEvent event, + final @NotNull Backfillable hint, + final boolean isBackgroundAnr) { + if (event.getExceptions() != null) { + return; + } + // AnrV2 threads contain a thread dump from the OS, so we just search for the main thread dump + // and make an exception out of its stacktrace + final Mechanism mechanism = new Mechanism(); + if (!hint.shouldEnrich()) { + // we only enrich the latest ANR in the list, so this is historical + mechanism.setType("HistoricalAppExitInfo"); + } else { + mechanism.setType("AppExitInfo"); + } + + String message = "ANR"; + if (isBackgroundAnr) { + message = "Background " + message; + } + final ApplicationNotResponding anr = + new ApplicationNotResponding(message, Thread.currentThread()); + + SentryThread mainThread = findMainThread(event.getThreads()); + if (mainThread == null) { + // if there's no main thread in the event threads, we just create a dummy thread so the + // exception is properly created as well, but without stacktrace + mainThread = new SentryThread(); + mainThread.setStacktrace(new SentryStackTrace()); + } + event.setExceptions( + sentryExceptionFactory.getSentryExceptionsFromThread(mainThread, mechanism, anr)); + } + + private void applyAnrProfile( + @NotNull SentryEvent event, + @NotNull Backfillable hint, + boolean isBackgroundAnr, + @NotNull OptionsSource optionsSource) { + + // Skip background ANRs (as profiling only runs in foreground) + if (isBackgroundAnr) { + return; + } + + final String cacheDirPath = options.getCacheDirPath(); + if (cacheDirPath == null) { + return; + } + final File cacheDir = new File(cacheDirPath); + + if (!(hint instanceof AbnormalExit)) { + return; + } + final Long anrTimestampObj = ((AbnormalExit) hint).timestamp(); + final long anrTimestamp; + if (anrTimestampObj != null) { + anrTimestamp = anrTimestampObj; + } else if (event.getTimestamp() != null) { + anrTimestamp = event.getTimestamp().getTime(); + } else { + return; + } + + AnrProfile anrProfile = null; + try { + final File lastFile = AnrProfileRotationHelper.getLastFile(cacheDir); + if (lastFile.exists()) { + options.getLogger().log(SentryLevel.DEBUG, "Reading ANR profile"); + try (final AnrProfileManager provider = new AnrProfileManager(options, lastFile)) { + anrProfile = provider.load(); + } + } else { + options.getLogger().log(SentryLevel.DEBUG, "No ANR profile file found"); + } + } catch (Throwable t) { + options.getLogger().log(SentryLevel.INFO, "Could not retrieve ANR profile", t); + } finally { + if (!AnrProfileRotationHelper.deleteLastFile(cacheDir)) { + options.getLogger().log(SentryLevel.INFO, "Could not delete ANR profile file"); + } + } + + if (anrProfile == null) { + return; + } + + options.getLogger().log(SentryLevel.INFO, "ANR profile found"); + if (anrTimestamp < anrProfile.startTimeMs || anrTimestamp > anrProfile.endTimeMs) { + options.getLogger().log(SentryLevel.DEBUG, "ANR profile found, but doesn't match"); + return; + } + + final AggregatedStackTrace culprit = AnrCulpritIdentifier.identify(anrProfile.stacks); + if (culprit == null) { + return; + } + + // Capture profile chunk + final @Nullable SentryId profilerId = + captureAnrProfile(anrTimestamp, anrProfile, optionsSource); + final @NotNull StackTraceElement[] stack = culprit.getStack(); + + if (stack.length > 0) { + final StackTraceElement stackTraceElement = stack[0]; + final String message = + stackTraceElement.getClassName() + "." + stackTraceElement.getMethodName(); + final ApplicationNotResponding exception = new ApplicationNotResponding(message); + exception.setStackTrace(stack); + + final Mechanism mechanism = new Mechanism(); + mechanism.setType("ANR"); + final ExceptionMechanismException error = + new ExceptionMechanismException(mechanism, exception, null, false); + + final @NotNull List sentryException = + sentryExceptionFactory.getSentryExceptions(error); + + // Replace the original ANR exception with the profile-derived one, + // as we assume the profiling culprit identification is more valuable + // the event threads are kept as-is, so the original main thread stacktrace is still + // available + event.setExceptions(sentryException); + + if (profilerId != null) { + event.getContexts().setProfile(new ProfileContext(profilerId)); + } + } + } + + @Nullable + private SentryId captureAnrProfile( + final long anrTimestampMs, + @NotNull AnrProfile anrProfile, + final @NotNull OptionsSource optionsSource) { + final SentryProfile profile = StackTraceConverter.convert(anrProfile); + final ProfileChunk chunk = + new ProfileChunk( + new SentryId(), + new SentryId(), + null, + new HashMap<>(0), + anrTimestampMs / 1000.0d, + ProfileChunk.PLATFORM_ANDROID, + options); + chunk.setSentryProfile(profile); + chunk.setDebugMeta(createAnrProfileDebugMeta(optionsSource)); + + final SentryId profilerId = Sentry.getCurrentScopes().captureProfileChunk(chunk); + if (SentryId.EMPTY_ID.equals(profilerId)) { + return null; + } else { + return chunk.getProfilerId(); + } + } + + private boolean hasOnlySystemFrames(@NotNull SentryEvent event) { + final List exceptions = event.getExceptions(); + if (exceptions == null || exceptions.isEmpty()) { + return false; + } + + for (final SentryException exception : exceptions) { + final SentryStackTrace stacktrace = exception.getStacktrace(); + if (stacktrace != null) { + final List frames = stacktrace.getFrames(); + if (frames != null && !frames.isEmpty()) { + for (final SentryStackFrame frame : frames) { + if (frame.isInApp() != null && frame.isInApp()) { + return false; + } + final String module = frame.getModule(); + if (module != null && !AnrCulpritIdentifier.isSystemFrame(module)) { + return false; + } + } + } + } + } + return true; + } + + /** + * Creates debug metadata for an ANR profile chunk using the build metadata selected for the ANR + * event. + * + *

ANR profile chunks are captured after app relaunch. If the app was updated between the ANR + * and the relaunch, the current options may contain the new build's ProGuard UUID. The provided + * {@link OptionsSource} lets us resolve the profile chunk and ANR event to the same originating + * build. + */ + private @Nullable DebugMeta createAnrProfileDebugMeta( + final @NotNull OptionsSource optionsSource) { + final String proguardUuid = + getBuildOption( + PROGUARD_UUID_FILENAME, String.class, options.getProguardUuid(), optionsSource); + if (proguardUuid == null) { + // If no historical UUID is available, let the generic profile chunk pipeline apply the + // current options UUID as its normal best-effort fallback. + return null; + } + + final DebugMeta debugMeta = new DebugMeta(); + debugMeta.setImages(Collections.singletonList(createProguardDebugImage(proguardUuid))); + return debugMeta; + } + } + + private static @NotNull DebugImage createProguardDebugImage(final @NotNull String proguardUuid) { + final DebugImage debugImage = new DebugImage(); + debugImage.setType(DebugImage.PROGUARD); + debugImage.setUuid(proguardUuid); + return debugImage; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java new file mode 100644 index 00000000000..79c3f19c6e5 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java @@ -0,0 +1,247 @@ +package io.sentry.android.core; + +import android.app.ActivityManager; +import android.app.ApplicationExitInfo; +import android.content.Context; +import android.os.Build; +import androidx.annotation.RequiresApi; +import io.sentry.Hint; +import io.sentry.IScopes; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.cache.EnvelopeCache; +import io.sentry.cache.IEnvelopeCache; +import io.sentry.hints.BlockingFlushHint; +import io.sentry.protocol.SentryId; +import io.sentry.transport.ICurrentDateProvider; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +final class ApplicationExitInfoHistoryDispatcher implements Runnable { + + // using 91 to avoid timezone change hassle, 90 days is how long Sentry keeps the events + static final long NINETY_DAYS_THRESHOLD = TimeUnit.DAYS.toMillis(91); + + private final @NotNull Context context; + private final @NotNull IScopes scopes; + private final @NotNull SentryAndroidOptions options; + private final @NotNull ApplicationExitInfoPolicy policy; + private final long threshold; + + ApplicationExitInfoHistoryDispatcher( + final @NotNull Context context, + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options, + final @NotNull ICurrentDateProvider dateProvider, + final @NotNull ApplicationExitInfoPolicy policy) { + this.context = ContextUtils.getApplicationContext(context); + this.scopes = scopes; + this.options = options; + this.policy = policy; + this.threshold = dateProvider.getCurrentTimeMillis() - NINETY_DAYS_THRESHOLD; + } + + @RequiresApi(api = Build.VERSION_CODES.R) + @Override + public void run() { + final ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + + if (activityManager == null) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve ActivityManager."); + return; + } + + final List applicationExitInfoList = + activityManager.getHistoricalProcessExitReasons(null, 0, 0); + + if (applicationExitInfoList.isEmpty()) { + options.getLogger().log(SentryLevel.DEBUG, "No records in historical exit reasons."); + return; + } + + waitPreviousSessionFlush(); + + final List exitInfos = new ArrayList<>(applicationExitInfoList); + final @Nullable Long lastReportedTimestamp = policy.getLastReportedTimestamp(); + + final ApplicationExitInfo latest = removeLatest(exitInfos); + if (latest == null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "No %ss have been found in the historical exit reasons list.", + policy.getLabel()); + return; + } + + if (latest.getTimestamp() < threshold) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Latest %s happened too long ago, returning early.", + policy.getLabel()); + return; + } + + if (lastReportedTimestamp != null && latest.getTimestamp() <= lastReportedTimestamp) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Latest %s has already been reported, returning early.", + policy.getLabel()); + return; + } + + if (policy.shouldReportHistorical()) { + reportHistorical(exitInfos, lastReportedTimestamp); + } + + report(latest, true); + } + + private void waitPreviousSessionFlush() { + final IEnvelopeCache cache = options.getEnvelopeDiskCache(); + if (cache instanceof EnvelopeCache) { + if (options.isEnableAutoSessionTracking() + && !((EnvelopeCache) cache).waitPreviousSessionFlush()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Timed out waiting to flush previous session to its own file."); + + // if we timed out waiting here, we can already flush the latch, because the timeout is + // big enough to wait for it only once and we don't have to wait again in + // PreviousSessionFinalizer + ((EnvelopeCache) cache).flushPreviousSession(); + } + } + } + + @RequiresApi(api = Build.VERSION_CODES.R) + private @Nullable ApplicationExitInfo removeLatest( + final @NotNull List exitInfos) { + for (Iterator it = exitInfos.iterator(); it.hasNext(); ) { + ApplicationExitInfo applicationExitInfo = it.next(); + if (applicationExitInfo.getReason() == policy.getTargetReason()) { + it.remove(); + return applicationExitInfo; + } + } + return null; + } + + @RequiresApi(api = Build.VERSION_CODES.R) + private void reportHistorical( + final @NotNull List exitInfos, + final @Nullable Long lastReportedTimestamp) { + Collections.reverse(exitInfos); + for (ApplicationExitInfo applicationExitInfo : exitInfos) { + if (applicationExitInfo.getReason() == policy.getTargetReason()) { + if (applicationExitInfo.getTimestamp() < threshold) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "%s happened too long ago %s.", + policy.getLabel(), + applicationExitInfo); + continue; + } + + if (lastReportedTimestamp != null + && applicationExitInfo.getTimestamp() <= lastReportedTimestamp) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "%s has already been reported %s.", + policy.getLabel(), + applicationExitInfo); + continue; + } + + report(applicationExitInfo, false); // do not enrich past events + } + } + } + + private void report(final @NotNull ApplicationExitInfo exitInfo, final boolean enrich) { + final @Nullable Report report = policy.buildReport(exitInfo, enrich); + + if (report == null) { + return; + } + + final @NotNull SentryId sentryId = scopes.captureEvent(report.getEvent(), report.getHint()); + final boolean isEventDropped = sentryId.equals(SentryId.EMPTY_ID); + if (!isEventDropped) { + final @Nullable BlockingFlushHint flushHint = report.getFlushHint(); + if (flushHint != null && !flushHint.waitFlush()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Timed out waiting to flush %s event to disk. Event: %s", + policy.getLabel(), + report.getEvent().getEventId()); + } + } + } + + interface ApplicationExitInfoPolicy { + @NotNull + String getLabel(); + + int getTargetReason(); + + boolean shouldReportHistorical(); + + @Nullable + Long getLastReportedTimestamp(); + + @Nullable + Report buildReport(@NotNull ApplicationExitInfo exitInfo, boolean enrich); + } + + public static final class Report { + private final @NotNull SentryEvent event; + private final @NotNull Hint hint; + private final @Nullable BlockingFlushHint flushHint; + + Report( + final @NotNull SentryEvent event, + final @NotNull Hint hint, + final @Nullable BlockingFlushHint flushHint) { + this.event = event; + this.hint = hint; + this.flushHint = flushHint; + } + + @NotNull + public SentryEvent getEvent() { + return event; + } + + @NotNull + public Hint getHint() { + return hint; + } + + @Nullable + public BlockingFlushHint getFlushHint() { + return flushHint; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java index f4998240f81..7b21c2e392c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java @@ -17,7 +17,12 @@ final class ApplicationNotResponding extends RuntimeException { private static final long serialVersionUID = 252541144579117016L; - private final @NotNull Thread thread; + private final @Nullable Thread thread; + + ApplicationNotResponding(final @Nullable String message) { + super(message); + this.thread = null; + } ApplicationNotResponding(final @Nullable String message, final @NotNull Thread thread) { super(message); @@ -25,7 +30,7 @@ final class ApplicationNotResponding extends RuntimeException { setStackTrace(this.thread.getStackTrace()); } - public @NotNull Thread getThread() { + public @Nullable Thread getThread() { return thread; } } 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 1e37916aaee..83f892573e4 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 @@ -27,6 +27,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -37,7 +38,7 @@ final class DefaultAndroidEventProcessor implements EventProcessor { private final @NotNull BuildInfoProvider buildInfoProvider; private final @NotNull SentryAndroidOptions options; - private final @Nullable Future deviceInfoUtil; + @TestOnly final @Nullable Future deviceInfoUtil; private final @NotNull LazyEvaluator deviceFamily = new LazyEvaluator<>(() -> ContextUtils.getFamily(NoOpLogger.getInstance())); @@ -56,7 +57,8 @@ public DefaultAndroidEventProcessor( // noinspection Convert2MethodRef // some device info performs disk I/O, but it's result is cached, let's pre-cache it @Nullable Future deviceInfoUtil; - final @NotNull ExecutorService executorService = Executors.newSingleThreadExecutor(); + final @NotNull ExecutorService executorService = + Executors.newSingleThreadExecutor(new DeviceInfoCacheThreadFactory()); try { deviceInfoUtil = executorService.submit(() -> DeviceInfoUtil.getInstance(this.context, options)); @@ -93,6 +95,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 +258,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) { @@ -389,4 +427,13 @@ private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { public @Nullable Long getOrder() { return 8000L; } + + private static final class DeviceInfoCacheThreadFactory implements ThreadFactory { + @Override + public @NotNull Thread newThread(final @NotNull Runnable r) { + final Thread ret = new Thread(r, "SentryDeviceInfoCache"); + ret.setDaemon(true); + return ret; + } + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index 7ba321426a1..63b88c0e440 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -9,6 +9,7 @@ import android.content.IntentFilter; import android.os.BatteryManager; import android.os.Build; +import android.os.Environment; import android.os.LocaleList; import android.os.StatFs; import android.os.SystemClock; @@ -148,7 +149,7 @@ public Device collectDeviceInformation( // setting such values require IO hence we don't run for transactions if (collectDeviceIO && options.isCollectAdditionalContext()) { - setDeviceIO(device, collectDynamicData); + setDeviceIO(device, collectDynamicData, options.isCollectExternalStorageContext()); } return device; @@ -195,7 +196,10 @@ public ContextUtils.SplitApksInfo getSplitApksInfo() { return splitApksInfo; } - private void setDeviceIO(final @NotNull Device device, final boolean includeDynamicData) { + private void setDeviceIO( + final @NotNull Device device, + final boolean includeDynamicData, + final boolean includeExternalStorage) { final Intent batteryIntent = getBatteryIntent(); if (batteryIntent != null) { device.setBatteryLevel(getBatteryLevel(batteryIntent, options)); @@ -203,6 +207,7 @@ private void setDeviceIO(final @NotNull Device device, final boolean includeDyna device.setBatteryTemperature(getBatteryTemperature(batteryIntent)); } + // TODO .getConnectionStatus() may be blocking, investigate if this can be done async Boolean connected; switch (options.getConnectionStatusProvider().getConnectionStatus()) { case DISCONNECTED: @@ -227,17 +232,20 @@ private void setDeviceIO(final @NotNull Device device, final boolean includeDyna // this way of getting the size of storage might be problematic for storages bigger than 2GB // check the use of // https://developer.android.com/reference/java/io/File.html#getFreeSpace%28%29 - final @Nullable File internalStorageFile = context.getExternalFilesDir(null); - if (internalStorageFile != null) { - StatFs internalStorageStat = new StatFs(internalStorageFile.getPath()); + final @Nullable File dataDir = Environment.getDataDirectory(); + if (dataDir != null) { + StatFs internalStorageStat = new StatFs(dataDir.getPath()); device.setStorageSize(getTotalInternalStorage(internalStorageStat)); device.setFreeStorage(getUnusedInternalStorage(internalStorageStat)); } - final @Nullable StatFs externalStorageStat = getExternalStorageStat(internalStorageFile); - if (externalStorageStat != null) { - device.setExternalStorageSize(getTotalExternalStorage(externalStorageStat)); - device.setExternalFreeStorage(getUnusedExternalStorage(externalStorageStat)); + if (includeExternalStorage) { + final @Nullable File internalStorageFile = context.getExternalFilesDir(null); + final @Nullable StatFs externalStorageStat = getExternalStorageStat(internalStorageFile); + if (externalStorageStat != null) { + device.setExternalStorageSize(getTotalExternalStorage(externalStorageStat)); + device.setExternalFreeStorage(getUnusedExternalStorage(externalStorageStat)); + } } if (device.getConnectionType() == null) { @@ -249,14 +257,19 @@ private void setDeviceIO(final @NotNull Device device, final boolean includeDyna @SuppressWarnings("NewApi") @NotNull private TimeZone getTimeZone() { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N) { + // Only use the costly Calendar API on Android 13+ (API Level 33+) when the locale contains a + // Unicode timezone extension (for example "en-US-u-tz-usnyc"), because Calendar honors that + // extension. For all other cases, use the process default timezone directly for performance. + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { LocaleList locales = context.getResources().getConfiguration().getLocales(); if (!locales.isEmpty()) { Locale locale = locales.get(0); - return Calendar.getInstance(locale).getTimeZone(); + if (locale.getUnicodeLocaleType("tz") != null) { + return Calendar.getInstance(locale).getTimeZone(); + } } } - return Calendar.getInstance().getTimeZone(); + return TimeZone.getDefault(); } @SuppressWarnings("JdkObsolete") diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java index 482d90c6e6c..ab95ae32daa 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java @@ -10,8 +10,10 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.FileUtils; import io.sentry.util.Objects; import java.io.Closeable; +import java.io.File; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -67,6 +69,12 @@ private void startOutboxSender( final @NotNull IScopes scopes, final @NotNull SentryOptions options, final @NotNull String path) { + // Create the outbox dir here (on the executor) so the observer can watch it for envelopes + // written by hybrid SDKs, instead of blocking Sentry.init on the mkdirs. + if (!FileUtils.createDirectory(new File(path))) { + options.getLogger().log(SentryLevel.ERROR, "Failed to create outbox dir %s", path); + } + final OutboxSender outboxSender = new OutboxSender( scopes, diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java new file mode 100644 index 00000000000..4405cd19309 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java @@ -0,0 +1,314 @@ +package io.sentry.android.core; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import android.app.Activity; +import android.app.Application; +import android.app.Dialog; +import android.os.Bundle; +import io.sentry.IScopes; +import io.sentry.Integration; +import io.sentry.SentryFeedbackOptions; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.util.Objects; +import java.io.Closeable; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.util.concurrent.CopyOnWriteArrayList; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +/** + * Detects shake gestures and shows the user feedback dialog when a shake is detected. {@link + * io.sentry.SentryFeedbackOptions#isUseShakeGesture()} determines the initial state; it can be + * toggled at runtime via {@code Sentry.feedback().enableOnShake()} and {@code + * Sentry.feedback().disableOnShake()}. + * + *

Shake detection is scoped to the resumed activity: a dialog belongs to the window of the + * activity that created it, so it can only ever be visible while that activity is resumed. Dialogs + * report themselves via {@link #onDialogVisible(Activity, Dialog)} / {@link #onDialogGone(Dialog)} + * and detection is then suppressed for the activity hosting them, which keeps a shake from stacking + * a second dialog on top of a visible one without letting a dialog on a backgrounded activity + * suppress detection elsewhere. + */ +public final class FeedbackShakeIntegration + implements Integration, + Closeable, + Application.ActivityLifecycleCallbacks, + SentryFeedbackOptions.IShakeController { + + private final @NotNull Application application; + private final @NotNull SentryShakeDetector shakeDetector; + private @Nullable SentryAndroidOptions options; + private volatile boolean enabled = false; + private volatile @Nullable WeakReference currentActivityRef; + + /** + * The feedback dialogs that are currently visible, together with the activity hosting them. More + * than one can be visible at a time, e.g. when the app calls {@code Sentry.feedback().show()} + * while another dialog is already showing. + */ + private final @NotNull CopyOnWriteArrayList visibleDialogs = + new CopyOnWriteArrayList<>(); + + public FeedbackShakeIntegration(final @NotNull Application application) { + this.application = Objects.requireNonNull(application, "Application is required"); + this.shakeDetector = new SentryShakeDetector(io.sentry.NoOpLogger.getInstance()); + } + + @Override + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions sentryOptions) { + this.options = + Objects.requireNonNull( + (sentryOptions instanceof SentryAndroidOptions) + ? (SentryAndroidOptions) sentryOptions + : null, + "SentryAndroidOptions is required"); + + final @NotNull SentryAndroidOptions options = this.options; + + // Always expose the runtime toggle, even when the option starts out disabled. + options.getFeedbackOptions().setShakeController(this); + + if (options.getFeedbackOptions().isUseShakeGesture()) { + enableOnShake(); + } + } + + @Override + public synchronized void enableOnShake() { + final @Nullable SentryAndroidOptions options = this.options; + if (enabled || options == null) { + return; + } + enabled = true; + + // Re-arm the detector in case it was closed before, either by disableOnShake() or by a previous + // close() (e.g. a second Sentry.init reusing the same options), otherwise the closed latch + // would keep shake detection off permanently. + shakeDetector.reopen(); + + // Resolving the accelerometer is the most expensive part of init (the first SensorManager + // access), so warm it up off the main thread. start() re-runs init() on demand, so shake + // detection still works if an activity resumes before this completes. + try { + options + .getExecutorService() + .submit(() -> shakeDetector.init(application, options.getLogger())); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.WARNING, "Failed to submit shake detector initialization.", t); + } + + addIntegrationToSdkVersion("FeedbackShake"); + application.registerActivityLifecycleCallbacks(this); + options.getLogger().log(SentryLevel.DEBUG, "FeedbackShakeIntegration installed."); + + // In case of a deferred init or runtime enable, hook into any already-resumed activity + final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); + if (activity != null) { + currentActivityRef = new WeakReference<>(activity); + startShakeDetection(activity); + } + } + + @Override + public synchronized void disableOnShake() { + if (!enabled) { + return; + } + enabled = false; + + application.unregisterActivityLifecycleCallbacks(this); + shakeDetector.close(); + currentActivityRef = null; + } + + @Override + public boolean isOnShakeEnabled() { + return enabled; + } + + /** + * Reports a feedback dialog as visible on {@code host}. Shake detection is suppressed for that + * activity until the dialog reports back via {@link #onDialogGone(Dialog)}, so a shake can never + * stack a second dialog on top of a visible one — no matter how the visible one was opened. + */ + void onDialogVisible(final @NotNull Activity host, final @NotNull Dialog dialog) { + visibleDialogs.add(new VisibleDialog(host, dialog)); + stopShakeDetection(); + } + + /** Reports a feedback dialog as no longer visible. Safe to call more than once per dialog. */ + void onDialogGone(final @NotNull Dialog dialog) { + if (!removeDialog(dialog)) { + return; + } + final @Nullable WeakReference currentRef = currentActivityRef; + final @Nullable Activity current = currentRef == null ? null : currentRef.get(); + if (enabled && current != null) { + startShakeDetection(current); + } + } + + private boolean removeDialog(final @NotNull Dialog dialog) { + boolean removed = false; + for (final @NotNull VisibleDialog visibleDialog : visibleDialogs) { + // Drop entries whose dialog was collected without reporting back, so they can't suppress + // detection forever. + final @Nullable Dialog trackedDialog = visibleDialog.dialogRef.get(); + if (trackedDialog == dialog) { + removed = visibleDialogs.remove(visibleDialog) || removed; + } else if (trackedDialog == null) { + visibleDialogs.remove(visibleDialog); + } + } + return removed; + } + + private boolean hasDialogOn(final @NotNull Activity activity) { + for (final @NotNull VisibleDialog visibleDialog : visibleDialogs) { + if (visibleDialog.dialogRef.get() != null && visibleDialog.activityRef.get() == activity) { + return true; + } + } + return false; + } + + @TestOnly + @Nullable + Activity getDialogActivity() { + for (final @NotNull VisibleDialog visibleDialog : visibleDialogs) { + if (visibleDialog.dialogRef.get() != null) { + return visibleDialog.activityRef.get(); + } + } + return null; + } + + /** Creates the dialog shown on shake. Replaceable in tests to simulate a failing show(). */ + interface DialogFactory { + @NotNull + Dialog create(final @NotNull Activity activity); + } + + private @NotNull DialogFactory dialogFactory = + activity -> new SentryUserFeedbackForm.Builder(activity).create(); + + @TestOnly + void setDialogFactory(final @NotNull DialogFactory dialogFactory) { + this.dialogFactory = dialogFactory; + } + + private static final class VisibleDialog { + private final @NotNull WeakReference activityRef; + private final @NotNull WeakReference

dialogRef; + + VisibleDialog(final @NotNull Activity activity, final @NotNull Dialog dialog) { + this.activityRef = new WeakReference<>(activity); + this.dialogRef = new WeakReference<>(dialog); + } + } + + @Override + public void close() throws IOException { + disableOnShake(); + visibleDialogs.clear(); + } + + @Override + public void onActivityResumed(final @NotNull Activity activity) { + currentActivityRef = new WeakReference<>(activity); + startShakeDetection(activity); + } + + @Override + public void onActivityPaused(final @NotNull Activity activity) { + // Only stop if this is the activity we're tracking. When transitioning between + // activities, B.onResume may fire before A.onPause — stopping unconditionally + // would kill shake detection for the new activity. + final @Nullable WeakReference currentRef = currentActivityRef; + final @Nullable Activity current = currentRef != null ? currentRef.get() : null; + if (activity == current) { + stopShakeDetection(); + currentActivityRef = null; + } + } + + @Override + public void onActivityCreated( + final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {} + + @Override + public void onActivityStarted(final @NotNull Activity activity) {} + + @Override + public void onActivityStopped(final @NotNull Activity activity) {} + + @Override + public void onActivitySaveInstanceState( + final @NotNull Activity activity, final @NotNull Bundle outState) {} + + @Override + public void onActivityDestroyed(final @NotNull Activity activity) {} + + private void startShakeDetection(final @NotNull Activity activity) { + if (options == null) { + return; + } + // Stop any existing detection (e.g. when transitioning between activities) + stopShakeDetection(); + // A dialog is already visible here, so a shake could only stack a second one on top of it. + // The dialog has no detector of its own in this case: SentryUserFeedbackForm only starts one + // while shake-to-report is globally disabled, which is exactly when this integration is not + // detecting either. + if (hasDialogOn(activity)) { + return; + } + shakeDetector.start( + activity, + () -> { + final @Nullable WeakReference ref = currentActivityRef; + final Activity active = ref != null ? ref.get() : null; + final Boolean inBackground = AppState.getInstance().isInBackground(); + if (active == null + || options == null + || !enabled + || hasDialogOn(active) + || Boolean.TRUE.equals(inBackground)) { + return; + } + active.runOnUiThread( + () -> { + // Re-check on the main thread: shake-to-report may have been disabled, or an + // earlier queued shake may have shown a dialog in the meantime (the dialog reports + // itself synchronously in onStart). + if (!enabled + || hasDialogOn(active) + || active.isFinishing() + || active.isDestroyed()) { + return; + } + @Nullable Dialog dialog = null; + try { + dialog = dialogFactory.create(active); + dialog.show(); + } catch (Throwable e) { + if (dialog != null) { + onDialogGone(dialog); + } + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e); + } + }); + }); + } + + private void stopShakeDetection() { + shakeDetector.stop(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index cae558f0d43..2779f803a69 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -1,7 +1,6 @@ package io.sentry.android.core; import static io.sentry.Sentry.getCurrentScopes; -import static io.sentry.SentryLevel.DEBUG; import static io.sentry.SentryLevel.INFO; import static io.sentry.SentryLevel.WARNING; @@ -291,13 +290,6 @@ private static void deleteCurrentSessionFile(final @NotNull SentryOptions option return; } - if (!options.isEnableAutoSessionTracking()) { - options - .getLogger() - .log(DEBUG, "Session tracking is disabled, bailing from deleting current session file."); - return; - } - final File sessionFile = EnvelopeCache.getCurrentSessionFile(cacheDirPath); if (!sessionFile.delete()) { options.getLogger().log(WARNING, "Failed to delete the current session file."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 3d4cedb1b53..de1c40c570c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -8,9 +8,7 @@ import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.AutoClosableReentrantLock; -import io.sentry.util.LazyEvaluator; -import java.util.Timer; -import java.util.TimerTask; +import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -22,9 +20,8 @@ final class LifecycleWatcher implements AppState.AppStateListener { private final long sessionIntervalMillis; - private @Nullable TimerTask timerTask; - private final @NotNull LazyEvaluator timer = new LazyEvaluator<>(() -> new Timer(true)); - private final @NotNull AutoClosableReentrantLock timerLock = new AutoClosableReentrantLock(); + private @Nullable Future endSessionFuture; + private final @NotNull AutoClosableReentrantLock endSessionLock = new AutoClosableReentrantLock(); private final @NotNull IScopes scopes; private final boolean enableSessionTracking; private final boolean enableAppLifecycleBreadcrumbs; @@ -104,29 +101,40 @@ public void onBackground() { } private void scheduleEndSession() { - try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { + try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) { cancelTask(); - timerTask = - new TimerTask() { - @Override - public void run() { - if (enableSessionTracking) { - scopes.endSession(); - } - scopes.getOptions().getReplayController().stop(); - scopes.getOptions().getContinuousProfiler().close(false); + final @NotNull Runnable endSession = + () -> { + if (enableSessionTracking) { + scopes.endSession(); } + scopes.getOptions().getReplayController().stop(); + scopes.getOptions().getContinuousProfiler().close(false); }; - timer.getValue().schedule(timerTask, sessionIntervalMillis); + try { + endSessionFuture = + scopes + .getOptions() + .getTimerExecutorService() + .schedule(endSession, sessionIntervalMillis); + } catch (Throwable e) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.WARNING, "Failed to schedule end of session. Ending it now.", e); + // if we cannot re-check after the session interval, end the session right away instead of + // leaving it open forever + endSession.run(); + } } } private void cancelTask() { - try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { - if (timerTask != null) { - timerTask.cancel(); - timerTask = null; + try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) { + if (endSessionFuture != null) { + endSessionFuture.cancel(false); + endSessionFuture = null; } } } @@ -144,13 +152,7 @@ private void addAppBreadcrumb(final @NotNull String state) { @TestOnly @Nullable - TimerTask getTimerTask() { - return timerTask; - } - - @TestOnly - @NotNull - Timer getTimer() { - return timer.getValue(); + Future getEndSessionFuture() { + return endSessionFuture; } } 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 3de708ce34c..f21d4c801a3 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 @@ -6,11 +6,14 @@ import io.sentry.ILogger; import io.sentry.InitPriority; import io.sentry.ProfileLifecycle; +import io.sentry.ScreenshotStrategyType; import io.sentry.SentryFeedbackOptions; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryLevel; +import io.sentry.SentryReplayOptions; import io.sentry.protocol.SdkVersion; import io.sentry.util.Objects; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -30,11 +33,23 @@ final class ManifestMetadataReader { static final String ANR_REPORT_DEBUG = "io.sentry.anr.report-debug"; static final String ANR_TIMEOUT_INTERVAL_MILLIS = "io.sentry.anr.timeout-interval-millis"; static final String ANR_ATTACH_THREAD_DUMPS = "io.sentry.anr.attach-thread-dumps"; + static final String ANR_REPORT_HISTORICAL = "io.sentry.anr.report-historical"; + + static final String NDK_APP_HANG_TRACKING_ENABLE = "io.sentry.ndk.app-hang.enable"; + + static final String NDK_APP_HANG_TIMEOUT_INTERVAL_MILLIS = + "io.sentry.ndk.app-hang.timeout-interval-millis"; + + static final String TOMBSTONE_ENABLE = "io.sentry.tombstone.enable"; + static final String TOMBSTONE_ATTACH_RAW = "io.sentry.tombstone.attach-raw"; + static final String TOMBSTONE_REPORT_HISTORICAL = "io.sentry.tombstone.report-historical"; static final String AUTO_INIT = "io.sentry.auto-init"; static final String NDK_ENABLE = "io.sentry.ndk.enable"; static final String NDK_SCOPE_SYNC_ENABLE = "io.sentry.ndk.scope-sync.enable"; + static final String NDK_SDK_NAME = "io.sentry.ndk.sdk-name"; static final String RELEASE = "io.sentry.release"; + static final String DIST = "io.sentry.dist"; static final String ENVIRONMENT = "io.sentry.environment"; static final String SDK_NAME = "io.sentry.sdk.name"; static final String SDK_VERSION = "io.sentry.sdk.version"; @@ -83,6 +98,7 @@ final class ManifestMetadataReader { static final String ATTACH_VIEW_HIERARCHY = "io.sentry.attach-view-hierarchy"; static final String CLIENT_REPORTS_ENABLE = "io.sentry.send-client-reports"; static final String COLLECT_ADDITIONAL_CONTEXT = "io.sentry.additional-context"; + static final String COLLECT_EXTERNAL_STORAGE_CONTEXT = "io.sentry.external-storage-context"; static final String SEND_DEFAULT_PII = "io.sentry.send-default-pii"; @@ -98,8 +114,13 @@ final class ManifestMetadataReader { static final String ENABLE_PERFORMANCE_V2 = "io.sentry.performance-v2.enable"; + static final String ENABLE_STANDALONE_APP_START_TRACING = + "io.sentry.standalone-app-start-tracing.enable"; + static final String ENABLE_APP_START_PROFILING = "io.sentry.profiling.enable-app-start"; + static final String ENABLE_LEGACY_PROFILING = "io.sentry.profiling.enable-legacy-profiling"; + static final String ENABLE_SCOPE_PERSISTENCE = "io.sentry.enable-scope-persistence"; static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.session-replay.session-sample-rate"; @@ -111,6 +132,24 @@ final class ManifestMetadataReader { static final String REPLAYS_MASK_ALL_IMAGES = "io.sentry.session-replay.mask-all-images"; static final String REPLAYS_DEBUG = "io.sentry.session-replay.debug"; + static final String REPLAYS_SCREENSHOT_STRATEGY = "io.sentry.session-replay.screenshot-strategy"; + static final String REPLAYS_CAPTURE_SURFACE_VIEWS = + "io.sentry.session-replay.capture-surface-views"; + + static final String REPLAYS_NETWORK_DETAIL_ALLOW_URLS = + "io.sentry.session-replay.network-detail-allow-urls"; + + static final String REPLAYS_NETWORK_DETAIL_DENY_URLS = + "io.sentry.session-replay.network-detail-deny-urls"; + + static final String REPLAYS_NETWORK_CAPTURE_BODIES = + "io.sentry.session-replay.network-capture-bodies"; + + static final String REPLAYS_NETWORK_REQUEST_HEADERS = + "io.sentry.session-replay.network-request-headers"; + + static final String REPLAYS_NETWORK_RESPONSE_HEADERS = + "io.sentry.session-replay.network-response-headers"; static final String FORCE_INIT = "io.sentry.force-init"; @@ -124,6 +163,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"; @@ -141,6 +182,23 @@ final class ManifestMetadataReader { static final String FEEDBACK_SHOW_BRANDING = "io.sentry.feedback.show-branding"; + static final String STRICT_TRACE_CONTINUATION = "io.sentry.strict-trace-continuation.enabled"; + static final String ORG_ID = "io.sentry.org-id"; + + static final String FEEDBACK_USE_SHAKE_GESTURE = "io.sentry.feedback.use-shake-gesture"; + + static final String SPOTLIGHT_ENABLE = "io.sentry.spotlight.enable"; + + static final String SPOTLIGHT_CONNECTION_URL = "io.sentry.spotlight.url"; + + static final String SCREENSHOT_MASK_ALL_TEXT = "io.sentry.screenshot.mask-all-text"; + + static final String SCREENSHOT_MASK_ALL_IMAGES = "io.sentry.screenshot.mask-all-images"; + + static final String ANR_PROFILING_SAMPLE_RATE = "io.sentry.anr.profiling.sample-rate"; + + static final String ENABLE_ANR_FINGERPRINTING = "io.sentry.anr.enable-fingerprinting"; + /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -178,6 +236,16 @@ static void applyMetadata( } options.setAnrEnabled(readBool(metadata, logger, ANR_ENABLE, options.isAnrEnabled())); + options.setTombstoneEnabled( + readBool(metadata, logger, TOMBSTONE_ENABLE, options.isTombstoneEnabled())); + options.setAttachRawTombstone( + readBool(metadata, logger, TOMBSTONE_ATTACH_RAW, options.isAttachRawTombstone())); + options.setReportHistoricalTombstones( + readBool( + metadata, + logger, + TOMBSTONE_REPORT_HISTORICAL, + options.isReportHistoricalTombstones())); // use enableAutoSessionTracking as fallback options.setEnableAutoSessionTracking( @@ -207,6 +275,23 @@ static void applyMetadata( options.setAttachAnrThreadDump( readBool(metadata, logger, ANR_ATTACH_THREAD_DUMPS, options.isAttachAnrThreadDump())); + options.setReportHistoricalAnrs( + readBool(metadata, logger, ANR_REPORT_HISTORICAL, options.isReportHistoricalAnrs())); + + options.setEnableNdkAppHangTracking( + readBool( + metadata, + logger, + NDK_APP_HANG_TRACKING_ENABLE, + options.isEnableNdkAppHangTracking())); + + options.setNdkAppHangTimeoutIntervalMillis( + readLong( + metadata, + logger, + NDK_APP_HANG_TIMEOUT_INTERVAL_MILLIS, + options.getNdkAppHangTimeoutIntervalMillis())); + final @Nullable String dsn = readString(metadata, logger, DSN, options.getDsn()); final boolean enabled = readBool(metadata, logger, ENABLE_SENTRY, options.isEnabled()); @@ -230,8 +315,16 @@ static void applyMetadata( options.setEnableScopeSync( readBool(metadata, logger, NDK_SCOPE_SYNC_ENABLE, options.isEnableScopeSync())); + final @Nullable String nativeSdkName = + readString(metadata, logger, NDK_SDK_NAME, options.getNativeSdkName()); + if (nativeSdkName != null) { + options.setNativeSdkName(nativeSdkName); + } + options.setRelease(readString(metadata, logger, RELEASE, options.getRelease())); + options.setDist(readString(metadata, logger, DIST, options.getDist())); + options.setEnvironment(readString(metadata, logger, ENVIRONMENT, options.getEnvironment())); options.setSessionTrackingIntervalMillis( @@ -319,6 +412,13 @@ static void applyMetadata( COLLECT_ADDITIONAL_CONTEXT, options.isCollectAdditionalContext())); + options.setCollectExternalStorageContext( + readBool( + metadata, + logger, + COLLECT_EXTERNAL_STORAGE_CONTEXT, + options.isCollectExternalStorageContext())); + if (options.getTracesSampleRate() == null) { final double tracesSampleRate = readDouble(metadata, logger, TRACES_SAMPLE_RATE); if (tracesSampleRate != -1) { @@ -433,10 +533,20 @@ static void applyMetadata( options.setEnablePerformanceV2( readBool(metadata, logger, ENABLE_PERFORMANCE_V2, options.isEnablePerformanceV2())); + options.setEnableStandaloneAppStartTracing( + readBool( + metadata, + logger, + ENABLE_STANDALONE_APP_START_TRACING, + options.isEnableStandaloneAppStartTracing())); + options.setEnableAppStartProfiling( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); + options.setEnableLegacyProfiling( + readBool(metadata, logger, ENABLE_LEGACY_PROFILING, options.isEnableLegacyProfiling())); + options.setEnableScopePersistence( readBool( metadata, logger, ENABLE_SCOPE_PERSISTENCE, options.isEnableScopePersistence())); @@ -476,6 +586,106 @@ static void applyMetadata( options.getSessionReplay().setDebug(readBool(metadata, logger, REPLAYS_DEBUG, false)); + final @Nullable String screenshotStrategyRaw = + readString(metadata, logger, REPLAYS_SCREENSHOT_STRATEGY, null); + if (screenshotStrategyRaw != null) { + if ("canvas".equals(screenshotStrategyRaw.toLowerCase(Locale.ROOT))) { + options.getSessionReplay().setScreenshotStrategy(ScreenshotStrategyType.CANVAS); + } else { + // always default to PIXEL_COPY + options.getSessionReplay().setScreenshotStrategy(ScreenshotStrategyType.PIXEL_COPY); + } + } + + options + .getSessionReplay() + .setCaptureSurfaceViews( + readBool( + metadata, + logger, + REPLAYS_CAPTURE_SURFACE_VIEWS, + options.getSessionReplay().isCaptureSurfaceViews())); + + // Network Details Configuration + if (options.getSessionReplay().getNetworkDetailAllowUrls().isEmpty()) { + final @Nullable List allowUrls = + readList(metadata, logger, REPLAYS_NETWORK_DETAIL_ALLOW_URLS); + if (allowUrls != null && !allowUrls.isEmpty()) { + final List filteredUrls = new ArrayList<>(); + for (String url : allowUrls) { + final String trimmedUrl = url.trim(); + if (!trimmedUrl.isEmpty()) { + filteredUrls.add(trimmedUrl); + } + } + if (!filteredUrls.isEmpty()) { + options.getSessionReplay().setNetworkDetailAllowUrls(filteredUrls); + } + } + } + + if (options.getSessionReplay().getNetworkDetailDenyUrls().isEmpty()) { + final @Nullable List denyUrls = + readList(metadata, logger, REPLAYS_NETWORK_DETAIL_DENY_URLS); + if (denyUrls != null && !denyUrls.isEmpty()) { + final List filteredUrls = new ArrayList<>(); + for (String url : denyUrls) { + final String trimmedUrl = url.trim(); + if (!trimmedUrl.isEmpty()) { + filteredUrls.add(trimmedUrl); + } + } + if (!filteredUrls.isEmpty()) { + options.getSessionReplay().setNetworkDetailDenyUrls(filteredUrls); + } + } + } + + options + .getSessionReplay() + .setNetworkCaptureBodies( + readBool( + metadata, + logger, + REPLAYS_NETWORK_CAPTURE_BODIES, + options.getSessionReplay().isNetworkCaptureBodies() /* defaultValue */)); + + if (options.getSessionReplay().getNetworkRequestHeaders().size() + == SentryReplayOptions.getNetworkDetailsDefaultHeaders().size()) { // Only has defaults + final @Nullable List requestHeaders = + readList(metadata, logger, REPLAYS_NETWORK_REQUEST_HEADERS); + if (requestHeaders != null) { + final List filteredHeaders = new ArrayList<>(); + for (String header : requestHeaders) { + final String trimmedHeader = header.trim(); + if (!trimmedHeader.isEmpty()) { + filteredHeaders.add(trimmedHeader); + } + } + if (!filteredHeaders.isEmpty()) { + options.getSessionReplay().setNetworkRequestHeaders(filteredHeaders); + } + } + } + + if (options.getSessionReplay().getNetworkResponseHeaders().size() + == SentryReplayOptions.getNetworkDetailsDefaultHeaders().size()) { // Only has defaults + final @Nullable List responseHeaders = + readList(metadata, logger, REPLAYS_NETWORK_RESPONSE_HEADERS); + if (responseHeaders != null && !responseHeaders.isEmpty()) { + final List filteredHeaders = new ArrayList<>(); + for (String header : responseHeaders) { + final String trimmedHeader = header.trim(); + if (!trimmedHeader.isEmpty()) { + filteredHeaders.add(trimmedHeader); + } + } + if (!filteredHeaders.isEmpty()) { + options.getSessionReplay().setNetworkResponseHeaders(filteredHeaders); + } + } + } + options.setIgnoredErrors(readList(metadata, logger, IGNORED_ERRORS)); final @Nullable List includes = readList(metadata, logger, IN_APP_INCLUDES); @@ -496,6 +706,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())); @@ -510,6 +725,47 @@ static void applyMetadata( metadata, logger, FEEDBACK_USE_SENTRY_USER, feedbackOptions.isUseSentryUser())); feedbackOptions.setShowBranding( readBool(metadata, logger, FEEDBACK_SHOW_BRANDING, feedbackOptions.isShowBranding())); + feedbackOptions.setUseShakeGesture( + readBool( + metadata, logger, FEEDBACK_USE_SHAKE_GESTURE, feedbackOptions.isUseShakeGesture())); + + options.setStrictTraceContinuation( + readBool( + metadata, logger, STRICT_TRACE_CONTINUATION, options.isStrictTraceContinuation())); + + final @Nullable String orgId = readString(metadata, logger, ORG_ID, null); + if (orgId != null) { + options.setOrgId(orgId); + } + + options.setEnableSpotlight( + readBool(metadata, logger, SPOTLIGHT_ENABLE, options.isEnableSpotlight())); + + final @Nullable String spotlightUrl = + readString(metadata, logger, SPOTLIGHT_CONNECTION_URL, null); + if (spotlightUrl != null) { + options.setSpotlightConnectionUrl(spotlightUrl); + } + + // Screenshot masking options (default to false for backwards compatibility) + options + .getScreenshot() + .setMaskAllText(readBool(metadata, logger, SCREENSHOT_MASK_ALL_TEXT, false)); + options + .getScreenshot() + .setMaskAllImages(readBool(metadata, logger, SCREENSHOT_MASK_ALL_IMAGES, false)); + + if (options.getAnrProfilingSampleRate() == null) { + final double anrProfilingSampleRate = + readDouble(metadata, logger, ANR_PROFILING_SAMPLE_RATE); + if (anrProfilingSampleRate != -1) { + options.setAnrProfilingSampleRate(anrProfilingSampleRate); + } + } + + options.setEnableAnrFingerprinting( + readBool( + metadata, logger, ENABLE_ANR_FINGERPRINTING, options.isEnableAnrFingerprinting())); } options .getLogger() @@ -528,7 +784,9 @@ private static boolean readBool( final @NotNull String key, final boolean defaultValue) { final boolean value = metadata.getBoolean(key, defaultValue); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } @@ -538,7 +796,9 @@ private static boolean readBool( final @NotNull String key, final @Nullable String defaultValue) { final String value = metadata.getString(key, defaultValue); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } @@ -548,14 +808,18 @@ private static boolean readBool( final @NotNull String key, final @NotNull String defaultValue) { final String value = metadata.getString(key, defaultValue); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } private static @Nullable List readList( final @NotNull Bundle metadata, final @NotNull ILogger logger, final @NotNull String key) { final String value = metadata.getString(key); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } if (value != null) { return Arrays.asList(value.split(",", -1)); } else { @@ -570,7 +834,9 @@ private static double readDouble( if (value == -1) { value = ((Integer) metadata.getInt(key, -1)).doubleValue(); } - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } @@ -581,7 +847,9 @@ private static long readLong( final long defaultValue) { // manifest meta-data only reads int if the value is not big enough final long value = metadata.getInt(key, (int) defaultValue); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NativeEventCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/NativeEventCollector.java new file mode 100644 index 00000000000..2cf5acd05fa --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NativeEventCollector.java @@ -0,0 +1,536 @@ +package io.sentry.android.core; + +import static io.sentry.cache.EnvelopeCache.PREFIX_CURRENT_SESSION_FILE; +import static io.sentry.cache.EnvelopeCache.PREFIX_PREVIOUS_SESSION_FILE; +import static io.sentry.cache.EnvelopeCache.STARTUP_CRASH_MARKER_FILE; +import static java.nio.charset.StandardCharsets.UTF_8; + +import io.sentry.JsonObjectReader; +import io.sentry.SentryEnvelope; +import io.sentry.SentryEnvelopeItem; +import io.sentry.SentryEvent; +import io.sentry.SentryItemType; +import io.sentry.SentryLevel; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Collects native crash events from the outbox directory. These events can be correlated with + * tombstone events from ApplicationExitInfo to avoid sending duplicate crash reports. + */ +@ApiStatus.Internal +public final class NativeEventCollector { + + private static final String NATIVE_PLATFORM = "native"; + + private static final long TIMESTAMP_TOLERANCE_MS = 5000; + + private final @NotNull SentryAndroidOptions options; + + /** Lightweight metadata collected during scan phase. */ + private final @NotNull List nativeEnvelopes = new ArrayList<>(); + + private boolean collected = false; + + public NativeEventCollector(final @NotNull SentryAndroidOptions options) { + this.options = options; + } + + /** Lightweight metadata for matching phase - only file reference and timestamp. */ + static final class NativeEnvelopeMetadata { + private final @NotNull File file; + private final long timestampMs; + + NativeEnvelopeMetadata(final @NotNull File file, final long timestampMs) { + this.file = file; + this.timestampMs = timestampMs; + } + + @NotNull + File getFile() { + return file; + } + + long getTimestampMs() { + return timestampMs; + } + } + + /** Holds a native event along with its source file for later deletion. */ + public static final class NativeEventData { + private final @NotNull SentryEvent event; + private final @NotNull File file; + private final @NotNull SentryEnvelope envelope; + + NativeEventData( + final @NotNull SentryEvent event, + final @NotNull File file, + final @NotNull SentryEnvelope envelope) { + this.event = event; + this.file = file; + this.envelope = envelope; + } + + public @NotNull SentryEvent getEvent() { + return event; + } + + public @NotNull File getFile() { + return file; + } + + public @NotNull SentryEnvelope getEnvelope() { + return envelope; + } + } + + /** + * Scans the outbox directory and collects all native crash events. This method should be called + * once before processing tombstones. Subsequent calls are no-ops. + */ + public void collect() { + if (collected) { + return; + } + collected = true; + + final @Nullable String outboxPath = options.getOutboxPath(); + if (outboxPath == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Outbox path is null, skipping native event collection."); + return; + } + + final File outboxDir = new File(outboxPath); + final File[] files = outboxDir.listFiles(); + if (files == null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Outbox path is not a directory or an I/O error occurred: %s", + outboxPath); + return; + } + if (files.length == 0) { + options.getLogger().log(SentryLevel.DEBUG, "No envelope files found in outbox."); + return; + } + + options + .getLogger() + .log(SentryLevel.DEBUG, "Scanning %d files in outbox for native events.", files.length); + + for (final File file : files) { + if (!file.isFile() || !isRelevantFileName(file.getName())) { + continue; + } + + final @Nullable NativeEnvelopeMetadata metadata = extractNativeEnvelopeMetadata(file); + if (metadata != null) { + nativeEnvelopes.add(metadata); + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Found native event in outbox: %s (timestamp: %d)", + file.getName(), + metadata.getTimestampMs()); + } + } + + options + .getLogger() + .log(SentryLevel.DEBUG, "Collected %d native events from outbox.", nativeEnvelopes.size()); + } + + /** + * Finds a native event that matches the given tombstone timestamp. If a match is found, it is + * removed from the internal list so it won't be matched again. + * + *

This method will lazily collect native events from the outbox on first call. + * + * @param tombstoneTimestampMs the timestamp from ApplicationExitInfo + * @return the matching native event data, or null if no match found + */ + public @Nullable NativeEventData findAndRemoveMatchingNativeEvent( + final long tombstoneTimestampMs) { + + // Lazily collect on first use (runs on executor thread, not main thread) + collect(); + + for (final NativeEnvelopeMetadata metadata : nativeEnvelopes) { + final long timeDiff = Math.abs(tombstoneTimestampMs - metadata.getTimestampMs()); + if (timeDiff <= TIMESTAMP_TOLERANCE_MS) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Matched native event by timestamp (diff: %d ms)", timeDiff); + nativeEnvelopes.remove(metadata); + // Only load full event data when we have a match + return loadFullNativeEventData(metadata.getFile()); + } + } + + return null; + } + + /** + * Deletes a native event file from the outbox. + * + * @param nativeEventData the native event data containing the file reference + * @return true if the file was deleted successfully + */ + public boolean deleteNativeEventFile(final @NotNull NativeEventData nativeEventData) { + final File file = nativeEventData.getFile(); + try { + if (file.delete()) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Deleted native event file from outbox: %s", file.getName()); + return true; + } else { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Failed to delete native event file: %s", + file.getAbsolutePath()); + return false; + } + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.ERROR, e, "Error deleting native event file: %s", file.getAbsolutePath()); + return false; + } + } + + /** + * Extracts only lightweight metadata (timestamp) from an envelope file using streaming parsing. + * This avoids loading the entire envelope and deserializing the full event. + */ + private @Nullable NativeEnvelopeMetadata extractNativeEnvelopeMetadata(final @NotNull File file) { + // we use the backend envelope size limit as a bound for the read loop + final long maxEnvelopeSize = 200 * 1024 * 1024; + long bytesProcessed = 0; + + try (final InputStream stream = new BufferedInputStream(new FileInputStream(file))) { + // Skip envelope header line + final int headerBytes = skipLine(stream); + if (headerBytes < 0) { + return null; + } + bytesProcessed += headerBytes; + + while (bytesProcessed < maxEnvelopeSize) { + final @Nullable String itemHeaderLine = readLine(stream); + if (itemHeaderLine == null || itemHeaderLine.isEmpty()) { + // We reached the end of the envelope + break; + } + bytesProcessed += itemHeaderLine.length() + 1; // +1 for newline + + final @Nullable ItemHeaderInfo headerInfo = parseItemHeader(itemHeaderLine); + if (headerInfo == null) { + break; + } + + if ("event".equals(headerInfo.type)) { + final @Nullable NativeEnvelopeMetadata metadata = + extractMetadataFromEventPayload(stream, headerInfo.length, file); + if (metadata != null) { + return metadata; + } + } else { + skipBytes(stream, headerInfo.length); + } + bytesProcessed += headerInfo.length; + + // Skip the newline after payload (if present) + final int next = stream.read(); + if (next == -1) { + break; + } + bytesProcessed++; + if (next != '\n') { + // Not a newline, we're at the next item header. Can't unread easily, + // but this shouldn't happen with well-formed envelopes + break; + } + } + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + e, + "Error extracting metadata from envelope file: %s", + file.getAbsolutePath()); + } + return null; + } + + /** + * Extracts platform and timestamp from an event payload using streaming JSON parsing. Only reads + * the fields we need and exits early once found. Uses a bounded stream to track position within + * the payload and skip any unread bytes on close, avoiding allocation of the full payload. + */ + private @Nullable NativeEnvelopeMetadata extractMetadataFromEventPayload( + final @NotNull InputStream stream, final int payloadLength, final @NotNull File file) { + + NativeEnvelopeMetadata result = null; + + try (final BoundedInputStream boundedStream = new BoundedInputStream(stream, payloadLength); + final Reader reader = new InputStreamReader(boundedStream, UTF_8)) { + final JsonObjectReader jsonReader = new JsonObjectReader(reader); + + String platform = null; + Date timestamp = null; + + jsonReader.beginObject(); + while (jsonReader.peek() == JsonToken.NAME) { + final String name = jsonReader.nextName(); + switch (name) { + case "platform": + platform = jsonReader.nextStringOrNull(); + break; + case "timestamp": + timestamp = jsonReader.nextDateOrNull(options.getLogger()); + break; + default: + jsonReader.skipValue(); + break; + } + if (platform != null && timestamp != null) { + break; + } + } + + if (NATIVE_PLATFORM.equals(platform) && timestamp != null) { + result = new NativeEnvelopeMetadata(file, timestamp.getTime()); + } + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.DEBUG, e, "Error parsing event JSON from: %s", file.getName()); + } + + return result; + } + + /** Loads the full envelope and event data from a file. Used only when a match is found. */ + private @Nullable NativeEventData loadFullNativeEventData(final @NotNull File file) { + try (final InputStream stream = new BufferedInputStream(new FileInputStream(file))) { + final SentryEnvelope envelope = options.getEnvelopeReader().read(stream); + if (envelope == null) { + return null; + } + + for (final SentryEnvelopeItem item : envelope.getItems()) { + if (!SentryItemType.Event.equals(item.getHeader().getType())) { + continue; + } + + try (final Reader eventReader = + new BufferedReader( + new InputStreamReader(new ByteArrayInputStream(item.getData()), UTF_8))) { + final SentryEvent event = + options.getSerializer().deserialize(eventReader, SentryEvent.class); + if (event != null && NATIVE_PLATFORM.equals(event.getPlatform())) { + return new NativeEventData(event, file, envelope); + } + } + } + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.DEBUG, e, "Error loading envelope file: %s", file.getAbsolutePath()); + } + return null; + } + + /** Minimal item header info needed for streaming. */ + private static final class ItemHeaderInfo { + final @Nullable String type; + final int length; + + ItemHeaderInfo(final @Nullable String type, final int length) { + this.type = type; + this.length = length; + } + } + + /** Parses item header JSON to extract only type and length fields. */ + private @Nullable ItemHeaderInfo parseItemHeader(final @NotNull String headerLine) { + try (final Reader reader = + new InputStreamReader(new ByteArrayInputStream(headerLine.getBytes(UTF_8)), UTF_8)) { + final JsonObjectReader jsonReader = new JsonObjectReader(reader); + + String type = null; + int length = -1; + + jsonReader.beginObject(); + while (jsonReader.peek() == JsonToken.NAME) { + final String name = jsonReader.nextName(); + switch (name) { + case "type": + type = jsonReader.nextStringOrNull(); + break; + case "length": + length = jsonReader.nextInt(); + break; + default: + jsonReader.skipValue(); + break; + } + // Early exit if we have both + if (type != null && length >= 0) { + break; + } + } + + if (length >= 0) { + return new ItemHeaderInfo(type, length); + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, e, "Error parsing item header"); + } + return null; + } + + /** Reads a line from the stream (up to and including newline). Returns null on EOF. */ + private @Nullable String readLine(final @NotNull InputStream stream) throws IOException { + final StringBuilder sb = new StringBuilder(); + int b; + while ((b = stream.read()) != -1) { + if (b == '\n') { + return sb.toString(); + } + sb.append((char) b); + } + return sb.length() > 0 ? sb.toString() : null; + } + + /** + * Skips a line in the stream (up to and including newline). Returns bytes skipped, or -1 on EOF. + */ + private int skipLine(final @NotNull InputStream stream) throws IOException { + int count = 0; + int b; + while ((b = stream.read()) != -1) { + count++; + if (b == '\n') { + return count; + } + } + return count > 0 ? count : -1; + } + + /** Skips exactly n bytes from the stream. */ + private static void skipBytes(final @NotNull InputStream stream, final long count) + throws IOException { + long remaining = count; + while (remaining > 0) { + final long skipped = stream.skip(remaining); + if (skipped == 0) { + // skip() returned 0, try reading instead + if (stream.read() == -1) { + throw new EOFException("Unexpected end of stream while skipping bytes"); + } + remaining--; + } else { + remaining -= skipped; + } + } + } + + private boolean isRelevantFileName(final @Nullable String fileName) { + return fileName != null + && !fileName.startsWith(PREFIX_CURRENT_SESSION_FILE) + && !fileName.startsWith(PREFIX_PREVIOUS_SESSION_FILE) + && !fileName.startsWith(STARTUP_CRASH_MARKER_FILE); + } + + /** + * An InputStream wrapper that tracks reads within a bounded section of the stream. This allows + * callers to read/parse only what they need (e.g., extract a few JSON fields), then skip the + * remainder of the section on close to position the stream at the next envelope item. Does not + * close the underlying stream. + */ + private static final class BoundedInputStream extends InputStream { + private final @NotNull InputStream inner; + private long remaining; + + BoundedInputStream(final @NotNull InputStream inner, final int limit) { + this.inner = inner; + this.remaining = limit; + } + + @Override + public int read() throws IOException { + if (remaining <= 0) { + return -1; + } + final int result = inner.read(); + if (result != -1) { + remaining--; + } + return result; + } + + @Override + public int read(final byte[] b, final int off, final int len) throws IOException { + if (remaining <= 0) { + return -1; + } + final int toRead = Math.min(len, (int) remaining); + final int result = inner.read(b, off, toRead); + if (result > 0) { + remaining -= result; + } + return result; + } + + @Override + public long skip(final long n) throws IOException { + final long toSkip = Math.min(n, remaining); + final long skipped = inner.skip(toSkip); + remaining -= skipped; + return skipped; + } + + @Override + public int available() throws IOException { + return Math.min(inner.available(), (int) remaining); + } + + @Override + public void close() throws IOException { + // Skip any remaining bytes to advance the underlying stream position, + // but don't close the underlying stream, because we might have other + // envelope items to read. + skipBytes(inner, remaining); + + // Reset remaining to 0 to handle multiple close() calls (e.g., from + // try-with-resources when wrapped by InputStreamReader). + remaining = 0; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java new file mode 100644 index 00000000000..731be774339 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java @@ -0,0 +1,647 @@ +package io.sentry.android.core; + +import static io.sentry.DataCategory.All; +import static io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED; + +import android.os.Build; +import android.os.SystemClock; +import androidx.annotation.RequiresApi; +import io.sentry.CompositePerformanceCollector; +import io.sentry.DataCategory; +import io.sentry.IContinuousProfiler; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryExecutorService; +import io.sentry.ISentryLifecycleToken; +import io.sentry.NoOpScopes; +import io.sentry.PerformanceCollectionData; +import io.sentry.ProfileChunk; +import io.sentry.ProfileLifecycle; +import io.sentry.Sentry; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SentryNanotimeDate; +import io.sentry.SentryOptions; +import io.sentry.TracesSampler; +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; +import io.sentry.profilemeasurements.ProfileMeasurement; +import io.sentry.profilemeasurements.ProfileMeasurementValue; +import io.sentry.protocol.SentryId; +import io.sentry.transport.RateLimiter; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.LazyEvaluator; +import io.sentry.util.SentryRandom; +import java.io.File; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; + +/** + * Continuous profiler that uses Android's {@link android.os.ProfilingManager} (API 35+) to capture + * Perfetto stack-sampling traces. + * + *

This class is intentionally separate from {@link AndroidContinuousProfiler} to keep the two + * profiling backends independent. All ProfilingManager API usage is confined to this file and + * {@link PerfettoProfiler}. + * + *

Currently, this class doesn't do app-start profiling {@link SentryPerformanceProvider}. It is + * created during {@code Sentry.init()}. + * + *

Thread safety: all mutable state is guarded by a single {@link + * io.sentry.util.AutoClosableReentrantLock}. Public entry points ({@link #startProfiler}, {@link + * #stopProfiler}, {@link #close}, {@link #onRateLimitChanged}, {@link #reevaluateSampling}, and the + * getters) acquire the lock themselves and are thread-safe. Private methods {@code startInternal} + * and {@code stopInternal} require the caller to hold the lock. + */ +@ApiStatus.Internal +@RequiresApi(api = Build.VERSION_CODES.VANILLA_ICE_CREAM) +public class PerfettoContinuousProfiler + implements IContinuousProfiler, RateLimiter.IRateLimitObserver { + private static final long MAX_CHUNK_DURATION_MILLIS = 60000; + + // Matches the thread name produced by SentryExecutorService's thread factory, used to detect + // when we are already running on the executor thread. + private static final String EXECUTOR_THREAD_NAME_PREFIX = "SentryExecutorServiceThreadFactory"; + + private final @NotNull ILogger logger; + private final @NotNull LazyEvaluator.Evaluator executorServiceSupplier; + private final @NotNull Supplier perfettoProfilerFactory; + + private @Nullable PerfettoProfiler perfettoProfiler = null; + private final @NotNull ChunkMeasurementCollector chunkMeasurements; + private boolean isRunning = false; + private @Nullable IScopes scopes; + private @Nullable CompositePerformanceCollector performanceCollector; + private @Nullable Future stopFuture; + private @NotNull SentryId profilerId = SentryId.EMPTY_ID; + private @NotNull SentryId chunkId = SentryId.EMPTY_ID; + private final @NotNull AtomicBoolean isClosed = new AtomicBoolean(false); + private @NotNull SentryDate startProfileChunkTimestamp = new io.sentry.SentryNanotimeDate(); + private boolean shouldSample = true; + private boolean shouldStop = false; + private boolean isSampled = false; + private int activeTraceCount = 0; + + private final AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + + public PerfettoContinuousProfiler( + final @NotNull ILogger logger, + final @NotNull SentryFrameMetricsCollector frameMetricsCollector, + final @NotNull LazyEvaluator.Evaluator executorServiceSupplier, + final @NotNull Supplier perfettoProfilerFactory) { + this.logger = logger; + this.chunkMeasurements = new ChunkMeasurementCollector(frameMetricsCollector); + this.executorServiceSupplier = executorServiceSupplier; + this.perfettoProfilerFactory = perfettoProfilerFactory; + } + + @Override + public void startProfiler( + final @NotNull ProfileLifecycle profileLifecycle, + final @NotNull TracesSampler tracesSampler) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (shouldSample) { + isSampled = tracesSampler.sampleSessionProfile(SentryRandom.current().nextDouble()); + shouldSample = false; + } + if (!isSampled) { + logger.log(SentryLevel.DEBUG, "Profiler was not started due to sampling decision."); + return; + } + switch (profileLifecycle) { + case TRACE: + activeTraceCount = Math.max(0, activeTraceCount); // safety check. + activeTraceCount++; + break; + case MANUAL: + if (isRunning()) { + logger.log( + SentryLevel.WARNING, + "Unexpected call to startProfiler(MANUAL) while profiler already running. Skipping."); + return; + } + break; + } + if (!isRunning()) { + logger.log(SentryLevel.DEBUG, "Started Profiler."); + shouldStop = false; + startInternal(); + } + } + } + + @Override + public void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + switch (profileLifecycle) { + case TRACE: + activeTraceCount--; + activeTraceCount = Math.max(0, activeTraceCount); // safety check + // If there are active spans, and profile lifecycle is trace, we don't stop the profiler + if (activeTraceCount > 0) { + return; + } + shouldStop = true; + break; + case MANUAL: + shouldStop = true; + break; + } + } + } + + /** + * Stop the profiler as soon as we are rate limited, to avoid the performance overhead. + * + * @param rateLimiter the {@link RateLimiter} instance to check categories against + */ + @Override + public void onRateLimitChanged(@NotNull RateLimiter rateLimiter) { + if (rateLimiter.isActiveForCategory(All) + || rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler."); + stopInternal(false); + } + } + // If we are not rate limited anymore, we don't do anything: the profile is broken, so it's + // useless to restart it automatically + } + + @Override + public void close(final boolean isTerminating) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + activeTraceCount = 0; + shouldStop = true; + if (isTerminating) { + stopInternal(false); + isClosed.set(true); + } + } + } + + @Override + public @NotNull SentryId getProfilerId() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return profilerId; + } + } + + @Override + public @NotNull SentryId getChunkId() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return chunkId; + } + } + + @Override + public boolean isRunning() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return isRunning; + } + } + + /** + * Resolves scopes on first call. Since PerfettoContinuousProfiler is created during Sentry.init() + * and never used for app-start profiling, scopes is guaranteed to be available by the time + * startProfiler is called. + * + *

Caller must hold {@link #lock}. + */ + private @NotNull IScopes resolveScopes() { + if (scopes != null && scopes != NoOpScopes.getInstance()) { + return scopes; + } + final @NotNull IScopes currentScopes = Sentry.getCurrentScopes(); + if (currentScopes == NoOpScopes.getInstance()) { + logger.log( + SentryLevel.ERROR, + "PerfettoContinuousProfiler: scopes not available. This is unexpected."); + return currentScopes; + } + this.scopes = currentScopes; + this.performanceCollector = currentScopes.getOptions().getCompositePerformanceCollector(); + final @Nullable RateLimiter rateLimiter = currentScopes.getRateLimiter(); + if (rateLimiter != null) { + rateLimiter.addRateLimitObserver(this); + } + return scopes; + } + + /** Caller must hold {@link #lock}. */ + private void startInternal() { + final @NotNull IScopes scopes = resolveScopes(); + + final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter(); + if (rateLimiter != null + && (rateLimiter.isActiveForCategory(All) + || rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi))) { + logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler."); + stopInternal(false); + return; + } + + // If device is offline, we don't start the profiler, to avoid flooding the cache + if (scopes.getOptions().getConnectionStatusProvider().getConnectionStatus() == DISCONNECTED) { + logger.log(SentryLevel.WARNING, "Device is offline. Stopping profiler."); + stopInternal(false); + return; + } + startProfileChunkTimestamp = scopes.getOptions().getDateProvider().now(); + + perfettoProfiler = perfettoProfilerFactory.get(); + if (perfettoProfiler == null) { + return; + } + if (!perfettoProfiler.start(MAX_CHUNK_DURATION_MILLIS)) { + logger.log( + SentryLevel.ERROR, + "Failed to start Perfetto profiling. PerfettoProfiler.start() returned false."); + return; + } + + isRunning = true; + + if (profilerId.equals(SentryId.EMPTY_ID)) { + profilerId = new SentryId(); + } + + if (chunkId.equals(SentryId.EMPTY_ID)) { + chunkId = new SentryId(); + } + + chunkMeasurements.start(performanceCollector, chunkId.toString()); + + try { + stopFuture = + executorServiceSupplier + .evaluate() + .schedule( + () -> { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + stopInternal(true); + } + }, + MAX_CHUNK_DURATION_MILLIS); + } catch (RejectedExecutionException e) { + logger.log( + SentryLevel.ERROR, + "Failed to schedule profiling chunk finish. Did you call Sentry.close()?", + e); + shouldStop = true; + } + } + + /** Caller must hold {@link #lock}. */ + private void stopInternal(final boolean restartProfiler) { + final @Nullable PerfettoProfiler currentProfiler = perfettoProfiler; + + if (stopFuture != null) { + stopFuture.cancel(false); + } + + // Make sure perfetto was running + if (currentProfiler == null || !isRunning) { + profilerId = SentryId.EMPTY_ID; + chunkId = SentryId.EMPTY_ID; + return; + } + + final @NotNull IScopes scopes = resolveScopes(); + final @NotNull SentryOptions options = scopes.getOptions(); + + final @NotNull Map measurements = chunkMeasurements.stop(); + + // Capture state needed by the callback before clearing it + final @NotNull SentryId chunkProfilerId = profilerId; + final @NotNull SentryId chunkChunkId = chunkId; + final @NotNull SentryDate chunkTimestamp = startProfileChunkTimestamp; + + isRunning = false; + perfettoProfiler = null; + chunkId = SentryId.EMPTY_ID; + + if (!restartProfiler || shouldStop) { + profilerId = SentryId.EMPTY_ID; + } + + final boolean shouldRestart = restartProfiler && !shouldStop; + + // endAndCollect is non-blocking: the listener fires when the OS delivers the trace file. + // Synchronous: result already available — callback runs inline, lock is still held (re-entrant) + // Asynchronous: callback runs on an OS thread — acquires lock itself for restart + currentProfiler.endAndCollect( + traceFile -> + onChunkCollected( + traceFile, + chunkProfilerId, + chunkChunkId, + measurements, + chunkTimestamp, + shouldRestart, + scopes, + options)); + } + + private void onChunkCollected( + final @Nullable File traceFile, + final @NotNull SentryId chunkProfilerId, + final @NotNull SentryId chunkChunkId, + final @NotNull Map measurements, + final @NotNull SentryDate chunkTimestamp, + final boolean shouldRestart, + final @NotNull IScopes scopes, + final @NotNull SentryOptions options) { + if (traceFile == null) { + logger.log( + SentryLevel.ERROR, + "An error occurred while collecting a profile chunk, and it won't be sent."); + } else { + final ProfileChunk.Builder builder = + new ProfileChunk.Builder( + chunkProfilerId, + chunkChunkId, + measurements, + traceFile, + chunkTimestamp, + ProfileChunk.PLATFORM_ANDROID); + builder.setContentType(ProfileChunk.CONTENT_TYPE_PERFETTO); + sendChunk(builder, scopes, options); + } + + if (shouldRestart) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + // shouldStop is re-checked here (not just at capture time) because a stopProfiler() or + // close() may have been requested while this async callback was pending. + if (isRunning || isClosed.get() || shouldStop) { + logger.log( + SentryLevel.DEBUG, + "Profile chunk finished, but profiler was already restarted, closed or stopped. Skipping."); + return; + } + logger.log(SentryLevel.DEBUG, "Profile chunk finished. Starting a new one."); + startInternal(); + } + } else { + logger.log(SentryLevel.DEBUG, "Profile chunk finished."); + } + } + + public void reevaluateSampling() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + shouldSample = true; + } + } + + private void sendChunk( + final @NotNull ProfileChunk.Builder builder, + final @NotNull IScopes scopes, + final @NotNull SentryOptions options) { + final @NotNull Runnable task = + () -> { + if (isClosed.get()) { + return; + } + scopes.captureProfileChunk(builder.build(options)); + }; + try { + // The chunk timer callback (stopInternal) already runs on the executor thread; submitting + // back into the same single-threaded executor from there can deadlock, so run inline instead. + if (Thread.currentThread().getName().startsWith(EXECUTOR_THREAD_NAME_PREFIX)) { + task.run(); + } else { + executorServiceSupplier.evaluate().submit(task); + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to send profile chunk.", e); + } + } + + /** + * Collects measurements for a single profiling chunk: frame metrics (slow/frozen frames, refresh + * rate) and performance data (CPU usage, memory footprint). + * + *

Frame metrics are delivered on the FrameMetrics HandlerThread. The deques use {@link + * ConcurrentLinkedDeque} because the HandlerThread writes and the executor thread reads. + * + *

Performance data is collected by the {@link CompositePerformanceCollector}'s Timer thread + * every 100ms and returned as a list on {@code stop()}. + */ + @VisibleForTesting + static class ChunkMeasurementCollector { + private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; + private @Nullable String frameMetricsListenerId = null; + private @Nullable CompositePerformanceCollector performanceCollector = null; + private @Nullable String chunkId = null; + + private final @NotNull ConcurrentLinkedDeque + slowFrameRenderMeasurements = new ConcurrentLinkedDeque<>(); + private final @NotNull ConcurrentLinkedDeque + frozenFrameRenderMeasurements = new ConcurrentLinkedDeque<>(); + private final @NotNull ConcurrentLinkedDeque + screenFrameRateMeasurements = new ConcurrentLinkedDeque<>(); + + // Elapsed realtime when the measurement was started (nanosecond precision). + // Used to convert wall-time clock values into ns-since-chunk-start for the measurements + // payload. + private long profileStartElapsedRealtimeNanos = 0; + + ChunkMeasurementCollector(final @NotNull SentryFrameMetricsCollector frameMetricsCollector) { + this.frameMetricsCollector = frameMetricsCollector; + } + + void start( + final @Nullable CompositePerformanceCollector performanceCollector, + final @NotNull String chunkId) { + this.performanceCollector = performanceCollector; + this.chunkId = chunkId; + this.profileStartElapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos(); + + // Start frame metrics collection (runs on the FrameMetrics HandlerThread) + slowFrameRenderMeasurements.clear(); + frozenFrameRenderMeasurements.clear(); + screenFrameRateMeasurements.clear(); + frameMetricsListenerId = + frameMetricsCollector.startCollection( + new SentryFrameMetricsCollector.FrameMetricsCollectorListener() { + float lastRefreshRate = 0; + + @Override + public void onFrameMetricCollected( + final long frameStartNanos, + final long frameEndNanos, + final long durationNanos, + final long delayNanos, + final boolean isSlow, + final boolean isFrozen, + final float refreshRate) { + final long timestampNanos = new SentryNanotimeDate().nanoTimestamp(); + // Convert frameEndNanos (reported by FrameMetricsCollector using System.nanoTime + // / + // SystemClock.uptimeMillis), into the SystemClock.elapsedRealtime to report + // elapsed + // realtime nanos since chunk start + final long frameEndElapsedRealtimeNanos = + frameEndNanos - System.nanoTime() + SystemClock.elapsedRealtimeNanos(); + final long frameTimestampRelativeNanos = + frameEndElapsedRealtimeNanos - profileStartElapsedRealtimeNanos; + + // We don't allow negative relative timestamps, e.g. for a frame that started + // before the chunk did. This should never happen, but we check anyway. + if (frameTimestampRelativeNanos < 0) { + return; + } + if (isFrozen) { + frozenFrameRenderMeasurements.addLast( + new ProfileMeasurementValue( + frameTimestampRelativeNanos, durationNanos, timestampNanos)); + } else if (isSlow) { + slowFrameRenderMeasurements.addLast( + new ProfileMeasurementValue( + frameTimestampRelativeNanos, durationNanos, timestampNanos)); + } + if (refreshRate != lastRefreshRate) { + lastRefreshRate = refreshRate; + screenFrameRateMeasurements.addLast( + new ProfileMeasurementValue( + frameTimestampRelativeNanos, refreshRate, timestampNanos)); + } + } + }); + + // Start performance collection (runs on CompositePerformanceCollector's Timer thread) + if (performanceCollector != null) { + performanceCollector.start(chunkId); + } + } + + /** + * Stops all collection, builds and returns the combined measurements map containing frame + * metrics and performance data (CPU, memory). + */ + @NotNull + Map stop() { + final @NotNull Map measurements = new HashMap<>(); + // Stop frame metrics + frameMetricsCollector.stopCollection(frameMetricsListenerId); + frameMetricsListenerId = null; + addFrameDataToMeasurements(measurements); + + // Stop performance collection + @Nullable List performanceData = null; + if (performanceCollector != null && chunkId != null) { + performanceData = performanceCollector.stop(chunkId); + final long wallClockNowNanos = TimeUnit.MILLISECONDS.toNanos(System.currentTimeMillis()); + final long elapsedRealtimeNowNanos = SystemClock.elapsedRealtimeNanos(); + addPerformanceDataToMeasurements( + performanceData, + measurements, + wallClockNowNanos, + elapsedRealtimeNowNanos, + profileStartElapsedRealtimeNanos); + } + performanceCollector = null; + chunkId = null; + + return measurements; + } + + private void addFrameDataToMeasurements( + final @NotNull Map measurements) { + if (!slowFrameRenderMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_SLOW_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, new ArrayList<>(slowFrameRenderMeasurements))); + } + if (!frozenFrameRenderMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_FROZEN_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, + new ArrayList<>(frozenFrameRenderMeasurements))); + } + if (!screenFrameRateMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_SCREEN_FRAME_RATES, + new ProfileMeasurement( + ProfileMeasurement.UNIT_HZ, new ArrayList<>(screenFrameRateMeasurements))); + } + } + + private static void addPerformanceDataToMeasurements( + final @Nullable List performanceData, + final @NotNull Map measurements, + final long wallClockNowNanos, + final long elapsedRealtimeNowNanos, + final long profileStartElapsedRealtimeNanos) { + if (performanceData == null || performanceData.isEmpty()) { + return; + } + final @NotNull ArrayDeque cpuUsageMeasurements = + new ArrayDeque<>(performanceData.size()); + final @NotNull ArrayDeque memoryUsageMeasurements = + new ArrayDeque<>(performanceData.size()); + final @NotNull ArrayDeque nativeMemoryUsageMeasurements = + new ArrayDeque<>(performanceData.size()); + + // CompositePerformanceCollector.stop() hands back its live list, which its timer thread may + // still write to, so we synchronize on it while iterating, as AndroidProfiler does. + synchronized (performanceData) { + for (final @NotNull PerformanceCollectionData data : performanceData) { + // Convert sample timestamps (reported by CompositePerformanceCollector using + // System.currentTimeMillis), into the SystemClock.elapsedRealtime to report + // elapsed realtime nanos since chunk start + final long nanoTimestamp = data.getNanoTimestamp(); + final long nanosSinceSample = wallClockNowNanos - nanoTimestamp; + final long sampleElapsedRealtimeNanos = elapsedRealtimeNowNanos - nanosSinceSample; + final long relativeStartNs = + sampleElapsedRealtimeNanos - profileStartElapsedRealtimeNanos; + if (data.hasCpuUsagePercentage()) { + cpuUsageMeasurements.addLast( + new ProfileMeasurementValue( + relativeStartNs, data.getCpuUsagePercentage(), nanoTimestamp)); + } + if (data.hasUsedHeapMemory()) { + memoryUsageMeasurements.addLast( + new ProfileMeasurementValue( + relativeStartNs, data.getUsedHeapMemory(), nanoTimestamp)); + } + if (data.hasUsedNativeMemory()) { + nativeMemoryUsageMeasurements.addLast( + new ProfileMeasurementValue( + relativeStartNs, data.getUsedNativeMemory(), nanoTimestamp)); + } + } + } + + if (!cpuUsageMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_CPU_USAGE, + new ProfileMeasurement(ProfileMeasurement.UNIT_PERCENT, cpuUsageMeasurements)); + } + if (!memoryUsageMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_MEMORY_FOOTPRINT, + new ProfileMeasurement(ProfileMeasurement.UNIT_BYTES, memoryUsageMeasurements)); + } + if (!nativeMemoryUsageMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT, + new ProfileMeasurement(ProfileMeasurement.UNIT_BYTES, nativeMemoryUsageMeasurements)); + } + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java new file mode 100644 index 00000000000..d09c7252694 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java @@ -0,0 +1,237 @@ +package io.sentry.android.core; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ProfilingManager; +import android.os.ProfilingResult; +import androidx.annotation.RequiresApi; +import io.sentry.ILogger; +import io.sentry.ISentryExecutorService; +import io.sentry.SentryLevel; +import java.io.File; +import java.util.concurrent.RejectedExecutionException; +import java.util.function.Consumer; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Wraps Android's {@link ProfilingManager} API for a single Perfetto stack-sampling session. + * + *

Each instance is single-use: call {@link #start} once, then {@link #endAndCollect} once. For a + * new profiling session, create a new instance. + */ +@ApiStatus.Internal +@RequiresApi(api = Build.VERSION_CODES.VANILLA_ICE_CREAM) +public class PerfettoProfiler { + + // Bundle keys matching ProfilingManager constants + private static final String KEY_DURATION_MS = "KEY_DURATION_MS"; + private static final String KEY_FREQUENCY_HZ = "KEY_FREQUENCY_HZ"; + + /** + * Fixed sampling frequency for Perfetto stack sampling. Not configurable by the developer. 101Hz + * (rather than 100Hz) to avoid lockstep sampling with the display refresh rate (e.g. 60/120fps), + * matching the legacy profiler's default sampling rate. + */ + private static final int PROFILING_FREQUENCY_HZ = 101; + + private static final long RESULT_TIMEOUT_MS = 5000; + + private final @NotNull ILogger logger; + private final @NotNull ISentryExecutorService executorService; + private final @Nullable ProfilingManager profilingManager; + private final @NotNull CancellationSignal cancellationSignal = new CancellationSignal(); + + private final @NotNull Object profilingResultLock = new Object(); + private volatile @Nullable ProfilingResult profilingResult = null; + + private @Nullable Consumer<@Nullable File> resultListener = null; + private volatile boolean started = false; + + @SuppressLint("WrongConstant") + public PerfettoProfiler( + final @NotNull Context context, + final @NotNull ILogger logger, + final @NotNull ISentryExecutorService executorService) { + this( + logger, + executorService, + (ProfilingManager) context.getSystemService(Context.PROFILING_SERVICE)); + } + + PerfettoProfiler( + final @NotNull ILogger logger, + final @NotNull ISentryExecutorService executorService, + final @Nullable ProfilingManager profilingManager) { + this.logger = logger; + this.executorService = executorService; + this.profilingManager = profilingManager; + } + + public boolean start(final long durationMs) { + if (started) { + logger.log(SentryLevel.WARNING, "PerfettoProfiler was already started."); + return false; + } + started = true; + + if (profilingManager == null) { + logger.log(SentryLevel.WARNING, "ProfilingManager is not available."); + return false; + } + + final Bundle params = new Bundle(); + params.putInt(KEY_DURATION_MS, (int) durationMs); + params.putInt(KEY_FREQUENCY_HZ, PROFILING_FREQUENCY_HZ); + + try { + profilingManager.requestProfiling( + ProfilingManager.PROFILING_TYPE_STACK_SAMPLING, + params, + "sentry-profiling", + cancellationSignal, + Runnable::run, + this::onProfilingResult); + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Failed to request Profiling.", e); + return false; + } + + return true; + } + + /** + * Cancels the current profiling session. The listener is called with the trace file (or null on + * error) once the OS delivers the result. The listener may be called synchronously if the result + * has already arrived, or asynchronously on an OS-managed thread otherwise. + */ + public void endAndCollect(final @NotNull Consumer<@Nullable File> listener) { + if (!started) { + logger.log(SentryLevel.WARNING, "PerfettoProfiler was never started"); + listener.accept(null); + return; + } + + cancellationSignal.cancel(); + + synchronized (profilingResultLock) { + final @Nullable ProfilingResult result = profilingResult; + if (result != null) { + listener.accept(processResult(result)); + return; + } + resultListener = listener; + } + + try { + executorService.schedule( + () -> { + synchronized (profilingResultLock) { + if (resultListener != null) { + logger.log(SentryLevel.WARNING, "Timed out waiting for Perfetto profiling result."); + resultListener.accept(null); + // Nobody consumes a late result anymore, so delete the trace file instead + resultListener = this::deleteTraceFile; + } + } + }, + RESULT_TIMEOUT_MS); + } catch (RejectedExecutionException e) { + logger.log(SentryLevel.DEBUG, "Failed to schedule profiling result timeout.", e); + } + } + + private void onProfilingResult(final @NotNull ProfilingResult result) { + logger.log( + SentryLevel.DEBUG, + "Perfetto ProfilingResult received: errorCode=%d, filePath=%s", + result.getErrorCode(), + result.getResultFilePath()); + + synchronized (profilingResultLock) { + profilingResult = result; + if (resultListener != null) { + resultListener.accept(processResult(result)); + resultListener = null; + } + } + } + + /** + * Deletes a trace file that nobody is going to consume. Called from {@link #onProfilingResult}, + * which the OS delivers on a binder thread, so deleting inline is fine. + */ + private void deleteTraceFile(final @Nullable File traceFile) { + if (traceFile == null) { + return; + } + if (!traceFile.delete()) { + logger.log( + SentryLevel.WARNING, "Failed to delete late Perfetto trace file %s", traceFile.getPath()); + } + } + + private @Nullable File processResult(final @NotNull ProfilingResult result) { + final int errorCode = result.getErrorCode(); + if (errorCode != ProfilingResult.ERROR_NONE) { + switch (errorCode) { + case ProfilingResult.ERROR_FAILED_RATE_LIMIT_PROCESS: + case ProfilingResult.ERROR_FAILED_RATE_LIMIT_SYSTEM: + logger.log( + SentryLevel.INFO, + "Perfetto profiling failed: %s." + + " To disable during development run:" + + " adb shell device_config put profiling_testing rate_limiter.disabled true", + errorCodeToString(errorCode)); + break; + default: + logger.log( + SentryLevel.WARNING, + "Perfetto profiling failed with %s (error code %d): %s." + + " See https://developer.android.com/reference/android/os/ProfilingResult", + errorCodeToString(errorCode), + errorCode, + result.getErrorMessage()); + break; + } + return null; + } + + final @Nullable String resultFilePath = result.getResultFilePath(); + if (resultFilePath == null) { + logger.log(SentryLevel.WARNING, "Perfetto profiling result file path is null."); + return null; + } + + final File traceFile = new File(resultFilePath); + if (!traceFile.exists() || traceFile.length() == 0) { + logger.log(SentryLevel.WARNING, "Perfetto trace file does not exist or is empty."); + return null; + } + + return traceFile; + } + + private static @NotNull String errorCodeToString(final int errorCode) { + switch (errorCode) { + case ProfilingResult.ERROR_FAILED_RATE_LIMIT_PROCESS: + return "ERROR_FAILED_RATE_LIMIT_PROCESS"; + case ProfilingResult.ERROR_FAILED_RATE_LIMIT_SYSTEM: + return "ERROR_FAILED_RATE_LIMIT_SYSTEM"; + case ProfilingResult.ERROR_FAILED_INVALID_REQUEST: + return "ERROR_FAILED_INVALID_REQUEST"; + case ProfilingResult.ERROR_FAILED_PROFILING_IN_PROGRESS: + return "ERROR_FAILED_PROFILING_IN_PROGRESS"; + case ProfilingResult.ERROR_FAILED_POST_PROCESSING: + return "ERROR_FAILED_POST_PROCESSING"; + case ProfilingResult.ERROR_UNKNOWN: + return "ERROR_UNKNOWN"; + default: + return "UNKNOWN_ERROR_CODE"; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index f7b51cce620..d758470baf7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -1,13 +1,16 @@ package io.sentry.android.core; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD; +import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM; +import static io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP; import static io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP; import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.ISentryLifecycleToken; import io.sentry.MeasurementUnit; +import io.sentry.SentryDate; import io.sentry.SentryEvent; import io.sentry.SpanContext; import io.sentry.SpanDataConvention; @@ -27,6 +30,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -84,23 +88,64 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { // the app start measurement is only sent once and only if the transaction has // the app.start span, which is automatically created by the SDK. if (hasAppStartSpan(transaction)) { - if (appStartMetrics.shouldSendStartMeasurements()) { + // For headless starts, appLaunchedInForeground is false, so only headless standalone app + // start transactions bypass the foreground check, not the duplicate-send guard. + final @Nullable SpanContext traceContext = transaction.getContexts().getTrace(); + final boolean isStandaloneAppStartTxn = + traceContext != null && STANDALONE_APP_START_OP.equals(traceContext.getOperation()); + final boolean isHeadlessStandaloneAppStartTxn = + traceContext != null + && isStandaloneAppStartTxn + && !traceContext.getData().containsKey(APP_START_SCREEN_DATA); + + if (appStartMetrics.shouldSendStartMeasurements(isHeadlessStandaloneAppStartTxn)) { final @NotNull TimeSpan appStartTimeSpan = - appStartMetrics.getAppStartTimeSpanWithFallback(options); - final long appStartUpDurationMs = appStartTimeSpan.getDurationMs(); + isHeadlessStandaloneAppStartTxn + ? appStartMetrics.getAppStartTimeSpanForHeadless() + : appStartMetrics.getAppStartTimeSpanWithFallback(options); + final long naturalDurationMs = appStartTimeSpan.getDurationMs(); + + final long appStartUpDurationMs; + final boolean shouldAttachAppStartSpans; + final boolean reportAppStartMeasurement; + final @NotNull AppStartExtension extension = appStartMetrics.getAppStartExtension(); + if (extension.isExtended()) { + final @Nullable SentryDate extendedEnd = extension.getExtendedEndTime(); + if (extendedEnd != null && appStartTimeSpan.hasStarted()) { + // Measure to the extended end, but never shorter than the natural first-frame + // duration. + final long extendedDurationMs = + TimeUnit.NANOSECONDS.toMillis(extendedEnd.nanoTimestamp()) + - appStartTimeSpan.getStartTimestampMs(); + appStartUpDurationMs = Math.max(naturalDurationMs, extendedDurationMs); + shouldAttachAppStartSpans = appStartUpDurationMs != 0; + reportAppStartMeasurement = shouldAttachAppStartSpans; + } else { + // Deadline (null) or no valid start: attach the spans but suppress the measurement so + // it isn't inflated. + appStartUpDurationMs = 0; + shouldAttachAppStartSpans = appStartTimeSpan.hasStarted(); + reportAppStartMeasurement = false; + } + } else { + appStartUpDurationMs = naturalDurationMs; + shouldAttachAppStartSpans = appStartUpDurationMs != 0; + reportAppStartMeasurement = shouldAttachAppStartSpans; + } - // if appStartUpDurationMs is 0, metrics are not ready to be sent - if (appStartUpDurationMs != 0) { - final MeasurementValue value = - new MeasurementValue( - (float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName()); + if (shouldAttachAppStartSpans) { + if (reportAppStartMeasurement) { + final MeasurementValue value = + new MeasurementValue( + (float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName()); - final String appStartKey = - appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD - ? MeasurementValue.KEY_APP_START_COLD - : MeasurementValue.KEY_APP_START_WARM; + final String appStartKey = + appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD + ? MeasurementValue.KEY_APP_START_COLD + : MeasurementValue.KEY_APP_START_WARM; - transaction.getMeasurements().put(appStartKey, value); + transaction.getMeasurements().put(appStartKey, value); + } attachAppStartSpans(appStartMetrics, transaction); appStartMetrics.onAppStartSpansSent(); @@ -216,9 +261,7 @@ private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { } final @Nullable SpanContext context = txn.getContexts().getTrace(); - return context != null - && (context.getOperation().equals(APP_START_COLD) - || context.getOperation().equals(APP_START_WARM)); + return context != null && context.getOperation().equals(STANDALONE_APP_START_OP); } private void attachAppStartSpans( @@ -245,6 +288,16 @@ private void attachAppStartSpans( } } + // For standalone app start transactions, the transaction root IS the app start span + if (parentSpanId == null) { + final @NotNull String txnOp = traceContext.getOperation(); + if (STANDALONE_APP_START_OP.equals(txnOp)) { + parentSpanId = traceContext.getSpanId(); + } + } + + final boolean isStandalone = STANDALONE_APP_START_OP.equals(traceContext.getOperation()); + // Process init final @NotNull TimeSpan processInitTimeSpan = appStartMetrics.createProcessInitSpan(); if (processInitTimeSpan.hasStarted() @@ -252,7 +305,11 @@ private void attachAppStartSpans( txn.getSpans() .add( timeSpanToSentrySpan( - processInitTimeSpan, parentSpanId, traceId, APP_METRICS_PROCESS_INIT_OP)); + processInitTimeSpan, + parentSpanId, + traceId, + APP_METRICS_PROCESS_INIT_OP, + isStandalone)); } // Content Providers @@ -263,7 +320,11 @@ private void attachAppStartSpans( txn.getSpans() .add( timeSpanToSentrySpan( - contentProvider, parentSpanId, traceId, APP_METRICS_CONTENT_PROVIDER_OP)); + contentProvider, + parentSpanId, + traceId, + APP_METRICS_CONTENT_PROVIDER_OP, + isStandalone)); } } @@ -272,7 +333,8 @@ private void attachAppStartSpans( if (appOnCreate.hasStopped()) { txn.getSpans() .add( - timeSpanToSentrySpan(appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP)); + timeSpanToSentrySpan( + appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP, isStandalone)); } } @@ -281,14 +343,17 @@ private static SentrySpan timeSpanToSentrySpan( final @NotNull TimeSpan span, final @Nullable SpanId parentSpanId, final @NotNull SentryId traceId, - final @NotNull String operation) { + final @NotNull String operation, + final boolean isStandaloneAppStart) { final Map defaultSpanData = new HashMap<>(2); defaultSpanData.put(SpanDataConvention.THREAD_ID, AndroidThreadChecker.mainThreadSystemId); defaultSpanData.put(SpanDataConvention.THREAD_NAME, "main"); - defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); - defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + if (!isStandaloneAppStart) { + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + } return new SentrySpan( span.getStartTimestampSecs(), diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java new file mode 100644 index 00000000000..9b4433e255e --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java @@ -0,0 +1,88 @@ +package io.sentry.android.core; + +import static io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE; + +import io.sentry.IOptionsObserver; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.cache.CacheUtils; +import io.sentry.cache.PersistingOptionsObserver; +import io.sentry.protocol.SdkVersion; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Persists the app generation that produced the options cache. + * + *

{@link ApplicationExitInfoEventProcessor} compares the cached {@link + * android.content.pm.PackageInfo#lastUpdateTime} with an exit timestamp before reusing + * launch-specific options. This prevents options written by a later app update from being attached + * to an older ANR or native crash. + * + *

For example: + * + *

    + *
  1. The installed build launches for account A and persists account A's tags and replay + * sampling options. + *
  2. A later launch of the same build exits before SDK initialization, so it cannot persist a + * new options snapshot. + *
  3. The next launch initializes the SDK for account B and reports the previous exit. + *
  4. The matching generation marker lets the processor use account A's persisted options instead + * of account B's current options. + *
+ * + *

This observer must be registered after {@link PersistingOptionsObserver}. Options observers + * are notified one at a time, so the first callback to this observer writes the generation marker + * only after the preceding observer has persisted the complete options snapshot. + */ +final class PersistingOptionsCacheGenerationObserver implements IOptionsObserver { + static final String APP_LAST_UPDATE_TIME_FILENAME = "app-last-update-time.json"; + + private final @NotNull SentryOptions options; + private final long lastUpdateTime; + + PersistingOptionsCacheGenerationObserver( + final @NotNull SentryOptions options, final long lastUpdateTime) { + this.options = options; + this.lastUpdateTime = lastUpdateTime; + } + + @Override + public void setRelease(final @Nullable String release) { + CacheUtils.store( + options, Long.toString(lastUpdateTime), OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME); + } + + static @Nullable Long read(final @NotNull SentryOptions options) { + final String value = + CacheUtils.read(options, OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME, String.class, null); + if (value == null) { + return null; + } + try { + return Long.valueOf(value); + } catch (NumberFormatException e) { + options.getLogger().log(SentryLevel.ERROR, e, "Failed to read options cache generation."); + return null; + } + } + + @Override + public void setProguardUuid(final @Nullable String proguardUuid) {} + + @Override + public void setSdkVersion(final @Nullable SdkVersion sdkVersion) {} + + @Override + public void setEnvironment(final @Nullable String environment) {} + + @Override + public void setDist(final @Nullable String dist) {} + + @Override + public void setTags(final @NotNull Map tags) {} + + @Override + public void setReplayErrorSampleRate(final @Nullable Double replayErrorSampleRate) {} +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 16e96979454..dd0e259f937 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -6,6 +6,7 @@ import android.app.Activity; import android.graphics.Bitmap; +import android.view.View; import io.sentry.Attachment; import io.sentry.EventProcessor; import io.sentry.Hint; @@ -14,9 +15,16 @@ import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.android.core.internal.util.Debouncer; import io.sentry.android.core.internal.util.ScreenshotUtils; +import io.sentry.android.replay.util.MaskRenderer; +import io.sentry.android.replay.util.ViewsKt; +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode; import io.sentry.protocol.SentryTransaction; import io.sentry.util.HintUtils; import io.sentry.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -34,10 +42,15 @@ public final class ScreenshotEventProcessor implements EventProcessor { private final @NotNull Debouncer debouncer; private static final long DEBOUNCE_WAIT_TIME_MS = 2000; private static final int DEBOUNCE_MAX_EXECUTIONS = 3; + private static final long MASKING_TIMEOUT_MS = 2000; + + private final boolean isReplayAvailable; + private final AtomicBoolean isReplayModuleAbsenceLogged = new AtomicBoolean(false); public ScreenshotEventProcessor( final @NotNull SentryAndroidOptions options, - final @NotNull BuildInfoProvider buildInfoProvider) { + final @NotNull BuildInfoProvider buildInfoProvider, + final boolean isReplayAvailable) { this.options = Objects.requireNonNull(options, "SentryAndroidOptions is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); @@ -47,11 +60,17 @@ public ScreenshotEventProcessor( DEBOUNCE_WAIT_TIME_MS, DEBOUNCE_MAX_EXECUTIONS); + this.isReplayAvailable = isReplayAvailable; + if (options.isAttachScreenshot()) { addIntegrationToSdkVersion("Screenshot"); } } + private boolean isMaskingEnabled() { + return !options.getScreenshot().getMaskViewClasses().isEmpty() && isReplayAvailable; + } + @Override public @NotNull SentryTransaction process( @NotNull SentryTransaction transaction, @NotNull Hint hint) { @@ -71,6 +90,15 @@ public ScreenshotEventProcessor( return event; } + if (!isReplayAvailable && !options.getScreenshot().getMaskViewClasses().isEmpty()) { + if (!isReplayModuleAbsenceLogged.getAndSet(true)) { + options + .getLogger() + .log(SentryLevel.WARNING, "Screenshot masking requires sentry-android-replay module"); + } + return event; + } + final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); if (activity == null || HintUtils.isFromHybridSdk(hint)) { return event; @@ -89,16 +117,32 @@ public ScreenshotEventProcessor( return event; } - final Bitmap screenshot = + Bitmap screenshot = captureScreenshot( activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider); if (screenshot == null) { return event; } + // Apply masking if enabled and replay module is available + if (isMaskingEnabled()) { + final @Nullable ViewHierarchyNode rootNode = captureViewHierarchy(activity); + if (rootNode == null) { + screenshot.recycle(); + return event; + } + final @Nullable Bitmap masked = applyMasking(screenshot, rootNode); + if (masked == null) { + // applyMasking already recycles its bitmaps on failure + return event; + } + screenshot = masked; + } + + final Bitmap finalScreenshot = screenshot; hint.setScreenshot( Attachment.fromByteProvider( - () -> ScreenshotUtils.compressBitmapToPng(screenshot, options.getLogger()), + () -> ScreenshotUtils.compressBitmapToPng(finalScreenshot, options.getLogger()), "screenshot.png", "image/png", false)); @@ -106,6 +150,102 @@ public ScreenshotEventProcessor( return event; } + /** + * Captures the view hierarchy on the main thread, since view traversal requires it. If already on + * the main thread, captures directly; otherwise posts to the main thread and waits. + */ + private @Nullable ViewHierarchyNode captureViewHierarchy(final @NotNull Activity activity) { + if (options.getThreadChecker().isMainThread()) { + return buildViewHierarchy(activity); + } + + final AtomicReference result = new AtomicReference<>(null); + final CountDownLatch latch = new CountDownLatch(1); + + try { + activity.runOnUiThread( + () -> { + try { + result.set(buildViewHierarchy(activity)); + } finally { + latch.countDown(); + } + }); + + if (!latch.await(MASKING_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + options + .getLogger() + .log( + SentryLevel.WARNING, "Timed out waiting for view hierarchy capture on main thread"); + return null; + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to capture view hierarchy", e); + return null; + } + + return result.get(); + } + + private @Nullable ViewHierarchyNode buildViewHierarchy(final @NotNull Activity activity) { + try { + final @Nullable View rootView = + activity.getWindow() != null + && activity.getWindow().peekDecorView() != null + && activity.getWindow().peekDecorView().getRootView() != null + ? activity.getWindow().peekDecorView().getRootView() + : null; + if (rootView == null) { + return null; + } + + final ViewHierarchyNode rootNode = + ViewHierarchyNode.Companion.fromView(rootView, null, 0, options.getScreenshot()); + ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger(), null); + return rootNode; + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to build view hierarchy", e); + return null; + } + } + + private @Nullable Bitmap applyMasking( + final @NotNull Bitmap screenshot, final @NotNull ViewHierarchyNode rootNode) { + Bitmap mutableBitmap = screenshot; + boolean createdCopy = false; + try (final MaskRenderer maskRenderer = new MaskRenderer()) { + // Make bitmap mutable if needed + if (!screenshot.isMutable()) { + mutableBitmap = screenshot.copy(Bitmap.Config.RGB_565, true); + if (mutableBitmap == null) { + screenshot.recycle(); + return null; + } + createdCopy = true; + } + + maskRenderer.renderMasks(mutableBitmap, rootNode, null); + + // Recycle original if we created a copy + if (createdCopy && !screenshot.isRecycled()) { + screenshot.recycle(); + } + + return mutableBitmap; + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to mask screenshot", e); + if (createdCopy) { + if (!mutableBitmap.isRecycled()) { + mutableBitmap.recycle(); + } + } + if (!screenshot.isRecycled()) { + screenshot.recycle(); + } + return null; + } + } + @Override public @Nullable Long getOrder() { return 10000L; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 82263ebcbda..ab18a5827b9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -5,11 +5,11 @@ import android.content.Context; import android.os.Process; import android.os.SystemClock; +import android.os.Trace; import io.sentry.ILogger; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; -import io.sentry.OptionsContainer; import io.sentry.Sentry; import io.sentry.SentryLevel; import io.sentry.SentryOptions; @@ -96,9 +96,12 @@ public static void init( @NotNull final Context context, @NotNull ILogger logger, @NotNull Sentry.OptionsConfiguration configuration) { + // Started before acquiring the lock so it stays balanced with the endSection() in the finally + // even if acquire() throws. + Trace.beginSection("SentryAndroid.init"); try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { Sentry.init( - OptionsContainer.create(SentryAndroidOptions.class), + new SentryAndroidOptionsContainer(), options -> { final io.sentry.util.LoadClass classLoader = new io.sentry.util.LoadClass(); final boolean isTimberUpstreamAvailable = @@ -171,7 +174,12 @@ public static void init( } AndroidOptionsInitializer.initializeIntegrationsAndProcessors( - options, context, buildInfoProvider, loadClass, activityFramesTracker); + options, + context, + buildInfoProvider, + loadClass, + activityFramesTracker, + isReplayAvailable); deduplicateIntegrations(options, isFragmentAvailable, isTimberAvailable); }, @@ -215,6 +223,8 @@ public static void init( logger.log(SentryLevel.FATAL, "Fatal error during SentryAndroid.init(...)", e); throw new RuntimeException("Failed to initialize Sentry's SDK", e); + } finally { + Trace.endSection(); } } @@ -232,6 +242,7 @@ private static void deduplicateIntegrations( final List timberIntegrations = new ArrayList<>(); final List fragmentIntegrations = new ArrayList<>(); + final List systemEventsIntegrations = new ArrayList<>(); for (final Integration integration : options.getIntegrations()) { if (isFragmentAvailable) { @@ -244,6 +255,9 @@ private static void deduplicateIntegrations( timberIntegrations.add(integration); } } + if (integration instanceof SystemEventsBreadcrumbsIntegration) { + systemEventsIntegrations.add(integration); + } } if (fragmentIntegrations.size() > 1) { @@ -259,5 +273,12 @@ private static void deduplicateIntegrations( options.getIntegrations().remove(integration); } } + + if (systemEventsIntegrations.size() > 1) { + for (int i = 0; i < systemEventsIntegrations.size() - 1; i++) { + final Integration integration = systemEventsIntegrations.get(i); + options.getIntegrations().remove(integration); + } + } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 221495172eb..615db97a28d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -17,6 +17,7 @@ import io.sentry.protocol.Mechanism; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; +import io.sentry.util.SampleRateUtils; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -36,6 +37,22 @@ public final class SentryAndroidOptions extends SentryOptions { /** Enable or disable ANR on Debug mode Default is disabled Used by AnrIntegration */ private boolean anrReportInDebug = false; + /** + * Enable or disable in-process, heartbeat-based app-hang detection in sentry-native. Default is + * disabled. When enabled, sentry-native's background watchdog captures an app-hang event if no + * heartbeat is received within {@link #ndkAppHangTimeoutIntervalMillis} on the monitored thread. + * + *

This is intended for downstream/hybrid SDKs that emit the heartbeat by calling the native + * {@code sentry_app_hang_heartbeat()} from their main thread. It is independent of the JVM-based + * {@link #anrEnabled} ANR detection. + */ + private boolean enableNdkAppHangTracking = false; + + /** + * The app-hang detection timeout interval in millis used by sentry-native. Default is 5000 = 5s. + */ + private long ndkAppHangTimeoutIntervalMillis = 5000; + /** * Enable or disable automatic breadcrumbs for Activity lifecycle. Using * Application.ActivityLifecycleCallbacks @@ -82,7 +99,7 @@ public final class SentryAndroidOptions extends SentryOptions { *

  • The transaction status will be {@link SpanStatus#OK} if none is set. * * - * The transaction is automatically bound to the {@link IScope}, but only if there's no + *

    The transaction is automatically bound to the {@link IScope}, but only if there's no * transaction already bound to the Scope. */ private boolean enableAutoActivityLifecycleTracing = true; @@ -121,6 +138,9 @@ public final class SentryAndroidOptions extends SentryOptions { */ private boolean collectAdditionalContext = true; + /** Enables or disables collecting of external storage context. */ + private boolean collectExternalStorageContext = false; + /** * Controls how many seconds to wait for sending events in case there were Startup Crashes in the * previous run. Sentry SDKs normally send events from a background queue, but in the case of @@ -217,16 +237,50 @@ public interface BeforeCaptureCallback { */ private boolean reportHistoricalAnrs = false; + /** + * Controls whether to report historical Tombstones from the {@link ApplicationExitInfo} system + * API. When enabled, reports all of the Tombstones available in the {@link + * ActivityManager#getHistoricalProcessExitReasons(String, int, int)} list, as opposed to + * reporting only the latest one. + * + *

    These events do not affect crash rate nor are they enriched with additional information from + * {@link IScope} like breadcrumbs. + */ + private boolean reportHistoricalTombstones = false; + /** * Controls whether to send ANR (v2) thread dump as an attachment with plain text. The thread dump * is being attached from {@link ApplicationExitInfo#getTraceInputStream()}, if available. */ private boolean attachAnrThreadDump = false; + /** + * Controls whether to attach the raw tombstone protobuf as an attachment. The tombstone is being + * attached from {@link ApplicationExitInfo#getTraceInputStream()}, if available. + */ + private boolean attachRawTombstone = false; + private boolean enablePerformanceV2 = true; + private boolean enableStandaloneAppStartTracing = false; + private @Nullable SentryFrameMetricsCollector frameMetricsCollector; + private boolean enableTombstone = false; + + /** + * Screenshot masking options. Configure which views should be masked when capturing screenshots + * on error events. + * + *

    Note: Screenshot masking requires the {@code sentry-android-replay} module to be present at + * runtime. If the replay module is not available, screenshots will be captured without masking. + */ + private final @NotNull SentryScreenshotOptions screenshot = new SentryScreenshotOptions(); + + private @Nullable Double anrProfilingSampleRate; + + private boolean enableAnrFingerprinting = true; + public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -300,6 +354,69 @@ public void setAnrReportInDebug(boolean anrReportInDebug) { this.anrReportInDebug = anrReportInDebug; } + /** + * Checks if heartbeat-based app-hang detection in sentry-native is enabled. Default is disabled. + * + * @return true if enabled or false otherwise + */ + @ApiStatus.Experimental + public boolean isEnableNdkAppHangTracking() { + return enableNdkAppHangTracking; + } + + /** + * Enables or disables heartbeat-based app-hang detection in sentry-native. Default is disabled. + * Requires the NDK integration to be present and emitting heartbeats via the native {@code + * sentry_app_hang_heartbeat()}. + * + * @param enableNdkAppHangTracking true for enabled and false for disabled + */ + @ApiStatus.Experimental + public void setEnableNdkAppHangTracking(boolean enableNdkAppHangTracking) { + this.enableNdkAppHangTracking = enableNdkAppHangTracking; + } + + /** + * Returns the app-hang detection timeout interval in millis used by sentry-native. Default is + * 5000 = 5s. + * + * @return the timeout in millis + */ + @ApiStatus.Experimental + public long getNdkAppHangTimeoutIntervalMillis() { + return ndkAppHangTimeoutIntervalMillis; + } + + /** + * Sets the app-hang detection timeout interval in millis used by sentry-native. Default is 5000 = + * 5s. + * + * @param ndkAppHangTimeoutIntervalMillis the timeout interval in millis + */ + @ApiStatus.Experimental + public void setNdkAppHangTimeoutIntervalMillis(long ndkAppHangTimeoutIntervalMillis) { + this.ndkAppHangTimeoutIntervalMillis = ndkAppHangTimeoutIntervalMillis; + } + + /** + * Sets Tombstone reporting (ApplicationExitInfo.REASON_CRASH_NATIVE) to enabled or disabled. + * + * @param enableTombstone true for enabled and false for disabled + */ + public void setTombstoneEnabled(boolean enableTombstone) { + this.enableTombstone = enableTombstone; + } + + /** + * Checks if Tombstone reporting (ApplicationExitInfo.REASON_CRASH_NATIVE) is enabled or disabled + * Default is disabled + * + * @return true if enabled or false otherwise + */ + public boolean isTombstoneEnabled() { + return enableTombstone; + } + public boolean isEnableActivityLifecycleBreadcrumbs() { return enableActivityLifecycleBreadcrumbs; } @@ -414,6 +531,14 @@ public void setCollectAdditionalContext(boolean collectAdditionalContext) { this.collectAdditionalContext = collectAdditionalContext; } + public boolean isCollectExternalStorageContext() { + return collectExternalStorageContext; + } + + public void setCollectExternalStorageContext(final boolean collectExternalStorageContext) { + this.collectExternalStorageContext = collectExternalStorageContext; + } + public boolean isEnableFramesTracking() { return enableFramesTracking; } @@ -570,6 +695,14 @@ public void setReportHistoricalAnrs(final boolean reportHistoricalAnrs) { this.reportHistoricalAnrs = reportHistoricalAnrs; } + public boolean isReportHistoricalTombstones() { + return reportHistoricalTombstones; + } + + public void setReportHistoricalTombstones(final boolean reportHistoricalTombstones) { + this.reportHistoricalTombstones = reportHistoricalTombstones; + } + public boolean isAttachAnrThreadDump() { return attachAnrThreadDump; } @@ -578,6 +711,14 @@ public void setAttachAnrThreadDump(final boolean attachAnrThreadDump) { this.attachAnrThreadDump = attachAnrThreadDump; } + public boolean isAttachRawTombstone() { + return attachRawTombstone; + } + + public void setAttachRawTombstone(final boolean attachRawTombstone) { + this.attachRawTombstone = attachRawTombstone; + } + /** * @return true if performance-v2 is enabled. See {@link #setEnablePerformanceV2(boolean)} for * more details. @@ -598,6 +739,53 @@ public void setEnablePerformanceV2(final boolean enablePerformanceV2) { this.enablePerformanceV2 = enablePerformanceV2; } + /** + * @return true if standalone app start tracing is enabled. See {@link + * #setEnableStandaloneAppStartTracing(boolean)} for more details. + */ + @ApiStatus.Experimental + public boolean isEnableStandaloneAppStartTracing() { + return enableStandaloneAppStartTracing; + } + + /** + * Enables or disables standalone app start tracing. + * + *

    When enabled, app start is sent as its own transaction instead of an {@code app.start.*} + * child span on the first Activity transaction. + * + *

    The SDK reports app start through these paths: + * + *

      + *
    • With an Activity: the SDK sends an "App Start" transaction with operation {@code + * app.start}, plus a separate {@code ui.load} transaction for the Activity. Both + * transactions share the same trace ID. + *
    • Headless app start: for launches started by something like a broadcast receiver, service, + * or content provider without an Activity, the SDK sends only the standalone app-start + * transaction. + *
        + *
      • On devices running Android 15 (API level 35) or newer, the SDK can use {@code + * ApplicationStartInfo} to classify cold versus warm starts and anchor the end time + * at the {@code Application.onCreate} start. + *
      • On devices running older Android versions, headless launches are treated as cold + * once {@code Application.onCreate} finishes without an Activity. The end time falls + * back to the best SDK/plugin timing available. + *
      • With {@code Application.onCreate} instrumentation, the SDK can add an {@code + * application.load} phase span and use the exact {@code Application.onCreate} end + * time. Without that instrumentation, the standalone transaction is still sent, but + * it may only include the {@code process.load} phase span. + *
      + *
    • If an Activity opens after a headless start, its {@code ui.load} transaction reuses the + * app-start trace ID. + *
    + * + * @param enableStandaloneAppStartTracing true if enabled or false otherwise + */ + @ApiStatus.Experimental + public void setEnableStandaloneAppStartTracing(final boolean enableStandaloneAppStartTracing) { + this.enableStandaloneAppStartTracing = enableStandaloneAppStartTracing; + } + @ApiStatus.Internal public @Nullable SentryFrameMetricsCollector getFrameMetricsCollector() { return frameMetricsCollector; @@ -626,9 +814,59 @@ public void setEnableSystemEventBreadcrumbsExtras( this.enableSystemEventBreadcrumbsExtras = enableSystemEventBreadcrumbsExtras; } - static class AndroidUserFeedbackIDialogHandler implements SentryFeedbackOptions.IDialogHandler { + /** + * Returns the screenshot masking options. + * + * @return the screenshot masking options + */ + public @NotNull SentryScreenshotOptions getScreenshot() { + return screenshot; + } + + public @Nullable Double getAnrProfilingSampleRate() { + return anrProfilingSampleRate; + } + + public void setAnrProfilingSampleRate(final @Nullable Double anrProfilingSampleRate) { + if (!SampleRateUtils.isValidSampleRate(anrProfilingSampleRate)) { + throw new IllegalArgumentException( + "The value " + + anrProfilingSampleRate + + " is not valid. Use null to disable or values >= 0.0 and <= 1.0."); + } + this.anrProfilingSampleRate = anrProfilingSampleRate; + } + + public boolean isAnrProfilingEnabled() { + return anrProfilingSampleRate != null && anrProfilingSampleRate > 0; + } + + /** + * Returns whether ANR fingerprinting is enabled. When enabled, the SDK assigns static + * fingerprints to ANR events that would otherwise produce noisy grouping. Currently, this applies + * a static fingerprint to ANRs whose stacktraces contain only system frames and no application + * frames. + * + * @return true if ANR fingerprinting is enabled + */ + public boolean isEnableAnrFingerprinting() { + return enableAnrFingerprinting; + } + + /** + * Sets whether ANR fingerprinting is enabled. When enabled, the SDK assigns static fingerprints + * to ANR events that would otherwise produce noisy grouping. Currently, this applies a static + * fingerprint to ANRs whose stacktraces contain only system frames and no application frames. + * + * @param enableAnrFingerprinting true to enable ANR fingerprinting + */ + public void setEnableAnrFingerprinting(final boolean enableAnrFingerprinting) { + this.enableAnrFingerprinting = enableAnrFingerprinting; + } + + static class AndroidUserFeedbackFormHandler implements SentryFeedbackOptions.IFormHandler { @Override - public void showDialog( + public void showForm( final @Nullable SentryId associatedEventId, final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); @@ -643,7 +881,7 @@ public void showDialog( return; } - new SentryUserFeedbackDialog.Builder(activity) + new SentryUserFeedbackForm.Builder(activity) .associatedEventId(associatedEventId) .configurator(configurator) .create() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java new file mode 100644 index 00000000000..678f7ab29b2 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java @@ -0,0 +1,16 @@ +package io.sentry.android.core; + +import io.sentry.OptionsContainer; +import org.jetbrains.annotations.NotNull; + +/** + * Direct OptionsContainer for SentryAndroidOptions that avoids reflective + * getDeclaredConstructor().newInstance() on the Android startup path. + */ +final class SentryAndroidOptionsContainer extends OptionsContainer { + + @Override + public @NotNull SentryAndroidOptions createInstance() { + return new SentryAndroidOptions(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryFramesDelayResult.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFramesDelayResult.java new file mode 100644 index 00000000000..724d8446ea8 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFramesDelayResult.java @@ -0,0 +1,31 @@ +package io.sentry.android.core; + +import org.jetbrains.annotations.ApiStatus; + +/** Result of querying frame delay for a given time range. */ +@ApiStatus.Internal +public final class SentryFramesDelayResult { + + private final double delaySeconds; + private final int framesContributingToDelayCount; + + public SentryFramesDelayResult( + final double delaySeconds, final int framesContributingToDelayCount) { + this.delaySeconds = delaySeconds; + this.framesContributingToDelayCount = framesContributingToDelayCount; + } + + /** + * @return the total frame delay in seconds, or -1 if incalculable (e.g. no frame data available) + */ + public double getDelaySeconds() { + return delaySeconds; + } + + /** + * @return the number of frames that contributed to the delay (slow + frozen frames) + */ + public int getFramesContributingToDelayCount() { + return framesContributingToDelayCount; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java index 3c162aab1ad..9d1f2e13ec4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java @@ -13,7 +13,7 @@ import io.sentry.ILogger; import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; -import io.sentry.JsonSerializer; +import io.sentry.JsonObjectReader; import io.sentry.SentryAppStartProfilingOptions; import io.sentry.SentryExecutorService; import io.sentry.SentryLevel; @@ -23,7 +23,6 @@ import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; -import io.sentry.util.AutoClosableReentrantLock; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; @@ -46,7 +45,6 @@ public final class SentryPerformanceProvider extends EmptySecureContentProvider private final @NotNull ILogger logger; private final @NotNull BuildInfoProvider buildInfoProvider; - private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); @TestOnly SentryPerformanceProvider( @@ -119,8 +117,7 @@ private void launchAppStartProfiler(final @NotNull AppStartMetrics appStartMetri try (final @NotNull Reader reader = new BufferedReader(new InputStreamReader(new FileInputStream(configFile)))) { final @Nullable SentryAppStartProfilingOptions profilingOptions = - new JsonSerializer(SentryOptions.empty()) - .deserialize(reader, SentryAppStartProfilingOptions.class); + deserializeProfilingConfig(reader); if (profilingOptions == null) { logger.log( @@ -129,6 +126,23 @@ private void launchAppStartProfiler(final @NotNull AppStartMetrics appStartMetri return; } + if (buildInfoProvider.getSdkInfoVersion() + >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) { + logger.log( + SentryLevel.DEBUG, + "Device is API 35+. Skipping legacy app-start profiling — " + + "Perfetto ProfilingManager will be initialized after Sentry.init()."); + return; + } + + if (!profilingOptions.isEnableLegacyProfiling()) { + logger.log( + SentryLevel.WARNING, + "enableLegacyProfiling is disabled and device is below API 35. " + + "App start profiling will not start."); + return; + } + if (profilingOptions.isContinuousProfilingEnabled() && profilingOptions.isStartProfilerOnAppStart()) { createAndStartContinuousProfiler(context, profilingOptions, appStartMetrics); @@ -151,6 +165,25 @@ private void launchAppStartProfiler(final @NotNull AppStartMetrics appStartMetri } } + /** + * Parses the app start profiling config with only the deserializer it needs. Going through {@link + * io.sentry.JsonSerializer} would allocate a full {@link SentryOptions} plus every registered + * deserializer on the main thread before {@code Application.onCreate}, to use exactly one of + * them. + * + *

    Returns null on malformed input, matching what {@code JsonSerializer.deserialize} did, so + * callers keep reporting it as a deserialization failure rather than a read error. + */ + private @Nullable SentryAppStartProfilingOptions deserializeProfilingConfig( + final @NotNull Reader reader) { + try (final @NotNull JsonObjectReader jsonReader = new JsonObjectReader(reader)) { + return new SentryAppStartProfilingOptions.Deserializer().deserialize(jsonReader, logger); + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Error when deserializing", e); + return null; + } + } + private void createAndStartContinuousProfiler( final @NotNull Context context, final @NotNull SentryAppStartProfilingOptions profilingOptions, @@ -161,6 +194,7 @@ private void createAndStartContinuousProfiler( return; } + final @NotNull SentryExecutorService startupExecutorService = new SentryExecutorService(); final @NotNull IContinuousProfiler appStartContinuousProfiler = new AndroidContinuousProfiler( buildInfoProvider, @@ -169,7 +203,7 @@ private void createAndStartContinuousProfiler( logger, profilingOptions.getProfilingTracesDirPath(), profilingOptions.getProfilingTracesHz(), - new SentryExecutorService()); + () -> startupExecutorService); appStartMetrics.setAppStartProfiler(null); appStartMetrics.setAppStartContinuousProfiler(appStartContinuousProfiler); logger.log(SentryLevel.DEBUG, "App start continuous profiling started."); @@ -199,6 +233,7 @@ private void createAndStartTransactionProfiler( return; } + final @NotNull SentryExecutorService executorService = new SentryExecutorService(); final @NotNull ITransactionProfiler appStartProfiler = new AndroidTransactionProfiler( context, @@ -208,7 +243,7 @@ private void createAndStartTransactionProfiler( profilingOptions.getProfilingTracesDirPath(), profilingOptions.isProfilingEnabled(), profilingOptions.getProfilingTracesHz(), - new SentryExecutorService()); + () -> executorService); appStartMetrics.setAppStartContinuousProfiler(null); appStartMetrics.setAppStartProfiler(appStartProfiler); logger.log(SentryLevel.DEBUG, "App start profiling started."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java new file mode 100644 index 00000000000..8680ed907d1 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryScreenshotOptions.java @@ -0,0 +1,60 @@ +package io.sentry.android.core; + +import io.sentry.SentryMaskingOptions; + +/** + * Screenshot masking options for error screenshots. Extends the base {@link SentryMaskingOptions} + * with screenshot-specific defaults. + * + *

    By default, masking is disabled for screenshots. Enable masking by calling {@link + * #setMaskAllText(boolean)} and/or {@link #setMaskAllImages(boolean)}. + * + *

    Note: Screenshot masking requires the {@code sentry-android-replay} module to be present at + * runtime. If the replay module is not available, screenshots will be captured without masking. + */ +public final class SentryScreenshotOptions extends SentryMaskingOptions { + + public SentryScreenshotOptions() { + // Default to NO masking until next major version. + // maskViewClasses starts empty, so nothing is masked by default. + } + + @Override + public void trackCustomMasking() { + // No-op for screenshots, custom masking tracking is only relevant for session replay. + } + + /** + * {@inheritDoc} + * + *

    When enabling image masking for screenshots, this also adds masking for WebView, VideoView, + * and media player views (ExoPlayer, Media3) since they may contain sensitive content. + */ + @Override + public void setMaskAllImages(final boolean maskAllImages) { + super.setMaskAllImages(maskAllImages); + if (maskAllImages) { + addSensitiveViewClasses(); + } else { + removeSensitiveViewClasses(); + } + } + + private void addSensitiveViewClasses() { + addMaskViewClass(WEB_VIEW_CLASS_NAME); + addMaskViewClass(VIDEO_VIEW_CLASS_NAME); + addMaskViewClass(CAMERAX_PREVIEW_VIEW_CLASS_NAME); + addMaskViewClass(ANDROIDX_MEDIA_VIEW_CLASS_NAME); + addMaskViewClass(EXOPLAYER_CLASS_NAME); + addMaskViewClass(EXOPLAYER_STYLED_CLASS_NAME); + } + + private void removeSensitiveViewClasses() { + getMaskViewClasses().remove(WEB_VIEW_CLASS_NAME); + getMaskViewClasses().remove(VIDEO_VIEW_CLASS_NAME); + getMaskViewClasses().remove(CAMERAX_PREVIEW_VIEW_CLASS_NAME); + getMaskViewClasses().remove(ANDROIDX_MEDIA_VIEW_CLASS_NAME); + getMaskViewClasses().remove(EXOPLAYER_CLASS_NAME); + getMaskViewClasses().remove(EXOPLAYER_STYLED_CLASS_NAME); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java new file mode 100644 index 00000000000..9f4f73d10f4 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java @@ -0,0 +1,254 @@ +// Adapted from Square's Seismic library. +// Copyright 2010 Square, Inc. +// Licensed under the Apache License, Version 2.0. +// https://github.com/square/seismic +package io.sentry.android.core; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.os.Handler; +import android.os.HandlerThread; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Detects shake gestures using the device's accelerometer. + * + *

    The accelerometer sensor (TYPE_ACCELEROMETER) does NOT require any special permissions on + * Android. The BODY_SENSORS permission is only needed for heart rate and similar body sensors. + * + *

    Uses a rolling sample window: if more than 75% of accelerometer readings in the past 0.5s + * exceed {@link #ACCELERATION_THRESHOLD}, a shake is detected. Based on Square's Seismic library. + * + *

    Sensor events are delivered on a background {@link HandlerThread} to avoid polluting the main + * thread. + */ +@ApiStatus.Internal +public final class SentryShakeDetector implements SensorEventListener { + + static final int ACCELERATION_THRESHOLD = 13; + + private @Nullable SensorManager sensorManager; + private @Nullable Sensor accelerometer; + private @Nullable HandlerThread handlerThread; + private @Nullable Handler handler; + private volatile @Nullable Listener listener; + private @NotNull ILogger logger; + private boolean closed; + + private final @NotNull SampleQueue queue = new SampleQueue(); + + public interface Listener { + void onShake(); + } + + public SentryShakeDetector(final @NotNull ILogger logger) { + this.logger = logger; + } + + /** + * Re-arms the detector after a previous {@link #close()} so it can be reused when the owning + * integration is registered again (e.g. a second {@code Sentry.init}). + */ + synchronized void reopen() { + closed = false; + } + + /** + * Initializes the sensor manager and accelerometer sensor. This is separated from start() so the + * values can be resolved once and reused across activity transitions. + */ + synchronized void init(final @NotNull Context context, final @NotNull ILogger logger) { + this.logger = logger; + init(context); + } + + private synchronized void init(final @NotNull Context context) { + // A warm-up submitted to the executor can be drained after close() (integrations are closed + // before the executor shuts down), so bail out instead of spinning up a new HandlerThread. + if (closed) { + return; + } + if (sensorManager == null) { + sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); + } + if (sensorManager != null && accelerometer == null) { + accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER, false); + } + if (accelerometer != null && handlerThread == null) { + handlerThread = new HandlerThread("sentry-shake"); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + } + } + + public synchronized void start( + final @NotNull Context context, final @NotNull Listener shakeListener) { + if (closed) { + return; + } + this.listener = shakeListener; + init(context); + if (sensorManager == null) { + logger.log(SentryLevel.WARNING, "SensorManager is not available. Shake detection disabled."); + return; + } + if (accelerometer == null) { + logger.log( + SentryLevel.WARNING, "Accelerometer sensor not available. Shake detection disabled."); + return; + } + sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL, handler); + } + + public synchronized void stop() { + listener = null; + if (sensorManager != null) { + sensorManager.unregisterListener(this); + } + final @Nullable Handler h = handler; + if (h != null) { + h.post( + () -> { + //noinspection Convert2MethodRef + queue.clear(); + }); + } + } + + /** Stops detection and releases the background thread. */ + public synchronized void close() { + closed = true; + stop(); + if (handlerThread != null) { + // quitSafely drains pending messages (including the clear posted by stop) before exiting + handlerThread.quitSafely(); + handlerThread = null; + handler = null; + } + } + + @Override + public void onSensorChanged(final @NotNull SensorEvent event) { + if (event.sensor.getType() != Sensor.TYPE_ACCELEROMETER) { + return; + } + final float ax = event.values[0]; + final float ay = event.values[1]; + final float az = event.values[2]; + final boolean accelerating = Math.sqrt(ax * ax + ay * ay + az * az) > ACCELERATION_THRESHOLD; + + queue.add(event.timestamp, accelerating); + if (queue.isShaking()) { + queue.clear(); + final @Nullable Listener currentListener = listener; + if (currentListener != null) { + currentListener.onShake(); + } + } + } + + @Override + public void onAccuracyChanged(final @NotNull Sensor sensor, final int accuracy) { + // Not needed for shake detection. + } + + static class SampleQueue { + private static final long MAX_WINDOW_SIZE_NS = 500_000_000L; // 0.5s + private static final long MIN_WINDOW_SIZE_NS = MAX_WINDOW_SIZE_NS >> 1; // 0.25s + private static final int MIN_QUEUE_SIZE = 4; + + private final @NotNull SamplePool pool = new SamplePool(); + private @Nullable Sample oldest; + private @Nullable Sample newest; + private int sampleCount; + private int acceleratingCount; + + void add(final long timestamp, final boolean accelerating) { + purge(timestamp - MAX_WINDOW_SIZE_NS); + + final @NotNull Sample added = pool.acquire(); + added.timestamp = timestamp; + added.accelerating = accelerating; + added.next = null; + if (newest != null) { + newest.next = added; + } + newest = added; + if (oldest == null) { + oldest = added; + } + + sampleCount++; + if (accelerating) { + acceleratingCount++; + } + } + + void clear() { + while (oldest != null) { + final @NotNull Sample removed = oldest; + oldest = removed.next; + pool.release(removed); + } + newest = null; + sampleCount = 0; + acceleratingCount = 0; + } + + private void purge(final long cutoff) { + while (sampleCount >= MIN_QUEUE_SIZE && oldest != null && cutoff - oldest.timestamp > 0) { + final @NotNull Sample removed = oldest; + if (removed.accelerating) { + acceleratingCount--; + } + sampleCount--; + oldest = removed.next; + if (oldest == null) { + newest = null; + } + pool.release(removed); + } + } + + boolean isShaking() { + return newest != null + && oldest != null + && sampleCount >= MIN_QUEUE_SIZE + && newest.timestamp - oldest.timestamp >= MIN_WINDOW_SIZE_NS + && acceleratingCount >= (sampleCount >> 1) + (sampleCount >> 2); + } + } + + static class Sample { + long timestamp; + boolean accelerating; + @Nullable Sample next; + } + + static class SamplePool { + private @Nullable Sample head; + + @NotNull + Sample acquire() { + Sample acquired = head; + if (acquired == null) { + acquired = new Sample(); + } else { + head = acquired.next; + } + return acquired; + } + + void release(final @NotNull Sample sample) { + sample.next = head; + head = sample; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java index eedafd8f001..f842f18674b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java @@ -10,25 +10,33 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +/** + * @deprecated `SentryUserFeedbackButton` will be removed in the next major version + */ +@Deprecated public class SentryUserFeedbackButton extends Button { private @Nullable OnClickListener delegate; + @Deprecated public SentryUserFeedbackButton(Context context) { super(context); init(context, null, 0, 0); } + @Deprecated public SentryUserFeedbackButton(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs, 0, 0); } + @Deprecated public SentryUserFeedbackButton(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs, defStyleAttr, 0); } + @Deprecated public SentryUserFeedbackButton( Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); @@ -104,7 +112,7 @@ private void init( } } - // Set the default ClickListener to open the SentryUserFeedbackDialog + // Set the default ClickListener to open the SentryUserFeedbackForm setOnClickListener(delegate); } @@ -113,7 +121,7 @@ public void setOnClickListener(final @Nullable OnClickListener listener) { delegate = listener; super.setOnClickListener( v -> { - new SentryUserFeedbackDialog.Builder(getContext()).create().show(); + new SentryUserFeedbackForm.Builder(getContext()).create().show(); if (delegate != null) { delegate.onClick(v); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java index 542a7027a4f..155464b7b73 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java @@ -1,380 +1,114 @@ package io.sentry.android.core; -import android.app.AlertDialog; import android.content.Context; -import android.os.Bundle; -import android.view.View; -import android.widget.Button; -import android.widget.EditText; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; -import io.sentry.IScopes; -import io.sentry.Sentry; import io.sentry.SentryFeedbackOptions; -import io.sentry.SentryIntegrationPackageStorage; -import io.sentry.SentryLevel; -import io.sentry.SentryOptions; -import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; -import io.sentry.protocol.User; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -public final class SentryUserFeedbackDialog extends AlertDialog { - - private boolean isCancelable = false; - private @Nullable SentryId currentReplayId; - private final @Nullable SentryId associatedEventId; - private @Nullable OnDismissListener delegate; - - private final @Nullable OptionsConfiguration configuration; - private final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator; +/** + * @deprecated Use {@link SentryUserFeedbackForm} instead. + */ +@Deprecated +public final class SentryUserFeedbackDialog extends SentryUserFeedbackForm { SentryUserFeedbackDialog( final @NotNull Context context, final int themeResId, final @Nullable SentryId associatedEventId, - final @Nullable OptionsConfiguration configuration, + final @Nullable SentryUserFeedbackForm.OptionsConfiguration configuration, final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { - super(context, themeResId); - this.associatedEventId = associatedEventId; - this.configuration = configuration; - this.configurator = configurator; - SentryIntegrationPackageStorage.getInstance().addIntegration("UserFeedbackWidget"); - } - - @Override - public void setCancelable(boolean cancelable) { - super.setCancelable(cancelable); - isCancelable = cancelable; - } - - @Override - @SuppressWarnings("deprecation") - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.sentry_dialog_user_feedback); - setCancelable(isCancelable); - - final @NotNull SentryFeedbackOptions feedbackOptions = - new SentryFeedbackOptions(Sentry.getCurrentScopes().getOptions().getFeedbackOptions()); - if (configuration != null) { - configuration.configure(getContext(), feedbackOptions); - } - if (configurator != null) { - configurator.configure(feedbackOptions); - } - final @NotNull TextView lblTitle = findViewById(R.id.sentry_dialog_user_feedback_title); - final @NotNull ImageView imgLogo = findViewById(R.id.sentry_dialog_user_feedback_logo); - final @NotNull TextView lblName = findViewById(R.id.sentry_dialog_user_feedback_txt_name); - final @NotNull EditText edtName = findViewById(R.id.sentry_dialog_user_feedback_edt_name); - final @NotNull TextView lblEmail = findViewById(R.id.sentry_dialog_user_feedback_txt_email); - final @NotNull EditText edtEmail = findViewById(R.id.sentry_dialog_user_feedback_edt_email); - final @NotNull TextView lblMessage = - findViewById(R.id.sentry_dialog_user_feedback_txt_description); - final @NotNull EditText edtMessage = - findViewById(R.id.sentry_dialog_user_feedback_edt_description); - final @NotNull Button btnSend = findViewById(R.id.sentry_dialog_user_feedback_btn_send); - final @NotNull Button btnCancel = findViewById(R.id.sentry_dialog_user_feedback_btn_cancel); - - if (feedbackOptions.isShowBranding()) { - imgLogo.setVisibility(View.VISIBLE); - } else { - imgLogo.setVisibility(View.GONE); - } - - // If name is required, ignore showName flag - if (!feedbackOptions.isShowName() && !feedbackOptions.isNameRequired()) { - lblName.setVisibility(View.GONE); - edtName.setVisibility(View.GONE); - } else { - lblName.setVisibility(View.VISIBLE); - edtName.setVisibility(View.VISIBLE); - lblName.setText(feedbackOptions.getNameLabel()); - edtName.setHint(feedbackOptions.getNamePlaceholder()); - if (feedbackOptions.isNameRequired()) { - lblName.append(feedbackOptions.getIsRequiredLabel()); - } - } - - // If email is required, ignore showEmail flag - if (!feedbackOptions.isShowEmail() && !feedbackOptions.isEmailRequired()) { - lblEmail.setVisibility(View.GONE); - edtEmail.setVisibility(View.GONE); - } else { - lblEmail.setVisibility(View.VISIBLE); - edtEmail.setVisibility(View.VISIBLE); - lblEmail.setText(feedbackOptions.getEmailLabel()); - edtEmail.setHint(feedbackOptions.getEmailPlaceholder()); - if (feedbackOptions.isEmailRequired()) { - lblEmail.append(feedbackOptions.getIsRequiredLabel()); - } - } - - // If Sentry user is set, and useSentryUser is true, populate the name and email - if (feedbackOptions.isUseSentryUser()) { - final @Nullable User user = Sentry.getCurrentScopes().getScope().getUser(); - if (user != null) { - edtName.setText(user.getUsername()); - edtEmail.setText(user.getEmail()); - } - } - - lblMessage.setText(feedbackOptions.getMessageLabel()); - lblMessage.append(feedbackOptions.getIsRequiredLabel()); - edtMessage.setHint(feedbackOptions.getMessagePlaceholder()); - lblTitle.setText(feedbackOptions.getFormTitle()); - - btnSend.setText(feedbackOptions.getSubmitButtonLabel()); - btnSend.setOnClickListener( - v -> { - // Gather fields and trim them - final @NotNull String name = edtName.getText().toString().trim(); - final @NotNull String email = edtEmail.getText().toString().trim(); - final @NotNull String message = edtMessage.getText().toString().trim(); - - // If a required field is missing, shows the error label - if (name.isEmpty() && feedbackOptions.isNameRequired()) { - edtName.setError(lblName.getText()); - return; - } - - if (email.isEmpty() && feedbackOptions.isEmailRequired()) { - edtEmail.setError(lblEmail.getText()); - return; - } - - if (message.isEmpty()) { - edtMessage.setError(lblMessage.getText()); - return; - } - - // Create the feedback object - final @NotNull Feedback feedback = new Feedback(message); - feedback.setName(name); - feedback.setContactEmail(email); - if (associatedEventId != null) { - feedback.setAssociatedEventId(associatedEventId); - } - if (currentReplayId != null) { - feedback.setReplayId(currentReplayId); - } - - // Capture the feedback. If the ID is empty, it means that the feedback was not sent - final @NotNull SentryId id = Sentry.captureFeedback(feedback); - if (!id.equals(SentryId.EMPTY_ID)) { - Toast.makeText( - getContext(), feedbackOptions.getSuccessMessageText(), Toast.LENGTH_SHORT) - .show(); - final @Nullable SentryFeedbackOptions.SentryFeedbackCallback onSubmitSuccess = - feedbackOptions.getOnSubmitSuccess(); - if (onSubmitSuccess != null) { - onSubmitSuccess.call(feedback); - } - } else { - final @Nullable SentryFeedbackOptions.SentryFeedbackCallback onSubmitError = - feedbackOptions.getOnSubmitError(); - if (onSubmitError != null) { - onSubmitError.call(feedback); - } - } - cancel(); - }); - - btnCancel.setText(feedbackOptions.getCancelButtonLabel()); - btnCancel.setOnClickListener(v -> cancel()); - setOnDismissListener(delegate); + super(context, themeResId, associatedEventId, configuration, configurator); } - @Override - public void setOnDismissListener(final @Nullable OnDismissListener listener) { - delegate = listener; - // If the user set a custom onDismissListener, we ensure it doesn't override the onFormClose - final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); - final @Nullable Runnable onFormClose = options.getFeedbackOptions().getOnFormClose(); - if (onFormClose != null) { - super.setOnDismissListener( - dialog -> { - onFormClose.run(); - currentReplayId = null; - if (delegate != null) { - delegate.onDismiss(dialog); - } - }); - } else { - super.setOnDismissListener(delegate); - } - } - - @Override - protected void onStart() { - super.onStart(); - final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); - final @NotNull SentryFeedbackOptions feedbackOptions = options.getFeedbackOptions(); - final @Nullable Runnable onFormOpen = feedbackOptions.getOnFormOpen(); - if (onFormOpen != null) { - onFormOpen.run(); - } - options.getReplayController().captureReplay(false); - currentReplayId = options.getReplayController().getReplayId(); - } - - @Override - public void show() { - // If Sentry is disabled, don't show the dialog, but log a warning - final @NotNull IScopes scopes = Sentry.getCurrentScopes(); - final @NotNull SentryOptions options = scopes.getOptions(); - if (!scopes.isEnabled() || !options.isEnabled()) { - options - .getLogger() - .log(SentryLevel.WARNING, "Sentry is disabled. Feedback dialog won't be shown."); - return; - } - // Otherwise, show the dialog - super.show(); - } - - public static class Builder { - - @Nullable OptionsConfiguration configuration; - @Nullable SentryFeedbackOptions.OptionsConfigurator configurator; - @Nullable SentryId associatedEventId; - final @NotNull Context context; - final int themeResId; + /** + * @deprecated Use {@link SentryUserFeedbackForm.Builder} instead. + */ + @Deprecated + public static class Builder extends SentryUserFeedbackForm.Builder { /** * Creates a builder for a {@link SentryUserFeedbackDialog} that uses the default alert dialog * theme. * - *

    The default alert dialog theme is defined by {@link android.R.attr#alertDialogTheme} - * within the parent {@code context}'s theme. - * * @param context the parent context + * @deprecated Use {@link SentryUserFeedbackForm.Builder#Builder(Context)} instead. */ + @Deprecated public Builder(final @NotNull Context context) { - this(context, 0); + super(context); } /** * Creates a builder for a {@link SentryUserFeedbackDialog} that uses an explicit theme * resource. * - *

    The specified theme resource ({@code themeResId}) is applied on top of the parent {@code - * context}'s theme. It may be specified as a style resource containing a fully-populated theme, - * such as {@link android.R.style#Theme_Material_Dialog}, to replace all attributes in the - * parent {@code context}'s theme including primary and accent colors. - * - *

    To preserve attributes such as primary and accent colors, the {@code themeResId} may - * instead be specified as an overlay theme such as {@link - * android.R.style#ThemeOverlay_Material_Dialog}. This will override only the window attributes - * necessary to style the alert window as a dialog. - * - *

    Alternatively, the {@code themeResId} may be specified as {@code 0} to use the parent - * {@code context}'s resolved value for {@link android.R.attr#alertDialogTheme}. - * * @param context the parent context - * @param themeResId the resource ID of the theme against which to inflate this dialog, or - * {@code 0} to use the parent {@code context}'s default alert dialog theme + * @param themeResId the resource ID of the theme + * @deprecated Use {@link SentryUserFeedbackForm.Builder#Builder(Context, int)} instead. */ + @Deprecated public Builder(Context context, int themeResId) { - this(context, themeResId, null); + super(context, themeResId); } /** - * Creates a builder for a {@link SentryUserFeedbackDialog} that uses the default alert dialog - * theme. The {@code configuration} can be used to configure the feedback options for this - * specific dialog. - * - *

    The default alert dialog theme is defined by {@link android.R.attr#alertDialogTheme} - * within the parent {@code context}'s theme. + * Creates a builder for a {@link SentryUserFeedbackDialog} with a configuration. * * @param context the parent context - * @param configuration the configuration for the feedback options, can be {@code null} to use - * the global feedback options. + * @param configuration the configuration for the feedback options + * @deprecated Use {@link SentryUserFeedbackForm.Builder#Builder(Context, + * SentryUserFeedbackForm.OptionsConfiguration)} instead. */ + @Deprecated public Builder( final @NotNull Context context, final @Nullable OptionsConfiguration configuration) { - this(context, 0, configuration); + super(context, configuration); } /** - * Creates a builder for a {@link SentryUserFeedbackDialog} that uses an explicit theme - * resource. The {@code configuration} can be used to configure the feedback options for this - * specific dialog. - * - *

    The specified theme resource ({@code themeResId}) is applied on top of the parent {@code - * context}'s theme. It may be specified as a style resource containing a fully-populated theme, - * such as {@link android.R.style#Theme_Material_Dialog}, to replace all attributes in the - * parent {@code context}'s theme including primary and accent colors. - * - *

    To preserve attributes such as primary and accent colors, the {@code themeResId} may - * instead be specified as an overlay theme such as {@link - * android.R.style#ThemeOverlay_Material_Dialog}. This will override only the window attributes - * necessary to style the alert window as a dialog. - * - *

    Alternatively, the {@code themeResId} may be specified as {@code 0} to use the parent - * {@code context}'s resolved value for {@link android.R.attr#alertDialogTheme}. + * Creates a builder for a {@link SentryUserFeedbackDialog} with a theme and configuration. * * @param context the parent context - * @param themeResId the resource ID of the theme against which to inflate this dialog, or - * {@code 0} to use the parent {@code context}'s default alert dialog theme - * @param configuration the configuration for the feedback options, can be {@code null} to use - * the global feedback options. + * @param themeResId the resource ID of the theme + * @param configuration the configuration for the feedback options + * @deprecated Use {@link SentryUserFeedbackForm.Builder#Builder(Context, int, + * SentryUserFeedbackForm.OptionsConfiguration)} instead. */ + @Deprecated public Builder( final @NotNull Context context, final int themeResId, final @Nullable OptionsConfiguration configuration) { - this.context = context; - this.themeResId = themeResId; - this.configuration = configuration; + super(context, themeResId, configuration); } - /** - * Sets the configuration for the feedback options. - * - * @param configurator the configuration for the feedback options, can be {@code null} to use - * the global feedback options. - */ + @Deprecated + @Override public Builder configurator( final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { - this.configurator = configurator; + super.configurator(configurator); return this; } - /** - * Sets the associated event ID for the feedback. - * - * @param associatedEventId the associated event ID for the feedback, can be {@code null} to - * avoid associating the feedback to an event. - */ + @Deprecated + @Override public Builder associatedEventId(final @Nullable SentryId associatedEventId) { - this.associatedEventId = associatedEventId; + super.associatedEventId(associatedEventId); return this; } - /** - * Builds a new {@link SentryUserFeedbackDialog} with the specified context, theme, and - * configuration. - * - * @return a new instance of {@link SentryUserFeedbackDialog} - */ + @Deprecated + @Override public SentryUserFeedbackDialog create() { return new SentryUserFeedbackDialog( context, themeResId, associatedEventId, configuration, configurator); } } - /** Configuration callback for feedback options. */ - public interface OptionsConfiguration { - - /** - * configure the feedback options - * - * @param context the context of the feedback dialog - * @param options the feedback options - */ - void configure(final @NotNull Context context, final @NotNull SentryFeedbackOptions options); - } + /** + * @deprecated Use {@link SentryUserFeedbackForm.OptionsConfiguration} instead. + */ + @Deprecated + public interface OptionsConfiguration extends SentryUserFeedbackForm.OptionsConfiguration {} } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java new file mode 100644 index 00000000000..01d4546d877 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java @@ -0,0 +1,577 @@ +package io.sentry.android.core; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Application; +import android.content.Context; +import android.content.ContextWrapper; +import android.os.Bundle; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; +import io.sentry.IScopes; +import io.sentry.Sentry; +import io.sentry.SentryFeedbackOptions; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.protocol.Feedback; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.User; +import java.lang.ref.WeakReference; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class SentryUserFeedbackForm extends AlertDialog { + + private boolean isCancelable = false; + private @Nullable SentryId currentReplayId; + private final @Nullable SentryId associatedEventId; + private @Nullable OnDismissListener delegate; + + private final @NotNull SentryFeedbackOptions resolvedFeedbackOptions; + + private @Nullable SentryShakeDetector shakeDetector; + private @Nullable Application.ActivityLifecycleCallbacks shakeLifecycleCallbacks; + + SentryUserFeedbackForm( + final @NotNull Context context, + final int themeResId, + final @Nullable SentryId associatedEventId, + final @Nullable OptionsConfiguration configuration, + final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { + super(context, themeResId); + this.associatedEventId = associatedEventId; + this.resolvedFeedbackOptions = + new SentryFeedbackOptions(Sentry.getCurrentScopes().getOptions().getFeedbackOptions()); + if (configuration != null) { + configuration.configure(context, resolvedFeedbackOptions); + } + if (configurator != null) { + configurator.configure(resolvedFeedbackOptions); + } + SentryIntegrationPackageStorage.getInstance().addIntegration("UserFeedbackWidget"); + maybeStartShakeDetection(context); + } + + private void maybeStartShakeDetection(final @NotNull Context context) { + // Only start shake detection if it's enabled within the options, + // and not already running globally + final @NotNull SentryFeedbackOptions globalFeedbackOptions = + Sentry.getCurrentScopes().getOptions().getFeedbackOptions(); + if (!resolvedFeedbackOptions.isUseShakeGesture() + || globalFeedbackOptions.getShakeController().isOnShakeEnabled()) { + return; + } + final @Nullable Activity activity = getActivity(context); + if (activity == null) { + return; + } + final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); + shakeDetector = new SentryShakeDetector(options.getLogger()); + final @NotNull WeakReference activityRef = new WeakReference<>(activity); + shakeDetector.start(activity, shakeListener(activityRef)); + final @NotNull Application app = activity.getApplication(); + shakeLifecycleCallbacks = new ShakeLifecycleCallbacks(activityRef); + app.registerActivityLifecycleCallbacks(shakeLifecycleCallbacks); + } + + private void stopShakeDetection() { + if (shakeDetector != null) { + shakeDetector.close(); + shakeDetector = null; + } + if (shakeLifecycleCallbacks != null) { + final @Nullable Activity activity = getActivity(getContext()); + if (activity != null) { + activity.getApplication().unregisterActivityLifecycleCallbacks(shakeLifecycleCallbacks); + } + shakeLifecycleCallbacks = null; + } + } + + private @NotNull SentryShakeDetector.Listener shakeListener( + final @NotNull WeakReference activityRef) { + return () -> { + // If shake-to-report got enabled globally in the meantime, FeedbackShakeIntegration + // reacts to the same shake — don't show a second dialog for it. + if (Sentry.getCurrentScopes() + .getOptions() + .getFeedbackOptions() + .getShakeController() + .isOnShakeEnabled()) { + return; + } + final @Nullable Activity active = activityRef.get(); + if (active != null && !active.isFinishing() && !active.isDestroyed()) { + active.runOnUiThread( + () -> { + if (!active.isFinishing() && !active.isDestroyed()) { + show(); + } + }); + } + }; + } + + private static @Nullable Activity getActivity(final @NotNull Context context) { + Context current = context; + while (current instanceof ContextWrapper) { + if (current instanceof Activity) { + return (Activity) current; + } + current = ((ContextWrapper) current).getBaseContext(); + } + return null; + } + + private class ShakeLifecycleCallbacks implements Application.ActivityLifecycleCallbacks { + private final @NotNull WeakReference activityRef; + + ShakeLifecycleCallbacks(final @NotNull WeakReference activityRef) { + this.activityRef = activityRef; + } + + @Override + public void onActivityResumed(final @NotNull Activity activity) { + if (activity == activityRef.get() && shakeDetector != null) { + shakeDetector.start(activity, shakeListener(activityRef)); + } + } + + @Override + public void onActivityPaused(final @NotNull Activity activity) { + if (activity == activityRef.get() && shakeDetector != null) { + shakeDetector.stop(); + } + } + + @Override + public void onActivityDestroyed(final @NotNull Activity activity) { + if (activity == activityRef.get()) { + stopShakeDetection(); + } + } + + @Override + public void onActivityCreated( + final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {} + + @Override + public void onActivityStarted(final @NotNull Activity activity) {} + + @Override + public void onActivityStopped(final @NotNull Activity activity) {} + + @Override + public void onActivitySaveInstanceState( + final @NotNull Activity activity, final @NotNull Bundle outState) {} + } + + @Override + public void setCancelable(boolean cancelable) { + super.setCancelable(cancelable); + isCancelable = cancelable; + } + + @Override + @SuppressWarnings("deprecation") + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.sentry_dialog_user_feedback); + final @Nullable Window window = getWindow(); + if (window != null) { + window.clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); + } + setCancelable(isCancelable); + + final @NotNull SentryFeedbackOptions feedbackOptions = resolvedFeedbackOptions; + final @NotNull TextView lblTitle = findViewById(R.id.sentry_dialog_user_feedback_title); + final @NotNull ImageView imgLogo = findViewById(R.id.sentry_dialog_user_feedback_logo); + final @NotNull TextView lblName = findViewById(R.id.sentry_dialog_user_feedback_txt_name); + final @NotNull EditText edtName = findViewById(R.id.sentry_dialog_user_feedback_edt_name); + final @NotNull TextView lblEmail = findViewById(R.id.sentry_dialog_user_feedback_txt_email); + final @NotNull EditText edtEmail = findViewById(R.id.sentry_dialog_user_feedback_edt_email); + final @NotNull TextView lblMessage = + findViewById(R.id.sentry_dialog_user_feedback_txt_description); + final @NotNull EditText edtMessage = + findViewById(R.id.sentry_dialog_user_feedback_edt_description); + final @NotNull Button btnSend = findViewById(R.id.sentry_dialog_user_feedback_btn_send); + final @NotNull Button btnCancel = findViewById(R.id.sentry_dialog_user_feedback_btn_cancel); + + if (feedbackOptions.isShowBranding()) { + imgLogo.setVisibility(View.VISIBLE); + } else { + imgLogo.setVisibility(View.GONE); + } + + // If name is required, ignore showName flag + if (!feedbackOptions.isShowName() && !feedbackOptions.isNameRequired()) { + lblName.setVisibility(View.GONE); + edtName.setVisibility(View.GONE); + } else { + lblName.setVisibility(View.VISIBLE); + edtName.setVisibility(View.VISIBLE); + lblName.setText(feedbackOptions.getNameLabel()); + edtName.setHint(feedbackOptions.getNamePlaceholder()); + if (feedbackOptions.isNameRequired()) { + lblName.append(feedbackOptions.getIsRequiredLabel()); + } + } + + // If email is required, ignore showEmail flag + if (!feedbackOptions.isShowEmail() && !feedbackOptions.isEmailRequired()) { + lblEmail.setVisibility(View.GONE); + edtEmail.setVisibility(View.GONE); + } else { + lblEmail.setVisibility(View.VISIBLE); + edtEmail.setVisibility(View.VISIBLE); + lblEmail.setText(feedbackOptions.getEmailLabel()); + edtEmail.setHint(feedbackOptions.getEmailPlaceholder()); + if (feedbackOptions.isEmailRequired()) { + lblEmail.append(feedbackOptions.getIsRequiredLabel()); + } + } + + // If Sentry user is set, and useSentryUser is true, populate the name and email + if (feedbackOptions.isUseSentryUser()) { + final @Nullable User user = Sentry.getCurrentScopes().getScope().getUser(); + if (user != null) { + edtName.setText(user.getUsername()); + edtEmail.setText(user.getEmail()); + } + } + + lblMessage.setText(feedbackOptions.getMessageLabel()); + lblMessage.append(feedbackOptions.getIsRequiredLabel()); + edtMessage.setHint(feedbackOptions.getMessagePlaceholder()); + lblTitle.setText(feedbackOptions.getFormTitle()); + + btnSend.setText(feedbackOptions.getSubmitButtonLabel()); + btnSend.setOnClickListener( + v -> { + // Gather fields and trim them + final @NotNull String name = edtName.getText().toString().trim(); + final @NotNull String email = edtEmail.getText().toString().trim(); + final @NotNull String message = edtMessage.getText().toString().trim(); + + // If a required field is missing, shows the error label + if (name.isEmpty() && feedbackOptions.isNameRequired()) { + edtName.setError(lblName.getText()); + return; + } + + if (email.isEmpty() && feedbackOptions.isEmailRequired()) { + edtEmail.setError(lblEmail.getText()); + return; + } + + if (message.isEmpty()) { + edtMessage.setError(lblMessage.getText()); + return; + } + + // Create the feedback object + final @NotNull Feedback feedback = new Feedback(message); + feedback.setName(name); + feedback.setContactEmail(email); + if (associatedEventId != null) { + feedback.setAssociatedEventId(associatedEventId); + } + if (currentReplayId != null) { + feedback.setReplayId(currentReplayId); + } + + // Capture the feedback. If the ID is empty, it means that the feedback was not sent + final @NotNull SentryId id = Sentry.feedback().capture(feedback); + if (!id.equals(SentryId.EMPTY_ID)) { + Toast.makeText( + getContext(), feedbackOptions.getSuccessMessageText(), Toast.LENGTH_SHORT) + .show(); + final @Nullable SentryFeedbackOptions.SentryFeedbackCallback onSubmitSuccess = + feedbackOptions.getOnSubmitSuccess(); + if (onSubmitSuccess != null) { + try { + onSubmitSuccess.call(feedback); + } catch (Exception e) { + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "onSubmitSuccess callback threw an exception.", e); + } + } + } else { + final @Nullable SentryFeedbackOptions.SentryFeedbackCallback onSubmitError = + feedbackOptions.getOnSubmitError(); + if (onSubmitError != null) { + try { + onSubmitError.call(feedback); + } catch (Exception e) { + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "onSubmitError callback threw an exception.", e); + } + } + } + cancel(); + }); + + btnCancel.setText(feedbackOptions.getCancelButtonLabel()); + btnCancel.setOnClickListener(v -> cancel()); + setOnDismissListener(delegate); + } + + @Override + public void setOnDismissListener(final @Nullable OnDismissListener listener) { + delegate = listener; + // If the user set a custom onDismissListener, we ensure it doesn't override the onFormClose + final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); + final @Nullable Runnable onFormClose = options.getFeedbackOptions().getOnFormClose(); + if (onFormClose != null) { + super.setOnDismissListener( + dialog -> { + // User-provided callback: a crash in it must not take down the app or skip the + // cleanup and the user's own dismiss listener below + try { + onFormClose.run(); + } catch (Exception e) { + options + .getLogger() + .log(SentryLevel.ERROR, "onFormClose callback threw an exception.", e); + } + currentReplayId = null; + if (delegate != null) { + delegate.onDismiss(dialog); + } + }); + } else { + super.setOnDismissListener(delegate); + } + } + + @Override + protected void onStart() { + super.onStart(); + // Clear the message field so subsequent show() calls start with a fresh dialog + final @NotNull EditText edtMessage = + findViewById(R.id.sentry_dialog_user_feedback_edt_description); + edtMessage.getText().clear(); + edtMessage.setError(null); + + final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); + final @NotNull SentryFeedbackOptions feedbackOptions = options.getFeedbackOptions(); + // Pause shake-to-report on this dialog's activity while it is visible, so a shake can't stack + // a second dialog on top of it + final @Nullable FeedbackShakeIntegration integration = getFeedbackShakeIntegration(); + final @Nullable Activity activity = getActivity(getContext()); + if (integration != null && activity != null) { + integration.onDialogVisible(activity, this); + } + final @Nullable Runnable onFormOpen = feedbackOptions.getOnFormOpen(); + if (onFormOpen != null) { + try { + onFormOpen.run(); + } catch (Exception e) { + options.getLogger().log(SentryLevel.ERROR, "onFormOpen callback threw an exception.", e); + } + } + options.getReplayController().captureReplay(false); + currentReplayId = options.getReplayController().getReplayId(); + } + + @Override + protected void onStop() { + super.onStop(); + final @Nullable FeedbackShakeIntegration integration = getFeedbackShakeIntegration(); + if (integration != null) { + integration.onDialogGone(this); + } + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + // Runs on every teardown: on dismiss the decor view is removed before onStop(), and when the + // host activity is destroyed with the dialog still showing this is the only callback that + // fires. onDialogGone is idempotent, so reporting from both here and onStop() is safe. + final @Nullable FeedbackShakeIntegration integration = getFeedbackShakeIntegration(); + if (integration != null) { + integration.onDialogGone(this); + } + } + + /** + * The shake integration to report this dialog's visibility to, or null when shake-to-report isn't + * available (non-Android controller, or the integration was never installed). + */ + private @Nullable FeedbackShakeIntegration getFeedbackShakeIntegration() { + final @NotNull SentryFeedbackOptions.IShakeController controller = + Sentry.getCurrentScopes().getOptions().getFeedbackOptions().getShakeController(); + return controller instanceof FeedbackShakeIntegration + ? (FeedbackShakeIntegration) controller + : null; + } + + @Override + public void show() { + // If Sentry is disabled, don't show the dialog, but log a warning + final @NotNull IScopes scopes = Sentry.getCurrentScopes(); + final @NotNull SentryOptions options = scopes.getOptions(); + if (!scopes.isEnabled() || !options.isEnabled()) { + options + .getLogger() + .log(SentryLevel.WARNING, "Sentry is disabled. Feedback dialog won't be shown."); + return; + } + // Otherwise, show the dialog + super.show(); + } + + public static class Builder { + + @Nullable OptionsConfiguration configuration; + @Nullable SentryFeedbackOptions.OptionsConfigurator configurator; + @Nullable SentryId associatedEventId; + final @NotNull Context context; + final int themeResId; + + /** + * Creates a builder for a {@link SentryUserFeedbackForm} that uses the default alert dialog + * theme. + * + *

    The default alert dialog theme is defined by {@link android.R.attr#alertDialogTheme} + * within the parent {@code context}'s theme. + * + * @param context the parent context + */ + public Builder(final @NotNull Context context) { + this(context, 0); + } + + /** + * Creates a builder for a {@link SentryUserFeedbackForm} that uses an explicit theme resource. + * + *

    The specified theme resource ({@code themeResId}) is applied on top of the parent {@code + * context}'s theme. It may be specified as a style resource containing a fully-populated theme, + * such as {@link android.R.style#Theme_Material_Dialog}, to replace all attributes in the + * parent {@code context}'s theme including primary and accent colors. + * + *

    To preserve attributes such as primary and accent colors, the {@code themeResId} may + * instead be specified as an overlay theme such as {@link + * android.R.style#ThemeOverlay_Material_Dialog}. This will override only the window attributes + * necessary to style the alert window as a dialog. + * + *

    Alternatively, the {@code themeResId} may be specified as {@code 0} to use the parent + * {@code context}'s resolved value for {@link android.R.attr#alertDialogTheme}. + * + * @param context the parent context + * @param themeResId the resource ID of the theme against which to inflate this dialog, or + * {@code 0} to use the parent {@code context}'s default alert dialog theme + */ + public Builder(Context context, int themeResId) { + this(context, themeResId, null); + } + + /** + * Creates a builder for a {@link SentryUserFeedbackForm} that uses the default alert dialog + * theme. The {@code configuration} can be used to configure the feedback options for this + * specific dialog. + * + *

    The default alert dialog theme is defined by {@link android.R.attr#alertDialogTheme} + * within the parent {@code context}'s theme. + * + * @param context the parent context + * @param configuration the configuration for the feedback options, can be {@code null} to use + * the global feedback options. + */ + public Builder( + final @NotNull Context context, final @Nullable OptionsConfiguration configuration) { + this(context, 0, configuration); + } + + /** + * Creates a builder for a {@link SentryUserFeedbackForm} that uses an explicit theme resource. + * The {@code configuration} can be used to configure the feedback options for this specific + * dialog. + * + *

    The specified theme resource ({@code themeResId}) is applied on top of the parent {@code + * context}'s theme. It may be specified as a style resource containing a fully-populated theme, + * such as {@link android.R.style#Theme_Material_Dialog}, to replace all attributes in the + * parent {@code context}'s theme including primary and accent colors. + * + *

    To preserve attributes such as primary and accent colors, the {@code themeResId} may + * instead be specified as an overlay theme such as {@link + * android.R.style#ThemeOverlay_Material_Dialog}. This will override only the window attributes + * necessary to style the alert window as a dialog. + * + *

    Alternatively, the {@code themeResId} may be specified as {@code 0} to use the parent + * {@code context}'s resolved value for {@link android.R.attr#alertDialogTheme}. + * + * @param context the parent context + * @param themeResId the resource ID of the theme against which to inflate this dialog, or + * {@code 0} to use the parent {@code context}'s default alert dialog theme + * @param configuration the configuration for the feedback options, can be {@code null} to use + * the global feedback options. + */ + public Builder( + final @NotNull Context context, + final int themeResId, + final @Nullable OptionsConfiguration configuration) { + this.context = context; + this.themeResId = themeResId; + this.configuration = configuration; + } + + /** + * Sets the configuration for the feedback options. + * + * @param configurator the configuration for the feedback options, can be {@code null} to use + * the global feedback options. + */ + public Builder configurator( + final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { + this.configurator = configurator; + return this; + } + + /** + * Sets the associated event ID for the feedback. + * + * @param associatedEventId the associated event ID for the feedback, can be {@code null} to + * avoid associating the feedback to an event. + */ + public Builder associatedEventId(final @Nullable SentryId associatedEventId) { + this.associatedEventId = associatedEventId; + return this; + } + + /** + * Builds a new {@link SentryUserFeedbackForm} with the specified context, theme, and + * configuration. + * + * @return a new instance of {@link SentryUserFeedbackForm} + */ + public SentryUserFeedbackForm create() { + return new SentryUserFeedbackForm( + context, themeResId, associatedEventId, configuration, configurator); + } + } + + /** Configuration callback for feedback options. */ + public interface OptionsConfiguration { + + /** + * configure the feedback options + * + * @param context the context of the feedback dialog + * @param options the feedback options + */ + void configure(final @NotNull Context context, final @NotNull SentryFeedbackOptions options); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java index a83454d29b7..074a4a6ea51 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java @@ -13,7 +13,6 @@ import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.protocol.MeasurementValue; import io.sentry.util.AutoClosableReentrantLock; -import java.util.Date; import java.util.Iterator; import java.util.SortedSet; import java.util.TreeSet; @@ -33,7 +32,7 @@ public class SpanFrameMetricsCollector // grow indefinitely in case of a long running span private static final int MAX_FRAMES_COUNT = 3600; private static final long ONE_SECOND_NANOS = TimeUnit.SECONDS.toNanos(1); - private static final SentryNanotimeDate EMPTY_NANO_TIME = new SentryNanotimeDate(new Date(0), 0); + private static final SentryNanotimeDate EMPTY_NANO_TIME = new SentryNanotimeDate(0, 0); private final boolean enabled; protected final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index 85d4803bf7b..18ff901b0e9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -72,15 +72,24 @@ public final class SystemEventsBreadcrumbsIntegration private final @NotNull AutoClosableReentrantLock receiverLock = new AutoClosableReentrantLock(); // Track previous battery state to avoid duplicate breadcrumbs when values haven't changed private @Nullable BatteryState previousBatteryState; + @TestOnly @Nullable Handler customHandler = null; public SystemEventsBreadcrumbsIntegration(final @NotNull Context context) { - this(context, getDefaultActionsInternal()); + this(context, getDefaultActionsInternal(), null); + } + + public SystemEventsBreadcrumbsIntegration( + final @NotNull Context context, final @NotNull Handler handler) { + this(context, getDefaultActionsInternal(), handler); } SystemEventsBreadcrumbsIntegration( - final @NotNull Context context, final @NotNull String[] actions) { + final @NotNull Context context, + final @NotNull String[] actions, + final @Nullable Handler handler) { this.context = ContextUtils.getApplicationContext(context); this.actions = actions; + this.customHandler = handler; } public SystemEventsBreadcrumbsIntegration( @@ -143,7 +152,7 @@ private void registerReceiver( filter.addAction(item); } } - if (handlerThread == null) { + if (customHandler == null && handlerThread == null) { handlerThread = new HandlerThread( "SystemEventsReceiver", Process.THREAD_PRIORITY_BACKGROUND); @@ -154,7 +163,12 @@ private void registerReceiver( // official docs // onReceive will be called on this handler thread - final @NotNull Handler handler = new Handler(handlerThread.getLooper()); + @NotNull Handler handler; + if (customHandler != null) { + handler = customHandler; + } else { + handler = new Handler(handlerThread.getLooper()); + } ContextUtils.registerReceiver(context, options, receiver, filter, handler); if (!isReceiverRegistered.getAndSet(true)) { options @@ -189,13 +203,13 @@ private void scheduleUnregisterReceiver() { } try { - options.getExecutorService().submit(() -> unregisterReceiver()); + options.getExecutorService().submit(() -> unregisterReceiver(options)); } catch (RejectedExecutionException e) { - unregisterReceiver(); + unregisterReceiver(options); } } - private void unregisterReceiver() { + private void unregisterReceiver(final @NotNull SentryAndroidOptions options) { final @Nullable SystemEventsBroadcastReceiver receiverRef; try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) { isStopped = true; @@ -204,7 +218,14 @@ private void unregisterReceiver() { } if (receiverRef != null) { - context.unregisterReceiver(receiverRef); + try { + context.unregisterReceiver(receiverRef); + } catch (Throwable exception) { + options + .getLogger() + .log( + SentryLevel.ERROR, exception, "Failed to unregister SystemEventsBroadcastReceiver"); + } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java new file mode 100644 index 00000000000..2663051f7e4 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java @@ -0,0 +1,360 @@ +package io.sentry.android.core; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import android.app.ApplicationExitInfo; +import android.content.Context; +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import io.sentry.Attachment; +import io.sentry.DateUtils; +import io.sentry.Hint; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.Integration; +import io.sentry.SentryEnvelope; +import io.sentry.SentryEnvelopeItem; +import io.sentry.SentryEvent; +import io.sentry.SentryItemType; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.android.core.ApplicationExitInfoHistoryDispatcher.ApplicationExitInfoPolicy; +import io.sentry.android.core.NativeEventCollector.NativeEventData; +import io.sentry.android.core.cache.AndroidEnvelopeCache; +import io.sentry.android.core.internal.tombstone.NativeExceptionMechanism; +import io.sentry.android.core.internal.tombstone.TombstoneParser; +import io.sentry.android.core.internal.util.NativeEventUtils; +import io.sentry.hints.Backfillable; +import io.sentry.hints.BlockingFlushHint; +import io.sentry.hints.NativeCrashExit; +import io.sentry.protocol.DebugMeta; +import io.sentry.protocol.Mechanism; +import io.sentry.protocol.SentryException; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.SentryThread; +import io.sentry.transport.CurrentDateProvider; +import io.sentry.transport.ICurrentDateProvider; +import io.sentry.util.HintUtils; +import io.sentry.util.Objects; +import java.io.ByteArrayInputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public class TombstoneIntegration implements Integration, Closeable { + private final @NotNull Context context; + private final @NotNull ICurrentDateProvider dateProvider; + private @Nullable SentryAndroidOptions options; + + public TombstoneIntegration(final @NotNull Context context) { + // using CurrentDateProvider instead of AndroidCurrentDateProvider as AppExitInfo uses + // System.currentTimeMillis + this(context, CurrentDateProvider.getInstance()); + } + + TombstoneIntegration( + final @NotNull Context context, final @NotNull ICurrentDateProvider dateProvider) { + this.context = ContextUtils.getApplicationContext(context); + this.dateProvider = dateProvider; + } + + @Override + public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { + this.options = + Objects.requireNonNull( + (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, + "SentryAndroidOptions is required"); + + this.options + .getLogger() + .log( + SentryLevel.DEBUG, + "TombstoneIntegration enabled: %s", + this.options.isTombstoneEnabled()); + + if (this.options.isTombstoneEnabled()) { + if (this.options.getCacheDirPath() == null) { + this.options + .getLogger() + .log(SentryLevel.INFO, "Cache dir is not set, unable to process Tombstones"); + return; + } + + try { + options + .getExecutorService() + .submit( + new ApplicationExitInfoHistoryDispatcher( + context, + scopes, + this.options, + dateProvider, + new TombstonePolicy(this.options, this.context))); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to start tombstone processor.", e); + } + options.getLogger().log(SentryLevel.DEBUG, "TombstoneIntegration installed."); + addIntegrationToSdkVersion("Tombstone"); + } + } + + @Override + public void close() throws IOException { + if (options != null) { + options.getLogger().log(SentryLevel.DEBUG, "TombstoneIntegration removed."); + } + } + + @ApiStatus.Internal + public static class TombstonePolicy implements ApplicationExitInfoPolicy { + + private final @NotNull SentryAndroidOptions options; + private final @NotNull NativeEventCollector nativeEventCollector; + @NotNull private final Context context; + + public TombstonePolicy(final @NotNull SentryAndroidOptions options, @NotNull Context context) { + this.options = options; + this.nativeEventCollector = new NativeEventCollector(options); + this.context = context; + } + + @Override + public @NotNull String getLabel() { + return "Tombstone"; + } + + @RequiresApi(api = Build.VERSION_CODES.R) + @Override + public int getTargetReason() { + return ApplicationExitInfo.REASON_CRASH_NATIVE; + } + + @Override + public boolean shouldReportHistorical() { + return options.isReportHistoricalTombstones(); + } + + @Override + public @Nullable Long getLastReportedTimestamp() { + return AndroidEnvelopeCache.lastReportedTombstone(options); + } + + @RequiresApi(api = Build.VERSION_CODES.R) + @Override + public @Nullable ApplicationExitInfoHistoryDispatcher.Report buildReport( + final @NotNull ApplicationExitInfo exitInfo, final boolean enrich) { + SentryEvent event; + @Nullable byte[] rawTombstone = null; + try { + final boolean attachRaw = options.isAttachRawTombstone(); + try (final InputStream tombstoneInputStream = exitInfo.getTraceInputStream()) { + if (tombstoneInputStream == null) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "No tombstone InputStream available for ApplicationExitInfo from %s", + DateTimeFormatter.ISO_INSTANT.format( + Instant.ofEpochMilli(exitInfo.getTimestamp()))); + return null; + } + + if (attachRaw) { + rawTombstone = NativeEventUtils.readBytes(tombstoneInputStream); + } + + final InputStream parserInput = + attachRaw ? new ByteArrayInputStream(rawTombstone) : tombstoneInputStream; + try (final TombstoneParser parser = + new TombstoneParser( + parserInput, + this.options.getInAppIncludes(), + this.options.getInAppExcludes(), + this.context.getApplicationInfo().nativeLibraryDir)) { + event = parser.parse(); + } + } + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Failed to parse tombstone from %s: %s", + DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(exitInfo.getTimestamp())), + e.getMessage()); + return null; + } + + final long tombstoneTimestamp = exitInfo.getTimestamp(); + event.setTimestamp(DateUtils.getDateTime(tombstoneTimestamp)); + + final TombstoneHint tombstoneHint = + new TombstoneHint( + options.getFlushTimeoutMillis(), options.getLogger(), tombstoneTimestamp, enrich); + final Hint hint = HintUtils.createWithTypeCheckHint(tombstoneHint); + + if (rawTombstone != null) { + hint.setTombstone(Attachment.fromTombstone(rawTombstone)); + } + + try { + final @Nullable SentryEvent mergedEvent = + mergeWithMatchingNativeEvents(tombstoneTimestamp, event, hint); + if (mergedEvent != null) { + event = mergedEvent; + } + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Failed to merge native event with tombstone, continuing without merge: %s", + e.getMessage()); + } + + return new ApplicationExitInfoHistoryDispatcher.Report(event, hint, tombstoneHint); + } + + /** + * Attempts to find a matching native SDK event for the tombstone and merge them. + * + * @return The merged native event (with tombstone data applied) if a match was found and + * merged, or null if no matching event was found or merge failed. + */ + private @Nullable SentryEvent mergeWithMatchingNativeEvents( + long tombstoneTimestamp, SentryEvent tombstoneEvent, Hint hint) { + // Try to find and remove matching native event from outbox + final @Nullable NativeEventData matchingNativeEvent = + nativeEventCollector.findAndRemoveMatchingNativeEvent(tombstoneTimestamp); + + if (matchingNativeEvent == null) { + options.getLogger().log(SentryLevel.DEBUG, "No matching native event found for tombstone."); + return null; + } + + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Found matching native event for tombstone, removing from outbox: %s", + matchingNativeEvent.getFile().getName()); + + // Delete from outbox so OutboxSender doesn't send it + boolean deletionSuccess = nativeEventCollector.deleteNativeEventFile(matchingNativeEvent); + + if (deletionSuccess) { + final SentryEvent nativeEvent = matchingNativeEvent.getEvent(); + mergeNativeCrashes(nativeEvent, tombstoneEvent); + addNativeAttachmentsToTombstoneHint(matchingNativeEvent, hint); + return nativeEvent; + } + return null; + } + + private void addNativeAttachmentsToTombstoneHint( + @NonNull NativeEventData matchingNativeEvent, Hint hint) { + @NotNull SentryEnvelope nativeEnvelope = matchingNativeEvent.getEnvelope(); + for (SentryEnvelopeItem item : nativeEnvelope.getItems()) { + try { + @Nullable String attachmentFileName = item.getHeader().getFileName(); + if (item.getHeader().getType() != SentryItemType.Attachment + || attachmentFileName == null) { + continue; + } + hint.addAttachment( + new Attachment( + item.getData(), + attachmentFileName, + item.getHeader().getContentType(), + item.getHeader().getAttachmentType(), + false)); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Failed to process envelope item: %s", e.getMessage()); + } + } + } + + private void mergeNativeCrashes( + final @NotNull SentryEvent nativeEvent, final @NotNull SentryEvent tombstoneEvent) { + // we take the event data verbatim from the Native SDK and only apply tombstone data where we + // are sure that it will improve the outcome: + // * context from the Native SDK will be closer to what users want than any backfilling + // * the Native SDK only tracks the crashing thread (vs. tombstone dumps all) + // * even for the crashing we expect a much better stack-trace (+ symbolication) + // * tombstone adds additional exception meta-data to signal handler content + // * we add debug-meta for consistency since the Native SDK caches memory maps early + @Nullable List tombstoneExceptions = tombstoneEvent.getExceptions(); + @Nullable DebugMeta tombstoneDebugMeta = tombstoneEvent.getDebugMeta(); + @Nullable List tombstoneThreads = tombstoneEvent.getThreads(); + if (tombstoneExceptions != null + && !tombstoneExceptions.isEmpty() + && tombstoneDebugMeta != null + && tombstoneThreads != null) { + // native crashes don't nest, we always expect one level. + SentryException exception = tombstoneExceptions.get(0); + @Nullable Mechanism mechanism = exception.getMechanism(); + if (mechanism != null) { + mechanism.setType(NativeExceptionMechanism.TOMBSTONE_MERGED.getValue()); + } + + // Don't overwrite existing messages in the native event + if (nativeEvent.getMessage() == null + || nativeEvent.getMessage().getMessage() == null + || nativeEvent.getMessage().getMessage().isEmpty()) { + nativeEvent.setMessage(tombstoneEvent.getMessage()); + } + + nativeEvent.setExceptions(tombstoneExceptions); + nativeEvent.setDebugMeta(tombstoneDebugMeta); + nativeEvent.setThreads(tombstoneThreads); + } + } + } + + @ApiStatus.Internal + public static final class TombstoneHint extends BlockingFlushHint + implements Backfillable, NativeCrashExit { + + private final long tombstoneTimestamp; + private final boolean shouldEnrich; + + public TombstoneHint( + long flushTimeoutMillis, + @NotNull ILogger logger, + long tombstoneTimestamp, + boolean shouldEnrich) { + super(flushTimeoutMillis, logger); + this.tombstoneTimestamp = tombstoneTimestamp; + this.shouldEnrich = shouldEnrich; + } + + @NotNull + @Override + public Long timestamp() { + return tombstoneTimestamp; + } + + @Override + public boolean shouldEnrich() { + return shouldEnrich; + } + + @Override + public boolean isFlushable(@Nullable SentryId eventId) { + return true; + } + + @Override + public void setFlushable(@NotNull SentryId eventId) {} + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java index 9f47fc8666c..0d77625c718 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java @@ -18,6 +18,9 @@ import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.WeakHashMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -28,14 +31,21 @@ public final class UserInteractionIntegration private @Nullable IScopes scopes; private @Nullable SentryAndroidOptions options; - private final boolean isAndroidXAvailable; private final boolean isAndroidxLifecycleAvailable; + // WeakReference value, because the callback chain strongly references the wrapper — a strong + // value would prevent the window from ever being GC'd. + // + // All access must be guarded by wrappedWindowsLock — lifecycle callbacks fire on the main + // thread, but close() may be called from a background thread (e.g. Sentry.close()). + private final @NotNull WeakHashMap> wrappedWindows = + new WeakHashMap<>(); + + private final @NotNull Object wrappedWindowsLock = new Object(); + public UserInteractionIntegration( final @NotNull Application application, final @NotNull io.sentry.util.LoadClass classLoader) { this.application = Objects.requireNonNull(application, "Application is required"); - isAndroidXAvailable = - classLoader.isClassAvailable("androidx.core.view.GestureDetectorCompat", options); isAndroidxLifecycleAvailable = classLoader.isClassAvailable("androidx.lifecycle.Lifecycle", options); } @@ -50,19 +60,26 @@ private void startTracking(final @NotNull Activity activity) { } if (scopes != null && options != null) { + synchronized (wrappedWindowsLock) { + final @Nullable WeakReference cached = wrappedWindows.get(window); + if (cached != null && cached.get() != null) { + return; + } + } + Window.Callback delegate = window.getCallback(); if (delegate == null) { delegate = new NoOpWindowCallback(); } - if (delegate instanceof SentryWindowCallback) { - // already instrumented - return; - } - final SentryGestureListener gestureListener = new SentryGestureListener(activity, scopes, options); - window.setCallback(new SentryWindowCallback(delegate, activity, gestureListener, options)); + final SentryWindowCallback wrapper = + new SentryWindowCallback(delegate, activity, gestureListener, options); + window.setCallback(wrapper); + synchronized (wrappedWindowsLock) { + wrappedWindows.put(window, new WeakReference<>(wrapper)); + } } } @@ -74,7 +91,10 @@ private void stopTracking(final @NotNull Activity activity) { } return; } + unwrapWindow(window); + } + private void unwrapWindow(final @NotNull Window window) { final Window.Callback current = window.getCallback(); if (current instanceof SentryWindowCallback) { ((SentryWindowCallback) current).stopTracking(); @@ -83,6 +103,23 @@ private void stopTracking(final @NotNull Activity activity) { } else { window.setCallback(((SentryWindowCallback) current).getDelegate()); } + synchronized (wrappedWindowsLock) { + wrappedWindows.remove(window); + } + return; + } + + // Another wrapper (e.g. Session Replay) sits on top of ours — cutting it out of the chain + // would break its instrumentation, so we leave the chain alone and just call stopTracking() + // to release our resources. The upstream wrapper holds a reference to ours, so it'll be + // GC'd whenever that upstream holder is (typically when the window is destroyed). + final @Nullable SentryWindowCallback ours; + synchronized (wrappedWindowsLock) { + final @Nullable WeakReference cached = wrappedWindows.remove(window); + ours = cached != null ? cached.get() : null; + } + if (ours != null) { + ours.stopTracking(); } } @@ -128,27 +165,19 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { .log(SentryLevel.DEBUG, "UserInteractionIntegration enabled: %s", integrationEnabled); if (integrationEnabled) { - if (isAndroidXAvailable) { - application.registerActivityLifecycleCallbacks(this); - this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed."); - addIntegrationToSdkVersion("UserInteraction"); - - // In case of a deferred init, we hook into any resumed activity - if (isAndroidxLifecycleAvailable) { - final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); - if (activity instanceof LifecycleOwner) { - if (((LifecycleOwner) activity).getLifecycle().getCurrentState() - == Lifecycle.State.RESUMED) { - startTracking(activity); - } + application.registerActivityLifecycleCallbacks(this); + this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed."); + addIntegrationToSdkVersion("UserInteraction"); + + // In case of a deferred init, we hook into any resumed activity + if (isAndroidxLifecycleAvailable) { + final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); + if (activity instanceof LifecycleOwner) { + if (((LifecycleOwner) activity).getLifecycle().getCurrentState() + == Lifecycle.State.RESUMED) { + startTracking(activity); } } - } else { - options - .getLogger() - .log( - SentryLevel.INFO, - "androidx.core is not available, UserInteractionIntegration won't be installed"); } } } @@ -157,6 +186,21 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { public void close() throws IOException { application.unregisterActivityLifecycleCallbacks(this); + // Restore original callbacks so a subsequent Sentry.init() starts from a clean chain instead + // of wrapping on top of our orphaned callback. + final ArrayList snapshot; + synchronized (wrappedWindowsLock) { + snapshot = new ArrayList<>(wrappedWindows.keySet()); + } + for (final Window window : snapshot) { + if (window != null) { + unwrapWindow(window); + } + } + synchronized (wrappedWindowsLock) { + wrappedWindows.clear(); + } + if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration removed."); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java index c32b05892f9..7090985a38b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -256,8 +256,10 @@ private static ViewHierarchyNode viewToNode(@NotNull final View view) { node.setType(className); try { - final String identifier = ViewUtils.getResourceId(view); - node.setIdentifier(identifier); + final @Nullable String identifier = ViewUtils.getResourceIdOrNull(view); + if (identifier != null) { + node.setIdentifier(identifier); + } } catch (Throwable e) { // ignored } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java new file mode 100644 index 00000000000..99eeeb7e004 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java @@ -0,0 +1,56 @@ +package io.sentry.android.core.anr; + +import java.util.Arrays; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public class AggregatedStackTrace { + // the number of frames of the stacktrace + final int depth; + + // the quality of the stack trace, higher means better (ratio of app frames: 0.0 to 1.0) + final float quality; + + private final StackTraceElement[] stack; + + // 0 is the most detailed frame in the stacktrace + private final int stackStartIdx; + private final int stackEndIdx; + + // the total number of times this exact stacktrace was captured + int count; + + // first time the stacktrace occurred + private long startTimeMs; + + // last time the stacktrace occurred + private long endTimeMs; + + public AggregatedStackTrace( + final StackTraceElement[] stack, + final int stackStartIdx, + final int stackEndIdx, + final long timestampMs, + final float quality) { + this.stack = stack; + this.stackStartIdx = stackStartIdx; + this.stackEndIdx = stackEndIdx; + this.depth = stackEndIdx - stackStartIdx + 1; + this.startTimeMs = timestampMs; + this.endTimeMs = timestampMs; + this.count = 1; + this.quality = quality; + } + + public void addOccurrence(final long timestampMs) { + this.startTimeMs = Math.min(startTimeMs, timestampMs); + this.endTimeMs = Math.max(endTimeMs, timestampMs); + this.count++; + } + + @NotNull + public StackTraceElement[] getStack() { + return Arrays.copyOfRange(stack, stackStartIdx, stackEndIdx + 1); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java new file mode 100644 index 00000000000..447cb106ce1 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java @@ -0,0 +1,165 @@ +package io.sentry.android.core.anr; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public class AnrCulpritIdentifier { + + // common Java and Android packages who are less relevant for being the actual culprit + private static final List systemAndFrameworkPackages = new ArrayList<>(11); + + static { + systemAndFrameworkPackages.add("java.lang"); + systemAndFrameworkPackages.add("java.util"); + + systemAndFrameworkPackages.add("android.app"); + systemAndFrameworkPackages.add("android.os.Handler"); + systemAndFrameworkPackages.add("android.os.Looper"); + systemAndFrameworkPackages.add("android.view"); + systemAndFrameworkPackages.add("android.widget"); + systemAndFrameworkPackages.add("com.android.internal"); + systemAndFrameworkPackages.add("com.google.android"); + + systemAndFrameworkPackages.add("kotlin"); + systemAndFrameworkPackages.add("kotlinx.coroutines"); + } + + private static final class StackTraceKey { + private final @NotNull StackTraceElement[] stack; + private final int startIdx; + private final int endIdx; + private final int hashCode; + + StackTraceKey(final @NotNull StackTraceElement[] stack, final int startIdx, final int endIdx) { + this.stack = stack; + this.startIdx = startIdx; + this.endIdx = endIdx; + this.hashCode = computeHashCode(); + } + + private int computeHashCode() { + int result = 1; + for (int i = startIdx; i <= endIdx; i++) { + result = 31 * result + stack[i].hashCode(); + } + return result; + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof StackTraceKey)) { + return false; + } + + final @NotNull StackTraceKey other = (StackTraceKey) obj; + + if (hashCode != other.hashCode) { + return false; + } + + final int length = endIdx - startIdx + 1; + final int otherLength = other.endIdx - other.startIdx + 1; + if (length != otherLength) { + return false; + } + + for (int i = 0; i < length; i++) { + if (!stack[startIdx + i].equals(other.stack[other.startIdx + i])) { + return false; + } + } + + return true; + } + } + + /** + * @param stacks a list of stack traces to analyze + * @return the most common occurring stacktrace identified as the culprit + */ + @Nullable + public static AggregatedStackTrace identify(final @NotNull List stacks) { + if (stacks.isEmpty()) { + return null; + } + + // fold all stacktraces and count their occurrences + final @NotNull Map stackTraceMap = new HashMap<>(); + for (final @NotNull AnrStackTrace stackTrace : stacks) { + if (stackTrace.stack.length < 2) { + continue; + } + + // stack[0] is the most detailed element in the stacktrace + // iterate from end to start (length-1 → 0) creating sub-stacks (i..n-1) to find the most + // common root cause + // count app frames from the end to compute quality scores + int appFramesCount = 0; + + for (int i = stackTrace.stack.length - 1; i >= 0; i--) { + + final @NotNull String topMostClassName = stackTrace.stack[i].getClassName(); + final boolean isSystemFrame = isSystemFrame(topMostClassName); + if (!isSystemFrame) { + appFramesCount++; + } + + final int totalFrames = stackTrace.stack.length - i; + final float quality = (float) appFramesCount / totalFrames; + + final @NotNull StackTraceKey key = + new StackTraceKey(stackTrace.stack, i, stackTrace.stack.length - 1); + + @Nullable AggregatedStackTrace aggregatedStackTrace = stackTraceMap.get(key); + if (aggregatedStackTrace == null) { + aggregatedStackTrace = + new AggregatedStackTrace( + stackTrace.stack, + i, + stackTrace.stack.length - 1, + stackTrace.timestampMs, + quality); + stackTraceMap.put(key, aggregatedStackTrace); + } else { + aggregatedStackTrace.addOccurrence(stackTrace.timestampMs); + } + } + } + + if (stackTraceMap.isEmpty()) { + return null; + } + + // the deepest stacktrace with most count wins + return Collections.max( + stackTraceMap.values(), + (c1, c2) -> + Float.compare( + c1.count * (1.0f + c1.quality) * c1.depth, + c2.count * (1.0f + c2.quality) * c2.depth)); + } + + public static boolean isSystemFrame(final @NotNull String clazz) { + for (final String systemPackage : systemAndFrameworkPackages) { + if (clazz.startsWith(systemPackage)) { + return true; + } + } + return false; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java new file mode 100644 index 00000000000..19999683905 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java @@ -0,0 +1,35 @@ +package io.sentry.android.core.anr; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public class AnrProfile { + public final List stacks; + + public final long startTimeMs; + public final long endTimeMs; + + public AnrProfile(final @NotNull List stacks) { + this.stacks = new ArrayList<>(stacks.size()); + for (AnrStackTrace stack : stacks) { + if (stack != null) { + this.stacks.add(stack); + } + } + Collections.sort(this.stacks); + + if (!this.stacks.isEmpty()) { + startTimeMs = this.stacks.get(0).timestampMs; + + // adding 10s to be less strict around end time + endTimeMs = this.stacks.get(this.stacks.size() - 1).timestampMs + 10_000L; + } else { + startTimeMs = 0L; + endTimeMs = 0L; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java new file mode 100644 index 00000000000..7e3e1fd514e --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java @@ -0,0 +1,103 @@ +package io.sentry.android.core.anr; + +import static io.sentry.SentryLevel.ERROR; +import static io.sentry.android.core.anr.AnrProfilingIntegration.POLLING_INTERVAL_MS; +import static io.sentry.android.core.anr.AnrProfilingIntegration.THRESHOLD_ANR_MS; + +import io.sentry.ILogger; +import io.sentry.SentryOptions; +import io.sentry.cache.tape.ObjectQueue; +import io.sentry.cache.tape.QueueFile; +import io.sentry.util.Objects; +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public class AnrProfileManager implements AutoCloseable { + + private static final int MAX_NUM_STACKTRACES = + (int) ((THRESHOLD_ANR_MS / POLLING_INTERVAL_MS) * 2); + + @NotNull private final ObjectQueue queue; + + public AnrProfileManager(final @NotNull SentryOptions options) { + this( + options, + new File( + Objects.requireNonNull(options.getCacheDirPath(), "cacheDirPath is required"), + "anr_profile")); + } + + public AnrProfileManager(final @NotNull SentryOptions options, final @NotNull File file) { + final @NotNull ILogger logger = options.getLogger(); + + @Nullable QueueFile queueFile = null; + try { + try { + queueFile = new QueueFile.Builder(file).size(MAX_NUM_STACKTRACES).build(); + } catch (IOException e) { + // if file is corrupted we simply delete it and try to create it again + if (!file.delete()) { + throw new IOException("Could not delete file"); + } + queueFile = new QueueFile.Builder(file).size(MAX_NUM_STACKTRACES).build(); + } + } catch (IOException e) { + logger.log(ERROR, "Failed to create stacktrace queue", e); + } + + if (queueFile == null) { + queue = ObjectQueue.createEmpty(); + } else { + queue = + ObjectQueue.create( + queueFile, + new ObjectQueue.Converter() { + @Override + public AnrStackTrace from(final byte[] source) throws IOException { + // no need to close the streams since they are backed by byte arrays and don't + // hold any resources + final @NotNull ByteArrayInputStream bis = new ByteArrayInputStream(source); + final @NotNull DataInputStream dis = new DataInputStream(bis); + return AnrStackTrace.deserialize(dis); + } + + @Override + public void toStream( + final @NotNull AnrStackTrace value, final @NotNull OutputStream sink) + throws IOException { + try (final @NotNull DataOutputStream dos = new DataOutputStream(sink)) { + value.serialize(dos); + dos.flush(); + sink.flush(); + } + } + }); + } + } + + public void clear() throws IOException { + queue.clear(); + } + + public void add(AnrStackTrace trace) throws IOException { + queue.add(trace); + } + + @NotNull + public AnrProfile load() throws IOException { + return new AnrProfile(queue.asList()); + } + + @Override + public void close() throws IOException { + queue.close(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java new file mode 100644 index 00000000000..761309f1670 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java @@ -0,0 +1,73 @@ +package io.sentry.android.core.anr; + +import java.io.File; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Coordinates file rotation between AnrProfilingIntegration and AnrV2Integration to prevent + * concurrent access to the same QueueFile. + */ +@ApiStatus.Internal +public class AnrProfileRotationHelper { + + private static final String RECORDING_FILE_NAME = "anr_profile"; + private static final String OLD_FILE_NAME = "anr_profile_old"; + + private static final AtomicBoolean shouldRotate = new AtomicBoolean(true); + private static final Object rotationLock = new Object(); + + public static void rotate() { + shouldRotate.set(true); + } + + private static void performRotationIfNeeded(final @NotNull File cacheDir) { + if (!shouldRotate.get()) { + return; + } + + synchronized (rotationLock) { + if (!shouldRotate.get()) { + return; + } + + final File currentFile = new File(cacheDir, RECORDING_FILE_NAME); + final File oldFile = new File(cacheDir, OLD_FILE_NAME); + + try { + oldFile.delete(); + } catch (Throwable e) { + // ignored + } + + try { + currentFile.renameTo(oldFile); + } catch (Throwable e) { + // ignored + } + + shouldRotate.set(false); + } + } + + @NotNull + public static File getFileForRecording(final @NotNull File cacheDir) { + performRotationIfNeeded(cacheDir); + return new File(cacheDir, RECORDING_FILE_NAME); + } + + @NotNull + public static File getLastFile(final @NotNull File cacheDir) { + performRotationIfNeeded(cacheDir); + return new File(cacheDir, OLD_FILE_NAME); + } + + public static boolean deleteLastFile(final @NotNull File cacheDir) { + final File oldFile = new File(cacheDir, OLD_FILE_NAME); + if (!oldFile.exists()) { + return true; + } + return oldFile.delete(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java new file mode 100644 index 00000000000..97ec0434249 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -0,0 +1,308 @@ +package io.sentry.android.core.anr; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.Integration; +import io.sentry.NoOpLogger; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.android.core.AppState; +import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.Objects; +import io.sentry.util.SentryRandom; +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +public class AnrProfilingIntegration + implements Integration, Closeable, AppState.AppStateListener, Runnable { + + public static final long POLLING_INTERVAL_MS = 66; + private static final long THRESHOLD_SUSPICION_MS = 1000; + public static final long THRESHOLD_ANR_MS = 4000; + static final int MAX_NUM_STACKS = (int) (10_000 / POLLING_INTERVAL_MS); + + private final AtomicBoolean enabled = new AtomicBoolean(true); + private final Runnable updater = () -> lastMainThreadExecutionTime = SystemClock.uptimeMillis(); + private final @NotNull AutoClosableReentrantLock lifecycleLock = new AutoClosableReentrantLock(); + private final @NotNull AutoClosableReentrantLock profileManagerLock = + new AutoClosableReentrantLock(); + + private volatile long lastMainThreadExecutionTime = SystemClock.uptimeMillis(); + final AtomicInteger numCollectedStacks = new AtomicInteger(); + private volatile MainThreadState mainThreadState = MainThreadState.IDLE; + private volatile @Nullable AnrProfileManager profileManager; + private volatile @NotNull ILogger logger = NoOpLogger.getInstance(); + private volatile @Nullable SentryAndroidOptions options; + private volatile @Nullable Thread thread = null; + private volatile boolean sampled = false; + private volatile boolean inForeground = false; + private volatile @Nullable Handler mainHandler; + private volatile @Nullable Thread mainThread; + + @Override + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + this.options = + Objects.requireNonNull( + (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, + "SentryAndroidOptions is required"); + this.logger = options.getLogger(); + + if (this.options.isAnrProfilingEnabled()) { + if (this.options.getCacheDirPath() == null) { + logger.log(SentryLevel.WARNING, "ANR Profiling is enabled but cacheDirPath is not set"); + return; + } + + final Looper mainLooper = Looper.getMainLooper(); + this.mainThread = mainLooper.getThread(); + this.mainHandler = new Handler(mainLooper); + + addIntegrationToSdkVersion("AnrProfiling"); + AppState.getInstance().addAppStateListener(this); + } + } + + @Override + public void close() throws IOException { + enabled.set(false); + AppState.getInstance().removeAppStateListener(this); + + // Remove any pending updater callbacks from the main handler + final @Nullable Handler handler = mainHandler; + if (handler != null) { + handler.removeCallbacks(updater); + } + + // Wake and interrupt the thread so it exits + final @Nullable Thread t = thread; + if (t != null) { + synchronized (this) { + notifyAll(); + } + t.interrupt(); + } + + final @Nullable SentryAndroidOptions opts = options; + final @Nullable AnrProfileManager pm; + try (final @NotNull ISentryLifecycleToken ignored = profileManagerLock.acquire()) { + pm = profileManager; + profileManager = null; + } + if (opts != null) { + try { + opts.getExecutorService() + .submit( + () -> { + if (pm == null) { + return; + } + + try { + pm.close(); + } catch (IOException e) { + logger.log(SentryLevel.WARNING, "Failed to close AnrProfileManager"); + } + }); + } catch (Throwable e) { + logger.log(SentryLevel.WARNING, "Failed to submit AnrProfileManager close"); + } + } + } + + @Override + public void onForeground() { + if (!enabled.get()) { + return; + } + try (final @NotNull ISentryLifecycleToken ignored = lifecycleLock.acquire()) { + if (inForeground) { + return; + } + inForeground = true; + updater.run(); + + final @Nullable Thread existingThread = thread; + if (existingThread != null && existingThread.isAlive()) { + // Wake the existing thread + synchronized (this) { + notifyAll(); + } + } + if (existingThread == null || !existingThread.isAlive()) { + final @NotNull Thread profilingThread = new Thread(this, "AnrProfilingIntegration"); + profilingThread.setDaemon(true); + profilingThread.start(); + thread = profilingThread; + } + } + } + + @Override + public void onBackground() { + if (!enabled.get()) { + return; + } + try (final @NotNull ISentryLifecycleToken ignored = lifecycleLock.acquire()) { + inForeground = false; + } + } + + @Override + public void run() { + final @Nullable Handler handler = mainHandler; + final @Nullable Thread mt = mainThread; + if (handler == null || mt == null) { + return; + } + + try { + while (enabled.get() && !Thread.currentThread().isInterrupted()) { + try { + if (!inForeground) { + // Wait until we're back in the foreground or disabled + synchronized (this) { + while (!inForeground && enabled.get()) { + wait(); + } + } + // Reset the updater timestamp after waking to avoid false suspicion + updater.run(); + continue; + } + + checkMainThread(mt); + + handler.removeCallbacks(updater); + handler.post(updater); + + // noinspection BusyWait + Thread.sleep(POLLING_INTERVAL_MS); + } catch (InterruptedException e) { + // Restore interrupt status and exit the polling loop + Thread.currentThread().interrupt(); + return; + } + } + } catch (Throwable t) { + logger.log(SentryLevel.WARNING, "Failed to execute AnrStacktraceIntegration", t); + } + } + + @ApiStatus.Internal + protected void checkMainThread(final @NotNull Thread mainThread) throws IOException { + final long now = SystemClock.uptimeMillis(); + final long diff = now - lastMainThreadExecutionTime; + + if (diff < THRESHOLD_SUSPICION_MS) { + mainThreadState = MainThreadState.IDLE; + sampled = false; + } + + if (mainThreadState == MainThreadState.IDLE && diff > THRESHOLD_SUSPICION_MS) { + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, "ANR: main thread is suspicious"); + } + mainThreadState = MainThreadState.SUSPICIOUS; + + final @Nullable SentryAndroidOptions opts = options; + final @Nullable Double sampleRate = opts != null ? opts.getAnrProfilingSampleRate() : null; + if (sampleRate != null && SentryRandom.current().nextDouble() < sampleRate) { + sampled = true; + } + + if (sampled) { + clearStacks(); + } + } + + // if we are suspicious and sampled, we need to collect stack traces + if (sampled + && (mainThreadState == MainThreadState.SUSPICIOUS + || mainThreadState == MainThreadState.ANR_DETECTED)) { + if (numCollectedStacks.get() < MAX_NUM_STACKS) { + final long start = SystemClock.uptimeMillis(); + final @NotNull AnrStackTrace trace = + new AnrStackTrace(System.currentTimeMillis(), mainThread.getStackTrace()); + final long duration = SystemClock.uptimeMillis() - start; + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log( + SentryLevel.DEBUG, + "AnrWatchdog: capturing main thread stacktrace took " + duration + "ms"); + } + addStackTrace(trace); + } else { + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log( + SentryLevel.DEBUG, + "ANR: reached maximum number of collected stack traces, skipping further collection"); + } + } + } + + if (mainThreadState == MainThreadState.SUSPICIOUS && diff > THRESHOLD_ANR_MS) { + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, "ANR: main thread ANR threshold reached"); + } + mainThreadState = MainThreadState.ANR_DETECTED; + } + } + + @TestOnly + @NotNull + protected MainThreadState getState() { + return mainThreadState; + } + + @TestOnly + @NotNull + protected AnrProfileManager getProfileManager() { + try (final @NotNull ISentryLifecycleToken ignored = profileManagerLock.acquire()) { + if (profileManager == null) { + final @NotNull SentryOptions opts = + Objects.requireNonNull(options, "Options can't be null"); + final @Nullable String cacheDirPath = opts.getCacheDirPath(); + if (cacheDirPath == null) { + throw new IllegalStateException("cacheDirPath is required for ANR profiling"); + } + final @NotNull File currentFile = + AnrProfileRotationHelper.getFileForRecording(new File(cacheDirPath)); + profileManager = new AnrProfileManager(opts, currentFile); + } + + return profileManager; + } + } + + private void clearStacks() throws IOException { + numCollectedStacks.set(0); + getProfileManager().clear(); + } + + private void addStackTrace(@NotNull final AnrStackTrace trace) throws IOException { + if (!enabled.get()) { + return; + } + numCollectedStacks.incrementAndGet(); + getProfileManager().add(trace); + } + + protected enum MainThreadState { + IDLE, + SUSPICIOUS, + ANR_DETECTED, + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java new file mode 100644 index 00000000000..2d165c501bf --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java @@ -0,0 +1,79 @@ +package io.sentry.android.core.anr; + +import io.sentry.util.StringUtils; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class AnrStackTrace implements Comparable { + + private static final int MAX_STACK_LENGTH = 1000; + + public final StackTraceElement[] stack; + public final long timestampMs; + + public AnrStackTrace(final long timestampMs, final StackTraceElement[] stack) { + this.timestampMs = timestampMs; + this.stack = stack; + } + + @Override + public int compareTo(final @NotNull AnrStackTrace o) { + return Long.compare(timestampMs, o.timestampMs); + } + + public void serialize(final @NotNull DataOutputStream dos) throws IOException { + dos.writeShort(1); // version + dos.writeLong(timestampMs); + dos.writeInt(stack.length); + for (final @NotNull StackTraceElement element : stack) { + dos.writeUTF(StringUtils.getOrEmpty(element.getClassName())); + dos.writeUTF(StringUtils.getOrEmpty(element.getMethodName())); + // Write null as a special marker to preserve null vs empty string distinction + final @Nullable String fileName = element.getFileName(); + dos.writeBoolean(fileName == null); + dos.writeUTF(fileName == null ? "" : fileName); + dos.writeInt(element.getLineNumber()); + } + } + + @Nullable + public static AnrStackTrace deserialize(final @NotNull DataInputStream dis) throws IOException { + try { + final short version = dis.readShort(); + if (version == 1) { + final long timestampMs = dis.readLong(); + final int stackLength = dis.readInt(); + if (stackLength < 0 || stackLength > MAX_STACK_LENGTH) { + return null; + } + final @NotNull StackTraceElement[] stack = new StackTraceElement[stackLength]; + + for (int i = 0; i < stackLength; i++) { + final @NotNull String className = dis.readUTF(); + final @NotNull String methodName = dis.readUTF(); + // Read the null marker to restore null vs empty string distinction + final boolean isFileNameNull = dis.readBoolean(); + final @NotNull String fileNameStr = dis.readUTF(); + final @Nullable String fileName = isFileNameNull ? null : fileNameStr; + final int lineNumber = dis.readInt(); + final StackTraceElement element = + new StackTraceElement(className, methodName, fileName, lineNumber); + stack[i] = element; + } + + return new AnrStackTrace(timestampMs, stack); + } else { + // unsupported future version + return null; + } + } catch (EOFException e) { + return null; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java new file mode 100644 index 00000000000..8f1254e08ca --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java @@ -0,0 +1,181 @@ +package io.sentry.android.core.anr; + +import io.sentry.protocol.SentryStackFrame; +import io.sentry.protocol.profiling.SentryProfile; +import io.sentry.protocol.profiling.SentrySample; +import io.sentry.protocol.profiling.SentryThreadMetadata; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Converts a list of {@link AnrStackTrace} objects captured during ANR detection into a {@link + * SentryProfile} object suitable for profiling telemetry. + * + *

    This converter handles: + * + *

      + *
    • Converting {@link StackTraceElement} to {@link SentryStackFrame} + *
    • Deduplicating frames based on their signature + *
    • Building stack references using frame indices + *
    • Creating samples with timestamps + *
    • Populating thread metadata + *
    + */ +@ApiStatus.Internal +public final class StackTraceConverter { + + private static final String MAIN_THREAD_ID = "0"; + private static final String MAIN_THREAD_NAME = "main"; + + /** + * Timestamp offset used with synthetic ANR profile samples. (Currently 33 ms.) + * + *

    Places the synthetic sample halfway to the next ANR polling tick. + */ + private static final double SYNTHETIC_SAMPLE_OFFSET_SECONDS = + (AnrProfilingIntegration.POLLING_INTERVAL_MS / 2.0d) / 1000.0d; + + /** + * Converts a list of {@link AnrStackTrace} objects to a {@link SentryProfile}. + * + * @param anrProfile The ANR Profile + * @return a populated SentryProfile with deduped frames and samples + */ + @NotNull + public static SentryProfile convert(final @NotNull AnrProfile anrProfile) { + final @NotNull List anrStackTraces = anrProfile.stacks; + + final @NotNull SentryProfile profile = new SentryProfile(); + final @NotNull List frames = new ArrayList<>(); + final @NotNull Map frameSignatureToIndex = new HashMap<>(); + final @NotNull List> stacks = new ArrayList<>(); + final @NotNull Map stackSignatureToIndex = new HashMap<>(); + + for (final @NotNull AnrStackTrace anrStackTrace : anrStackTraces) { + final @NotNull StackTraceElement[] stackElements = anrStackTrace.stack; + final @NotNull List frameIndices = new ArrayList<>(); + for (final @NotNull StackTraceElement element : stackElements) { + final @NotNull String frameSignature = createFrameSignature(element); + @Nullable Integer frameIndex = frameSignatureToIndex.get(frameSignature); + if (frameIndex == null) { + frameIndex = frames.size(); + frames.add(createSentryStackFrame(element)); + frameSignatureToIndex.put(frameSignature, frameIndex); + } + frameIndices.add(frameIndex); + } + + final @NotNull String stackSignature = createStackSignature(frameIndices); + @Nullable Integer stackIndex = stackSignatureToIndex.get(stackSignature); + + if (stackIndex == null) { + stackIndex = stacks.size(); + stacks.add(new ArrayList<>(frameIndices)); + stackSignatureToIndex.put(stackSignature, stackIndex); + } + + final @NotNull SentrySample sample = new SentrySample(); + sample.setTimestamp(anrStackTrace.timestampMs / 1000.0); // Convert ms to seconds + sample.setStackId(stackIndex); + sample.setThreadId(MAIN_THREAD_ID); + + profile.getSamples().add(sample); + } + + // Relay will reject ANR profiles with only one sample, even though they're still useful. + // (Relay's policy was defined with continuous profiles in mind, before ANR profiles were a + // thing.) If we only have one sample, synthesize another that only differs in its timestamp. + if (profile.getSamples().size() == 1) { + final @NotNull SentrySample originalSample = profile.getSamples().get(0); + final @NotNull SentrySample syntheticSample = createSyntheticSample(originalSample); + profile.getSamples().add(syntheticSample); + } + + profile.setFrames(frames); + profile.setStacks(stacks); + + final @NotNull SentryThreadMetadata threadMetadata = new SentryThreadMetadata(); + threadMetadata.setName(MAIN_THREAD_NAME); + threadMetadata.setPriority(Thread.NORM_PRIORITY); + + final @NotNull Map threadMetadataMap = + Collections.singletonMap(MAIN_THREAD_ID, threadMetadata); + profile.setThreadMetadata(threadMetadataMap); + + return profile; + } + + /** + * Creates a unique signature for a StackTraceElement to identify duplicate frames. + * + * @param element the stack trace element + * @return a signature string representing this frame + */ + @NotNull + private static String createFrameSignature(@NotNull StackTraceElement element) { + return element.getClassName() + + "#" + + element.getMethodName() + + "#" + + element.getFileName() + + "#" + + element.getLineNumber(); + } + + /** + * Creates a unique signature for a stack (list of frame indices) to identify duplicate stacks. + * + * @param frameIndices the list of frame indices + * @return a signature string representing this stack + */ + @NotNull + private static String createStackSignature(@NotNull List frameIndices) { + final @NotNull StringBuilder sb = new StringBuilder(); + for (Integer index : frameIndices) { + if (sb.length() > 0) { + sb.append(","); + } + sb.append(index); + } + return sb.toString(); + } + + /** + * Converts a {@link StackTraceElement} to a {@link SentryStackFrame}. + * + * @param element the stack trace element + * @return a SentryStackFrame populated with available information + */ + @NotNull + private static SentryStackFrame createSentryStackFrame(@NotNull StackTraceElement element) { + final @NotNull SentryStackFrame frame = new SentryStackFrame(); + frame.setFilename(element.getFileName()); + frame.setFunction(element.getMethodName()); + frame.setModule(element.getClassName()); + frame.setLineno(element.getLineNumber() > 0 ? element.getLineNumber() : null); + if (element.isNativeMethod()) { + frame.setNative(true); + } + return frame; + } + + /** + * Creates a {@link SentrySample} identical to {@code originalSample}, save that its timestamp is + * advanced by {@link #SYNTHETIC_SAMPLE_OFFSET_SECONDS}. + * + *

    Lets us produce a plausible synthetic sample without misleading the user about the ANR's + * actual duration or cause. + */ + @NotNull + private static SentrySample createSyntheticSample(@NotNull SentrySample originalSample) { + final @NotNull SentrySample syntheticSample = new SentrySample(originalSample); + syntheticSample.setTimestamp(originalSample.getTimestamp() + SYNTHETIC_SAMPLE_OFFSET_SECONDS); + return syntheticSample; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index 2829abc50c9..1ef02dfdd2c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -10,6 +10,7 @@ import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.android.core.AnrV2Integration; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.TombstoneIntegration; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; @@ -19,8 +20,11 @@ import io.sentry.util.HintUtils; import io.sentry.util.Objects; import java.io.File; +import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.OutputStream; +import java.util.Arrays; +import java.util.List; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -30,6 +34,7 @@ public final class AndroidEnvelopeCache extends EnvelopeCache { public static final String LAST_ANR_REPORT = "last_anr_report"; + public static final String LAST_TOMBSTONE_REPORT = "last_tombstone_report"; private final @NotNull ICurrentDateProvider currentDateProvider; @@ -79,26 +84,16 @@ private boolean storeInternalAndroid(@NotNull SentryEnvelope envelope, @NotNull } } - HintUtils.runIfHasType( - hint, - AnrV2Integration.AnrV2Hint.class, - (anrHint) -> { - final @Nullable Long timestamp = anrHint.timestamp(); - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Writing last reported ANR marker with timestamp %d", - timestamp); + for (TimestampMarkerHandler handler : TIMESTAMP_MARKER_HANDLERS) { + handler.handle(this, hint, options); + } - writeLastReportedAnrMarker(timestamp); - }); return didStore; } @TestOnly public @NotNull File getDirectory() { - return directory; + return directory.getFile(); } private void writeStartupCrashMarkerFile() { @@ -111,7 +106,14 @@ private void writeStartupCrashMarkerFile() { .log(DEBUG, "Outbox path is null, the startup crash marker file will not be written"); return; } - final File crashMarkerFile = new File(outboxPath, STARTUP_CRASH_MARKER_FILE); + // The outbox dir is no longer created during Sentry.init, so create it here in case the native + // SDK (which normally creates it) is disabled. + final File outboxDir = new File(outboxPath); + if (!FileUtils.createDirectory(outboxDir)) { + options.getLogger().log(ERROR, "Failed to create outbox dir %s", outboxPath); + return; + } + final File crashMarkerFile = new File(outboxDir, STARTUP_CRASH_MARKER_FILE); try { crashMarkerFile.createNewFile(); } catch (Throwable e) { @@ -150,42 +152,124 @@ public static boolean hasStartupCrashMarker(final @NotNull SentryOptions options return false; } - public static @Nullable Long lastReportedAnr(final @NotNull SentryOptions options) { + private static @Nullable Long lastReportedMarker( + final @NotNull SentryOptions options, + @NotNull String reportFilename, + @NotNull String markerLabel) { final String cacheDirPath = Objects.requireNonNull( - options.getCacheDirPath(), "Cache dir path should be set for getting ANRs reported"); + options.getCacheDirPath(), + "Cache dir path should be set for getting " + markerLabel + "s reported"); - final File lastAnrMarker = new File(cacheDirPath, LAST_ANR_REPORT); + final File lastMarker = new File(cacheDirPath, reportFilename); try { - if (lastAnrMarker.exists() && lastAnrMarker.canRead()) { - final String content = FileUtils.readText(lastAnrMarker); - // we wrapped into try-catch already - //noinspection ConstantConditions - return content.equals("null") ? null : Long.parseLong(content.trim()); - } else { + final String content = FileUtils.readText(lastMarker); + // we wrapped into try-catch already + //noinspection ConstantConditions + return (content == null || content.equals("null")) ? null : Long.parseLong(content.trim()); + } catch (Throwable e) { + if (e instanceof FileNotFoundException) { options .getLogger() - .log(DEBUG, "Last ANR marker does not exist. %s.", lastAnrMarker.getAbsolutePath()); + .log( + DEBUG, + "Last " + markerLabel + " marker does not exist. %s.", + lastMarker.getAbsolutePath()); + } else { + options.getLogger().log(ERROR, "Error reading last " + markerLabel + " marker", e); } - } catch (Throwable e) { - options.getLogger().log(ERROR, "Error reading last ANR marker", e); } return null; } - private void writeLastReportedAnrMarker(final @Nullable Long timestamp) { + private void writeLastReportedMarker( + final @Nullable Long timestamp, + @NotNull String reportFilename, + @NotNull String markerCategory) { final String cacheDirPath = options.getCacheDirPath(); if (cacheDirPath == null) { - options.getLogger().log(DEBUG, "Cache dir path is null, the ANR marker will not be written"); + options + .getLogger() + .log( + DEBUG, + "Cache dir path is null, the " + markerCategory + " marker will not be written"); return; } - final File anrMarker = new File(cacheDirPath, LAST_ANR_REPORT); + final File anrMarker = new File(cacheDirPath, reportFilename); try (final OutputStream outputStream = new FileOutputStream(anrMarker)) { outputStream.write(String.valueOf(timestamp).getBytes(UTF_8)); outputStream.flush(); } catch (Throwable e) { - options.getLogger().log(ERROR, "Error writing the ANR marker to the disk", e); + options + .getLogger() + .log(ERROR, "Error writing the " + markerCategory + " marker to the disk", e); + } + } + + public static @Nullable Long lastReportedAnr(final @NotNull SentryOptions options) { + return lastReportedMarker(options, LAST_ANR_REPORT, LAST_ANR_MARKER_LABEL); + } + + public static @Nullable Long lastReportedTombstone(final @NotNull SentryOptions options) { + return lastReportedMarker(options, LAST_TOMBSTONE_REPORT, LAST_TOMBSTONE_MARKER_LABEL); + } + + private static final class TimestampMarkerHandler { + interface TimestampExtractor { + @NotNull + Long extract(T value); + } + + private final @NotNull Class type; + private final @NotNull String label; + private final @NotNull String reportFilename; + private final @NotNull TimestampExtractor timestampProvider; + + TimestampMarkerHandler( + final @NotNull Class type, + final @NotNull String label, + final @NotNull String reportFilename, + final @NotNull TimestampExtractor timestampProvider) { + this.type = type; + this.label = label; + this.reportFilename = reportFilename; + this.timestampProvider = timestampProvider; + } + + void handle( + final @NotNull AndroidEnvelopeCache cache, + final @NotNull Hint hint, + final @NotNull SentryAndroidOptions options) { + HintUtils.runIfHasType( + hint, + type, + (typedHint) -> { + final @NotNull Long timestamp = timestampProvider.extract(typedHint); + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Writing last reported %s marker with timestamp %d", + label, + timestamp); + cache.writeLastReportedMarker(timestamp, reportFilename, label); + }); } } + + public static final String LAST_TOMBSTONE_MARKER_LABEL = "Tombstone"; + public static final String LAST_ANR_MARKER_LABEL = "ANR"; + private static final List> TIMESTAMP_MARKER_HANDLERS = + Arrays.asList( + new TimestampMarkerHandler<>( + AnrV2Integration.AnrV2Hint.class, + LAST_ANR_MARKER_LABEL, + LAST_ANR_REPORT, + anrV2Hint -> anrV2Hint.timestamp()), + new TimestampMarkerHandler<>( + TombstoneIntegration.TombstoneHint.class, + LAST_TOMBSTONE_MARKER_LABEL, + LAST_TOMBSTONE_REPORT, + tombstoneHint -> tombstoneHint.timestamp())); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java index f271c3da9e3..5f6187cd39a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -1,6 +1,5 @@ package io.sentry.android.core.internal.gestures; -import android.content.res.Resources; import android.view.View; import android.widget.AbsListView; import android.widget.ScrollView; @@ -8,6 +7,7 @@ import io.sentry.android.core.internal.util.ClassUtil; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.gestures.UiElement; +import io.sentry.util.LazyEvaluator; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -17,9 +17,10 @@ public final class AndroidViewGestureTargetLocator implements GestureTargetLocat private static final String ORIGIN = "old_view_system"; - private final boolean isAndroidXAvailable; + private final @NotNull LazyEvaluator isAndroidXAvailable; - public AndroidViewGestureTargetLocator(final boolean isAndroidXAvailable) { + public AndroidViewGestureTargetLocator( + final @NotNull LazyEvaluator isAndroidXAvailable) { this.isAndroidXAvailable = isAndroidXAvailable; } @@ -33,20 +34,19 @@ public AndroidViewGestureTargetLocator(final boolean isAndroidXAvailable) { if (targetType == UiElement.Type.CLICKABLE && isViewTappable(view)) { return createUiElement(view); } else if (targetType == UiElement.Type.SCROLLABLE - && isViewScrollable(view, isAndroidXAvailable)) { + && isViewScrollable(view, isAndroidXAvailable.getValue())) { return createUiElement(view); } return null; } private UiElement createUiElement(final @NotNull View targetView) { - try { - final String resourceName = ViewUtils.getResourceId(targetView); - @Nullable String className = ClassUtil.getClassName(targetView); - return new UiElement(targetView, className, resourceName, null, ORIGIN); - } catch (Resources.NotFoundException ignored) { + final @Nullable String resourceName = ViewUtils.getResourceIdOrNull(targetView); + if (resourceName == null) { return null; } + @Nullable String className = ClassUtil.getClassName(targetView); + return new UiElement(targetView, className, resourceName, null, ORIGIN); } private static boolean isViewTappable(final @NotNull View view) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureDetector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureDetector.java new file mode 100644 index 00000000000..002938e9d60 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureDetector.java @@ -0,0 +1,150 @@ +package io.sentry.android.core.internal.gestures; + +import android.content.Context; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.ViewConfiguration; +import io.sentry.ISentryLifecycleToken; +import io.sentry.util.AutoClosableReentrantLock; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A lightweight gesture detector that replaces {@code GestureDetectorCompat}/{@link + * GestureDetector} to avoid ANRs caused by Handler/MessageQueue lock contention and IPC calls + * (FrameworkStatsLog.write). + * + *

    Only detects click (tap), scroll, and fling — the gestures used by {@link + * SentryGestureListener}. Long-press, show-press, and double-tap detection (which require Handler + * message scheduling) are intentionally omitted. + */ +@ApiStatus.Internal +public final class SentryGestureDetector { + + private final @NotNull GestureDetector.OnGestureListener listener; + private final int touchSlopSquare; + private final int minimumFlingVelocity; + private final int maximumFlingVelocity; + + private boolean isInTapRegion; + private boolean ignoreUpEvent; + private float downX; + private float downY; + private float lastX; + private float lastY; + private @Nullable MotionEvent currentDownEvent; + private @Nullable VelocityTracker velocityTracker; + + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + + SentryGestureDetector( + final @NotNull Context context, final @NotNull GestureDetector.OnGestureListener listener) { + this.listener = listener; + final ViewConfiguration config = ViewConfiguration.get(context); + final int touchSlop = config.getScaledTouchSlop(); + this.touchSlopSquare = touchSlop * touchSlop; + this.minimumFlingVelocity = config.getScaledMinimumFlingVelocity(); + this.maximumFlingVelocity = config.getScaledMaximumFlingVelocity(); + } + + void onTouchEvent(final @NotNull MotionEvent event) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final int action = event.getActionMasked(); + + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain(); + } + velocityTracker.addMovement(event); + + switch (action) { + case MotionEvent.ACTION_DOWN: + downX = event.getX(); + downY = event.getY(); + lastX = downX; + lastY = downY; + isInTapRegion = true; + ignoreUpEvent = false; + + if (currentDownEvent != null) { + currentDownEvent.recycle(); + } + currentDownEvent = MotionEvent.obtain(event); + + listener.onDown(event); + break; + + case MotionEvent.ACTION_MOVE: + { + final float x = event.getX(); + final float y = event.getY(); + final float dx = x - downX; + final float dy = y - downY; + final float distanceSquare = (dx * dx) + (dy * dy); + + if (distanceSquare > touchSlopSquare) { + final float scrollX = lastX - x; + final float scrollY = lastY - y; + listener.onScroll(currentDownEvent, event, scrollX, scrollY); + isInTapRegion = false; + lastX = x; + lastY = y; + } + break; + } + + case MotionEvent.ACTION_POINTER_DOWN: + // A second finger means this is not a single tap (e.g. pinch-to-zoom). + // Also suppress the UP handler to avoid spurious fling detection when the + // last finger lifts quickly after a pinch — mirrors GestureDetector's + // mIgnoreNextUpEvent / cancelTaps() behavior. + isInTapRegion = false; + ignoreUpEvent = true; + break; + + case MotionEvent.ACTION_UP: + if (ignoreUpEvent) { + recycle(); + break; + } + if (isInTapRegion) { + listener.onSingleTapUp(event); + } else { + final int pointerId = event.getPointerId(0); + velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity); + final float velocityX = velocityTracker.getXVelocity(pointerId); + final float velocityY = velocityTracker.getYVelocity(pointerId); + + if (Math.abs(velocityX) > minimumFlingVelocity + || Math.abs(velocityY) > minimumFlingVelocity) { + listener.onFling(currentDownEvent, event, velocityX, velocityY); + } + } + recycle(); + break; + + case MotionEvent.ACTION_CANCEL: + recycle(); + break; + } + } + } + + void recycle() { + final @Nullable MotionEvent capturedDownEvent; + final @Nullable VelocityTracker capturedVelocityTracker; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + capturedDownEvent = currentDownEvent; + currentDownEvent = null; + capturedVelocityTracker = velocityTracker; + velocityTracker = null; + } + if (capturedDownEvent != null) { + capturedDownEvent.recycle(); + } + if (capturedVelocityTracker != null) { + capturedVelocityTracker.recycle(); + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index cd89db72f53..61a32b675db 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -244,6 +244,21 @@ private void startTracing(final @NotNull UiElement target, final @NotNull Gestur } } + // if there's already a transaction bound to the Scope (e.g. started manually by the user), we + // skip starting a new UI transaction: it would never be bound to the Scope in applyScope, would + // gather no children, and would be dropped as an idle transaction without children + final @Nullable ITransaction[] boundTransaction = {null}; + scopes.configureScope(scope -> boundTransaction[0] = scope.getTransaction()); + if (boundTransaction[0] != null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Transaction won't be created for view with id: %s since there's already a transaction bound to the Scope.", + viewIdentifier); + return; + } + // we can only bind to the scope if there's no running transaction final String name = getActivityName(activity) + "." + viewIdentifier; final String op = UI_ACTION + "." + getGestureType(eventType); @@ -347,7 +362,7 @@ void applyScope(final @NotNull IScope scope, final @NotNull ITransaction transac return null; } - final View decorView = window.getDecorView(); + final View decorView = window.peekDecorView(); if (decorView == null) { options .getLogger() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java index edb9c9f9daa..612eb97946e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java @@ -1,11 +1,8 @@ package io.sentry.android.core.internal.gestures; import android.content.Context; -import android.os.Handler; -import android.os.Looper; import android.view.MotionEvent; import android.view.Window; -import androidx.core.view.GestureDetectorCompat; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SpanStatus; @@ -18,10 +15,14 @@ public final class SentryWindowCallback extends WindowCallbackAdapter { private final @NotNull Window.Callback delegate; private final @NotNull SentryGestureListener gestureListener; - private final @NotNull GestureDetectorCompat gestureDetector; + private final @NotNull SentryGestureDetector gestureDetector; private final @Nullable SentryOptions options; private final @NotNull MotionEventObtainer motionEventObtainer; + // When we can't be removed from the callback chain (see UserInteractionIntegration), + // stopTracking() flips this so handleTouchEvent short-circuits. + private volatile boolean inert; + public SentryWindowCallback( final @NotNull Window.Callback delegate, final @NotNull Context context, @@ -29,7 +30,7 @@ public SentryWindowCallback( final @Nullable SentryOptions options) { this( delegate, - new GestureDetectorCompat(context, gestureListener, new Handler(Looper.getMainLooper())), + new SentryGestureDetector(context, gestureListener), gestureListener, options, new MotionEventObtainer() {}); @@ -37,7 +38,7 @@ public SentryWindowCallback( SentryWindowCallback( final @NotNull Window.Callback delegate, - final @NotNull GestureDetectorCompat gestureDetector, + final @NotNull SentryGestureDetector gestureDetector, final @NotNull SentryGestureListener gestureListener, final @Nullable SentryOptions options, final @NotNull MotionEventObtainer motionEventObtainer) { @@ -67,6 +68,9 @@ public boolean dispatchTouchEvent(final @Nullable MotionEvent motionEvent) { } private void handleTouchEvent(final @NotNull MotionEvent motionEvent) { + if (inert) { + return; + } gestureDetector.onTouchEvent(motionEvent); int action = motionEvent.getActionMasked(); if (action == MotionEvent.ACTION_UP) { @@ -75,7 +79,9 @@ private void handleTouchEvent(final @NotNull MotionEvent motionEvent) { } public void stopTracking() { + inert = true; gestureListener.stopTracing(SpanStatus.CANCELLED); + gestureDetector.recycle(); } public @NotNull Window.Callback getDelegate() { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java index 501a05a5007..78c73713bd4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -1,13 +1,14 @@ package io.sentry.android.core.internal.gestures; import android.content.res.Resources; +import android.graphics.Matrix; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.gestures.UiElement; -import java.util.LinkedList; +import java.util.ArrayDeque; import java.util.List; import java.util.Queue; import org.jetbrains.annotations.ApiStatus; @@ -17,30 +18,53 @@ @ApiStatus.Internal public final class ViewUtils { - private static final int[] coordinates = new int[2]; - /** - * Verifies if the given touch coordinates are within the bounds of the given view. + * Verifies if the given touch coordinates, expressed in the view's own local coordinate space, + * are within the bounds of the given view. * * @param view the view to check if the touch coordinates are within its bounds - * @param x - the x coordinate of a {@link MotionEvent} - * @param y - the y coordinate of {@link MotionEvent} + * @param localX - the x coordinate of the touch, relative to the view's top-left corner + * @param localY - the y coordinate of the touch, relative to the view's top-left corner * @return true if the touch coordinates are within the bounds of the view, false otherwise */ private static boolean touchWithinBounds( - final @Nullable View view, final float x, final float y) { + final @Nullable View view, final float localX, final float localY) { if (view == null) { return false; } - view.getLocationOnScreen(coordinates); - int vx = coordinates[0]; - int vy = coordinates[1]; + final int w = view.getWidth(); + final int h = view.getHeight(); - int w = view.getWidth(); - int h = view.getHeight(); + return !(localX < 0 || localX > w || localY < 0 || localY > h); + } - return !(x < vx || x > vx + w || y < vy || y > vy + h); + /** + * Maps a touch point expressed in the parent's local coordinate space into the child's local + * coordinate space. This mirrors how {@link ViewGroup} dispatches touch events to its children + * and lets us hit-test the whole tree with a single downward traversal, instead of calling {@link + * View#getLocationOnScreen(int[])} (which walks up to the root) for every view. + */ + private static @NotNull ViewWithLocation mapToChild( + final @NotNull View child, + final float parentX, + final float parentY, + final int parentScrollX, + final int parentScrollY) { + float childX = parentX + parentScrollX - child.getLeft(); + float childY = parentY + parentScrollY - child.getTop(); + + final @Nullable Matrix matrix = child.getMatrix(); + if (matrix != null && !matrix.isIdentity()) { + final Matrix inverse = new Matrix(); + if (matrix.invert(inverse)) { + final float[] point = {childX, childY}; + inverse.mapPoints(point); + childX = point[0]; + childY = point[1]; + } + } + return new ViewWithLocation(child, childX, childY); } /** @@ -48,8 +72,8 @@ private static boolean touchWithinBounds( * given {@code viewTargetSelector}. * * @param decorView - the root view of this window - * @param x - the x coordinate of a {@link MotionEvent} - * @param y - the y coordinate of {@link MotionEvent} + * @param x - the x coordinate of a {@link MotionEvent}, relative to the decor view + * @param y - the y coordinate of {@link MotionEvent}, relative to the decor view * @param targetType - the type of target to find * @return the {@link View} that contains the touch coordinates and complements the {@code * viewTargetSelector} @@ -62,25 +86,35 @@ private static boolean touchWithinBounds( final UiElement.Type targetType) { final List locators = options.getGestureTargetLocators(); - final Queue queue = new LinkedList<>(); - queue.add(decorView); + final Queue queue = new ArrayDeque<>(); + // The touch coordinates from the MotionEvent are already relative to the decor view, i.e. in + // its local coordinate space. + queue.add(new ViewWithLocation(decorView, x, y)); @Nullable UiElement target = null; - while (queue.size() > 0) { - final View view = queue.poll(); + while (!queue.isEmpty()) { + final ViewWithLocation current = queue.poll(); + final View view = current.view; - if (!touchWithinBounds(view, x, y)) { + if (!touchWithinBounds(view, current.x, current.y)) { // if the touch is not hitting the view, skip traversal of its children continue; } if (view instanceof ViewGroup) { final ViewGroup viewGroup = (ViewGroup) view; + final int scrollX = viewGroup.getScrollX(); + final int scrollY = viewGroup.getScrollY(); for (int i = 0; i < viewGroup.getChildCount(); i++) { - queue.add(viewGroup.getChildAt(i)); + final @Nullable View child = viewGroup.getChildAt(i); + if (child != null) { + queue.add(mapToChild(child, current.x, current.y, scrollX, scrollY)); + } } } + // Locators receive the original decor-view-relative coordinates, as the Compose locator + // hit-tests against window coordinates. for (int i = 0; i < locators.size(); i++) { final GestureTargetLocator locator = locators.get(i); final @Nullable UiElement newTarget = locator.locate(view, x, y, targetType); @@ -96,6 +130,18 @@ private static boolean touchWithinBounds( return target; } + private static final class ViewWithLocation { + final @NotNull View view; + final float x; + final float y; + + ViewWithLocation(final @NotNull View view, final float x, final float y) { + this.view = view; + this.x = x; + this.y = y; + } + } + /** * Retrieves the human-readable view id based on {@code view.getContext().getResources()}, falls * back to a hexadecimal id representation in case the view id is not available in the resources. @@ -104,32 +150,37 @@ private static boolean touchWithinBounds( * @return human-readable view id */ static String getResourceIdWithFallback(final @NotNull View view) { - final int viewId = view.getId(); - try { - return getResourceId(view); - } catch (Resources.NotFoundException e) { + final @Nullable String resourceId = getResourceIdOrNull(view); + if (resourceId == null) { // fall back to hex representation of the id - return "0x" + Integer.toString(viewId, 16); + return "0x" + Integer.toString(view.getId(), 16); } + return resourceId; } /** - * Retrieves the human-readable view id based on {@code view.getContext().getResources()}. + * Retrieves the human-readable view id based on {@code view.getContext().getResources()}, or + * {@code null} when the view has no resource-backed id. Returning {@code null} rather than + * throwing avoids exception-driven control flow on hot, main-thread paths such as view-hierarchy + * snapshots and gesture target resolution. * * @param view - the view whose id is being retrieved - * @return human-readable view id - * @throws Resources.NotFoundException in case the view id was not found + * @return human-readable view id, or {@code null} if it cannot be resolved */ - public static String getResourceId(final @NotNull View view) throws Resources.NotFoundException { + public static @Nullable String getResourceIdOrNull(final @NotNull View view) { final int viewId = view.getId(); if (viewId == View.NO_ID || isViewIdGenerated(viewId)) { - throw new Resources.NotFoundException(); + return null; } final Resources resources = view.getContext().getResources(); - if (resources != null) { + if (resources == null) { + return ""; + } + try { return resources.getResourceEntryName(viewId); + } catch (Resources.NotFoundException e) { + return null; } - return ""; } private static boolean isViewIdGenerated(int id) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java index 05bb75a60f4..1aa8a89f8fc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java @@ -1,8 +1,8 @@ package io.sentry.android.core.internal.modules; import android.content.Context; -import io.sentry.ILogger; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.android.core.ContextUtils; import io.sentry.internal.modules.ModulesLoader; import java.io.FileNotFoundException; @@ -18,13 +18,21 @@ public final class AssetsModulesLoader extends ModulesLoader { private final @NotNull Context context; - public AssetsModulesLoader(final @NotNull Context context, final @NotNull ILogger logger) { - super(logger); + public AssetsModulesLoader(final @NotNull Context context, final @NotNull SentryOptions options) { + super(options.getLogger()); this.context = ContextUtils.getApplicationContext(context); // pre-load modules on a bg thread to avoid doing so on the main thread in case of a crash/error - //noinspection Convert2MethodRef - new Thread(() -> getOrLoadModules()).start(); + try { + options + .getExecutorService() + .submit( + () -> { + getOrLoadModules(); + }); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "AssetsModulesLoader submit failed", e); + } } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ArtContextParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ArtContextParser.java new file mode 100644 index 00000000000..af5e2214aba --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ArtContextParser.java @@ -0,0 +1,149 @@ +package io.sentry.android.core.internal.threaddump; + +import io.sentry.protocol.ArtContext; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Parses ART runtime memory and GC metrics from ANR thread dump lines. + * + * @see ART + * Heap::DumpGcCountRateHistogram + */ +final class ArtContextParser { + + private static final long KB = 1024; + private static final long MB = 1024 * KB; + private static final long GB = 1024 * MB; + + private static final String FREE_MEMORY_PREFIX = "Free memory "; + private static final String FREE_MEMORY_UNTIL_GC_PREFIX = "Free memory until GC "; + private static final String FREE_MEMORY_UNTIL_OOME_PREFIX = "Free memory until OOME "; + private static final String TOTAL_MEMORY_PREFIX = "Total memory "; + private static final String MAX_MEMORY_PREFIX = "Max memory "; + private static final String TOTAL_TIME_WAITING_FOR_GC_PREFIX = + "Total time waiting for GC to complete: "; + private static final String TOTAL_GC_COUNT_PREFIX = "Total GC count: "; + private static final String TOTAL_GC_TIME_PREFIX = "Total GC time: "; + private static final String TOTAL_BLOCKING_GC_COUNT_PREFIX = "Total blocking GC count: "; + private static final String TOTAL_BLOCKING_GC_TIME_PREFIX = "Total blocking GC time: "; + private static final String TOTAL_PRE_OOME_GC_COUNT_PREFIX = "Total pre-OOME GC count: "; + + private @Nullable ArtContext artContext; + + @Nullable + ArtContext getArtContext() { + return artContext; + } + + void parseLine(final @NotNull String text) { + if (text.startsWith(FREE_MEMORY_UNTIL_OOME_PREFIX)) { + getOrCreateArtContext() + .setFreeMemoryUntilOome( + parsePrettySize(text.substring(FREE_MEMORY_UNTIL_OOME_PREFIX.length()))); + } else if (text.startsWith(FREE_MEMORY_UNTIL_GC_PREFIX)) { + getOrCreateArtContext() + .setFreeMemoryUntilGc( + parsePrettySize(text.substring(FREE_MEMORY_UNTIL_GC_PREFIX.length()))); + } else if (text.startsWith(FREE_MEMORY_PREFIX)) { + getOrCreateArtContext() + .setFreeMemory(parsePrettySize(text.substring(FREE_MEMORY_PREFIX.length()))); + } else if (text.startsWith(TOTAL_MEMORY_PREFIX)) { + getOrCreateArtContext() + .setTotalMemory(parsePrettySize(text.substring(TOTAL_MEMORY_PREFIX.length()))); + } else if (text.startsWith(MAX_MEMORY_PREFIX)) { + getOrCreateArtContext() + .setMaxMemory(parsePrettySize(text.substring(MAX_MEMORY_PREFIX.length()))); + } else if (text.startsWith(TOTAL_TIME_WAITING_FOR_GC_PREFIX)) { + getOrCreateArtContext() + .setGcWaitingTime(parseTimeMs(text.substring(TOTAL_TIME_WAITING_FOR_GC_PREFIX.length()))); + } else if (text.startsWith(TOTAL_GC_TIME_PREFIX)) { + getOrCreateArtContext() + .setGcTotalTime(parseTimeMs(text.substring(TOTAL_GC_TIME_PREFIX.length()))); + } else if (text.startsWith(TOTAL_GC_COUNT_PREFIX)) { + getOrCreateArtContext() + .setGcTotalCount(parseLongOrNull(text.substring(TOTAL_GC_COUNT_PREFIX.length()))); + } else if (text.startsWith(TOTAL_BLOCKING_GC_TIME_PREFIX)) { + getOrCreateArtContext() + .setGcBlockingTime(parseTimeMs(text.substring(TOTAL_BLOCKING_GC_TIME_PREFIX.length()))); + } else if (text.startsWith(TOTAL_BLOCKING_GC_COUNT_PREFIX)) { + getOrCreateArtContext() + .setGcBlockingCount( + parseLongOrNull(text.substring(TOTAL_BLOCKING_GC_COUNT_PREFIX.length()))); + } else if (text.startsWith(TOTAL_PRE_OOME_GC_COUNT_PREFIX)) { + getOrCreateArtContext() + .setGcPreOomeCount( + parseLongOrNull(text.substring(TOTAL_PRE_OOME_GC_COUNT_PREFIX.length()))); + } + } + + private @NotNull ArtContext getOrCreateArtContext() { + if (artContext == null) { + artContext = new ArtContext(); + } + return artContext; + } + + /** + * Matches Android's PrettySize output: number followed by unit with no space, e.g. "3107KB". + * + *

    Counterpart to + * https://cs.android.com/android/platform/superproject/+/android-latest-release:art/libartbase/base/utils.cc;l=232-251;drc=d0d3deb269b1e14de2ec2707815e38bc95de570c + */ + private @Nullable Long parsePrettySize(final @NotNull String sizeString) { + final String trimmed = sizeString.trim(); + try { + if (trimmed.endsWith("GB")) { + return Long.parseLong(trimmed.substring(0, trimmed.length() - 2)) * GB; + } else if (trimmed.endsWith("MB")) { + return Long.parseLong(trimmed.substring(0, trimmed.length() - 2)) * MB; + } else if (trimmed.endsWith("KB")) { + return Long.parseLong(trimmed.substring(0, trimmed.length() - 2)) * KB; + } else if (trimmed.endsWith("B")) { + return Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); + } + } catch (NumberFormatException e) { + return null; + } + return null; + } + + /** + * Parses ART's PrettyDuration output and converts to milliseconds. Handles "s", "ms", "us", "ns" + * suffixes and the bare "0" special case. + * + * @see ART + * PrettyDuration / FormatDuration + */ + private static @Nullable Double parseTimeMs(final @NotNull String timeString) { + final String trimmed = timeString.trim(); + try { + if (trimmed.equals("0")) { + return 0.0; + } + // Double.parseDouble is locale-independent (always uses '.' as decimal separator), + // which matches the ART runtime output format. + if (trimmed.endsWith("ms")) { + return Double.parseDouble(trimmed.substring(0, trimmed.length() - 2)); + } else if (trimmed.endsWith("ns")) { + return Double.parseDouble(trimmed.substring(0, trimmed.length() - 2)) / 1_000_000.0; + } else if (trimmed.endsWith("us")) { + return Double.parseDouble(trimmed.substring(0, trimmed.length() - 2)) / 1_000.0; + } else if (trimmed.endsWith("s")) { + return Double.parseDouble(trimmed.substring(0, trimmed.length() - 1)) * 1_000.0; + } + } catch (NumberFormatException e) { + return null; + } + return null; + } + + private static @Nullable Long parseLongOrNull(final @NotNull String value) { + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java index 922828c2075..08432d2f41d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java @@ -22,14 +22,12 @@ import io.sentry.SentryLockReason; import io.sentry.SentryOptions; import io.sentry.SentryStackTraceFactory; +import io.sentry.android.core.internal.util.NativeEventUtils; +import io.sentry.protocol.ArtContext; import io.sentry.protocol.DebugImage; import io.sentry.protocol.SentryStackFrame; import io.sentry.protocol.SentryStackTrace; import io.sentry.protocol.SentryThread; -import java.math.BigInteger; -import java.nio.BufferUnderflowException; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -47,6 +45,12 @@ public class ThreadDumpParser { private static final Pattern BEGIN_UNMANAGED_NATIVE_THREAD_RE = Pattern.compile("\"(.*)\" (.*) ?sysTid=(\\d+)"); + // e.g. "----- pid 12345 at 2024-01-01 10:00:00.000000000+0000 -----" + private static final Pattern PID_RE = Pattern.compile("----- pid (\\d+) at .*"); + + // e.g. " | sysTid=12345 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8" + private static final Pattern SYS_TID_RE = Pattern.compile("\\s*\\|\\s*sysTid=(\\d+).*"); + // For reference, see native_stack_dump.cc and tombstone_proto_to_text.cpp in Android sources // Groups // 0:entire regex @@ -106,12 +110,19 @@ public class ThreadDumpParser { private final boolean isBackground; + // the process id parsed from the thread dump header; on Linux/Android the main thread's kernel + // thread id (sysTid) always equals the process id, so we use it to reliably detect the main + // thread + private @Nullable Long processId; + private final @NotNull SentryStackTraceFactory stackTraceFactory; private final @NotNull Map debugImages; private final @NotNull List threads; + private final @NotNull ArtContextParser artContextParser = new ArtContextParser(); + public ThreadDumpParser(final @NotNull SentryOptions options, final boolean isBackground) { this.options = options; this.isBackground = isBackground; @@ -131,28 +142,15 @@ public List getThreads() { } @Nullable - private static String buildIdToDebugId(final @NotNull String buildId) { - try { - // Abuse BigInteger as a hex string parser. Extra byte needed to handle leading zeros. - final ByteBuffer buf = ByteBuffer.wrap(new BigInteger("10" + buildId, 16).toByteArray()); - buf.get(); - return String.format( - "%08x-%04x-%04x-%04x-%04x%08x", - buf.order(ByteOrder.LITTLE_ENDIAN).getInt(), - buf.getShort(), - buf.getShort(), - buf.order(ByteOrder.BIG_ENDIAN).getShort(), - buf.getShort(), - buf.getInt()); - } catch (NumberFormatException | BufferUnderflowException e) { - return null; - } + public ArtContext getArtContext() { + return artContextParser.getArtContext(); } public void parse(final @NotNull Lines lines) { final Matcher beginManagedThreadRe = BEGIN_MANAGED_THREAD_RE.matcher(""); final Matcher beginUnmanagedNativeThreadRe = BEGIN_UNMANAGED_NATIVE_THREAD_RE.matcher(""); + final Matcher pidRe = PID_RE.matcher(""); while (lines.hasNext()) { final Line line = lines.next(); @@ -170,8 +168,14 @@ public void parse(final @NotNull Lines lines) { if (thread != null) { threads.add(thread); } + } else if (matches(pidRe, text)) { + processId = getLong(pidRe, 1, null); + } else { + artContextParser.parseLine(text); } } + + markThreads(); } private SentryThread parseThread(final @NotNull Lines lines) { @@ -197,7 +201,11 @@ private SentryThread parseThread(final @NotNull Lines lines) { return null; } sentryThread.setId(tid); - sentryThread.setName(beginManagedThreadRe.group(1)); + final String name = beginManagedThreadRe.group(1); + sentryThread.setName(name); + if ("main".equals(name)) { + sentryThread.setMain(true); + } final String state = beginManagedThreadRe.group(5); // sanitizing thread that have more details after their actual state, e.g. // "Native (still starting up)" <- we just need "Native" here @@ -217,19 +225,18 @@ private SentryThread parseThread(final @NotNull Lines lines) { } sentryThread.setId(sysTid); sentryThread.setName(beginUnmanagedNativeThreadRe.group(1)); - } - - final String threadName = sentryThread.getName(); - if (threadName != null) { - final boolean isMain = threadName.equals("main"); - sentryThread.setMain(isMain); - // since it's an ANR, the crashed thread will always be main - sentryThread.setCrashed(isMain); - sentryThread.setCurrent(isMain && !isBackground); + if (sysTid.equals(processId)) { + sentryThread.setMain(true); + } } // thread stacktrace final SentryStackTrace stackTrace = parseStacktrace(lines, sentryThread); + final List frames = stackTrace.getFrames(); + if (frames == null || frames.isEmpty()) { + // skip threads without a stacktrace, they are not actionable + return null; + } sentryThread.setStacktrace(stackTrace); return sentryThread; } @@ -250,6 +257,7 @@ private SentryStackTrace parseStacktrace( final Matcher waitingToLockRe = WAITING_TO_LOCK_RE.matcher(""); final Matcher waitingToLockUnknownRe = WAITING_TO_LOCK_UNKNOWN_RE.matcher(""); final Matcher blankRe = BLANK_RE.matcher(""); + final Matcher sysTidRe = SYS_TID_RE.matcher(""); while (lines.hasNext()) { final Line line = lines.next(); @@ -258,7 +266,12 @@ private SentryStackTrace parseStacktrace( break; } final String text = line.text; - if (matches(javaRe, text)) { + if (matches(sysTidRe, text)) { + final Long sysTid = getLong(sysTidRe, 1, null); + if (sysTid != null && sysTid.equals(processId)) { + thread.setMain(true); + } + } else if (matches(javaRe, text)) { final SentryStackFrame frame = new SentryStackFrame(); final String packageName = javaRe.group(1); final String className = javaRe.group(2); @@ -279,7 +292,7 @@ private SentryStackTrace parseStacktrace( frame.setPlatform("native"); final String buildId = nativeRe.group(8); - final String debugId = buildId == null ? null : buildIdToDebugId(buildId); + final String debugId = buildId == null ? null : NativeEventUtils.buildIdToDebugId(buildId); if (debugId != null) { if (!debugImages.containsKey(debugId)) { final DebugImage debugImage = new DebugImage(); @@ -377,6 +390,24 @@ private SentryStackTrace parseStacktrace( return stackTrace; } + private void markThreads() { + for (final @NotNull SentryThread thread : threads) { + if (Boolean.TRUE.equals(thread.isMain())) { + // the OS may have renamed the main thread to the (truncated) process name; normalize it + // back to "main" so downstream consumers see a consistent name + thread.setName("main"); + + // since it's an ANR, the crashed thread will always be main + thread.setCrashed(true); + thread.setCurrent(!isBackground); + } else { + thread.setCrashed(false); + thread.setCurrent(false); + thread.setMain(false); + } + } + } + private boolean matches(final @NotNull Matcher matcher, final @NotNull String text) { matcher.reset(text); return matcher.matches(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/NativeExceptionMechanism.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/NativeExceptionMechanism.java new file mode 100644 index 00000000000..2dad712b193 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/NativeExceptionMechanism.java @@ -0,0 +1,21 @@ +package io.sentry.android.core.internal.tombstone; + +import androidx.annotation.NonNull; + +/** Mechanism types for native crashes. */ +public enum NativeExceptionMechanism { + TOMBSTONE("Tombstone"), + SIGNAL_HANDLER("signalhandler"), + TOMBSTONE_MERGED("TombstoneMerged"); + + private final @NonNull String value; + + NativeExceptionMechanism(@NonNull final String value) { + this.value = value; + } + + @NonNull + public String getValue() { + return value; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java new file mode 100644 index 00000000000..c3966615899 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java @@ -0,0 +1,365 @@ +package io.sentry.android.core.internal.tombstone; + +import androidx.annotation.NonNull; +import com.abovevacant.epitaph.core.BacktraceFrame; +import com.abovevacant.epitaph.core.MemoryMapping; +import com.abovevacant.epitaph.core.Register; +import com.abovevacant.epitaph.core.Signal; +import com.abovevacant.epitaph.core.Tombstone; +import com.abovevacant.epitaph.core.TombstoneThread; +import com.abovevacant.epitaph.wire.TombstoneDecoder; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryStackTraceFactory; +import io.sentry.android.core.internal.util.NativeEventUtils; +import io.sentry.protocol.DebugImage; +import io.sentry.protocol.DebugMeta; +import io.sentry.protocol.Mechanism; +import io.sentry.protocol.Message; +import io.sentry.protocol.SentryException; +import io.sentry.protocol.SentryStackFrame; +import io.sentry.protocol.SentryStackTrace; +import io.sentry.protocol.SentryThread; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class TombstoneParser implements Closeable { + + @Nullable private final InputStream tombstoneStream; + @NotNull private final List inAppIncludes; + @NotNull private final List inAppExcludes; + @Nullable private final String nativeLibraryDir; + private final Map excTypeValueMap = new HashMap<>(); + + private static String formatHex(long value) { + return String.format("0x%x", value); + } + + public TombstoneParser( + @NotNull List inAppIncludes, + @NotNull List inAppExcludes, + @Nullable String nativeLibraryDir) { + this(null, inAppIncludes, inAppExcludes, nativeLibraryDir); + } + + public TombstoneParser( + @Nullable final InputStream tombstoneStream, + @NotNull List inAppIncludes, + @NotNull List inAppExcludes, + @Nullable String nativeLibraryDir) { + this.tombstoneStream = tombstoneStream; + this.inAppIncludes = inAppIncludes; + this.inAppExcludes = inAppExcludes; + this.nativeLibraryDir = nativeLibraryDir; + + // keep the current signal type -> value mapping for compatibility + excTypeValueMap.put("SIGILL", "IllegalInstruction"); + excTypeValueMap.put("SIGTRAP", "Trap"); + excTypeValueMap.put("SIGABRT", "Abort"); + excTypeValueMap.put("SIGBUS", "BusError"); + excTypeValueMap.put("SIGFPE", "FloatingPointException"); + excTypeValueMap.put("SIGSEGV", "Segfault"); + } + + @NonNull + public SentryEvent parse() throws IOException { + if (tombstoneStream == null) { + throw new IOException("No InputStream provided; use parse(Tombstone) instead."); + } + return parse(TombstoneDecoder.decode(tombstoneStream)); + } + + @NonNull + public SentryEvent parse(@NonNull final Tombstone tombstone) { + final SentryEvent event = new SentryEvent(); + event.setLevel(SentryLevel.FATAL); + + // must use the "native" platform because otherwise the stack-trace wouldn't be correctly parsed + event.setPlatform("native"); + + event.setMessage(constructMessage(tombstone)); + event.setDebugMeta(createDebugMeta(tombstone)); + event.setExceptions(createException(tombstone)); + event.setThreads( + createThreads(tombstone, Objects.requireNonNull(event.getExceptions()).get(0))); + + return event; + } + + @NonNull + private List createThreads( + @NonNull final Tombstone tombstone, @NonNull final SentryException exc) { + final List threads = new ArrayList<>(); + for (Map.Entry threadEntry : tombstone.threads.entrySet()) { + final TombstoneThread threadEntryValue = threadEntry.getValue(); + + final SentryThread thread = new SentryThread(); + thread.setId(Long.valueOf(threadEntry.getKey())); + thread.setName(threadEntryValue.name); + + final SentryStackTrace stacktrace = createStackTrace(threadEntryValue); + thread.setStacktrace(stacktrace); + if (tombstone.tid == threadEntryValue.id) { + thread.setCrashed(true); + // even though we refer to the thread_id from the exception, + // the backend currently requires a stack-trace in exception + exc.setStacktrace(stacktrace); + } + + // thread id always equals the process id, + // so we use it to reliably detect the main thread + if (tombstone.pid == threadEntryValue.id) { + // the OS may provide a (truncated) process name; normalize it + // back to "main" so downstream consumers see a consistent name + thread.setName("main"); + thread.setMain(true); + } + threads.add(thread); + } + + return threads; + } + + @NonNull + private SentryStackTrace createStackTrace(@NonNull final TombstoneThread thread) { + final List frames = new ArrayList<>(); + + for (BacktraceFrame frame : thread.backtrace) { + if (frame.fileName.endsWith("libart.so")) { + // We ignore all ART frames for time being because they aren't actionable for app developers + continue; + } + if (frame.fileName.startsWith(" registers = new HashMap<>(); + for (Register register : thread.registers) { + registers.put(register.name, formatHex(register.value)); + } + stacktrace.setRegisters(registers); + + return stacktrace; + } + + @NonNull + private List createException(@NonNull Tombstone tombstone) { + final SentryException exception = new SentryException(); + + if (tombstone.hasSignal()) { + final Signal signalInfo = tombstone.signal; + exception.setType(signalInfo.name); + exception.setValue(excTypeValueMap.get(signalInfo.name)); + exception.setMechanism(createMechanismFromSignalInfo(signalInfo)); + } + + exception.setThreadId((long) tombstone.tid); + final List exceptions = new ArrayList<>(1); + exceptions.add(exception); + + return exceptions; + } + + @NonNull + private static Mechanism createMechanismFromSignalInfo(@NonNull final Signal signalInfo) { + + final Mechanism mechanism = new Mechanism(); + mechanism.setType(NativeExceptionMechanism.TOMBSTONE.getValue()); + mechanism.setHandled(false); + mechanism.setSynthetic(true); + + final Map meta = new HashMap<>(); + meta.put("number", signalInfo.number); + meta.put("name", signalInfo.name); + meta.put("code", signalInfo.code); + meta.put("code_name", signalInfo.codeName); + mechanism.setMeta(meta); + + return mechanism; + } + + @NonNull + private Message constructMessage(@NonNull final Tombstone tombstone) { + final Message message = new Message(); + final Signal signalInfo = tombstone.signal; + + // reproduce the message `debuggerd` would use to dump the stack trace in logcat + String command = String.join(" ", tombstone.commandLine); + if (tombstone.hasSignal()) { + String abortMessage = tombstone.abortMessage; + message.setFormatted( + String.format( + Locale.ROOT, + "%sFatal signal %s (%d), %s (%d), pid = %d (%s)", + !abortMessage.isEmpty() ? abortMessage + ": " : "", + signalInfo.name, + signalInfo.number, + signalInfo.codeName, + signalInfo.code, + tombstone.pid, + command)); + } else { + message.setFormatted( + String.format(Locale.ROOT, "Fatal exit pid = %d (%s)", tombstone.pid, command)); + } + + return message; + } + + /** + * Helper class to accumulate memory mappings into a single module. Modules in the Sentry sense + * are the entire readable memory map for a file, not just the executable segment. This is + * important to maintain the file-offset contract of map entries, which is necessary to resolve + * runtime instruction addresses in the files uploaded for symbolication. + */ + private static class ModuleAccumulator { + String mappingName; + String buildId; + long beginAddress; + long endAddress; + + ModuleAccumulator(MemoryMapping mapping) { + this.mappingName = mapping.mappingName; + this.buildId = mapping.buildId; + this.beginAddress = mapping.beginAddress; + this.endAddress = mapping.endAddress; + } + + void extendTo(long newEndAddress) { + this.endAddress = newEndAddress; + } + + DebugImage toDebugImage() { + if (buildId.isEmpty()) { + return null; + } + final DebugImage image = new DebugImage(); + image.setCodeId(buildId); + image.setCodeFile(mappingName); + + final String debugId = NativeEventUtils.buildIdToDebugId(buildId); + image.setDebugId(debugId != null ? debugId : buildId); + + image.setImageAddr(formatHex(beginAddress)); + image.setImageSize(endAddress - beginAddress); + image.setType("elf"); + + return image; + } + } + + private DebugMeta createDebugMeta(@NonNull final Tombstone tombstone) { + final List images = new ArrayList<>(); + + // Coalesce memory mappings into modules similar to how sentry-native does it. + // A module consists of all readable mappings for the same file, starting from + // the first mapping that has a valid ELF header (indicated by offset 0 with build_id). + // In sentry-native, is_valid_elf_header() reads the ELF magic bytes from memory, + // which is only present at the start of the file (offset 0). We use offset == 0 + // combined with non-empty build_id as a proxy for this check. + ModuleAccumulator currentModule = null; + + for (MemoryMapping mapping : tombstone.memoryMappings) { + // Skip mappings that are not readable + if (!mapping.read) { + continue; + } + + // Skip mappings with empty name or in /dev/ + final String mappingName = mapping.mappingName; + if (mappingName.isEmpty() || mappingName.startsWith("/dev/")) { + continue; + } + + final boolean hasBuildId = !mapping.buildId.isEmpty(); + final boolean isFileStart = mapping.offset == 0; + + if (hasBuildId && isFileStart) { + // Check for duplicated mappings: On Android, the same ELF can have multiple + // mappings at offset 0 with different permissions (r--p, r-xp, r--p). + // If it's the same file as the current module, just extend it. + if (currentModule != null && mappingName.equals(currentModule.mappingName)) { + currentModule.extendTo(mapping.endAddress); + continue; + } + + // Flush the previous module (different file) + if (currentModule != null) { + final DebugImage image = currentModule.toDebugImage(); + if (image != null) { + images.add(image); + } + } + + // Start a new module + currentModule = new ModuleAccumulator(mapping); + } else if (currentModule != null && mappingName.equals(currentModule.mappingName)) { + // Extend the current module with this mapping (same file, continuation) + currentModule.extendTo(mapping.endAddress); + } + } + + // Flush the last module + if (currentModule != null) { + final DebugImage image = currentModule.toDebugImage(); + if (image != null) { + images.add(image); + } + } + + final DebugMeta debugMeta = new DebugMeta(); + debugMeta.setImages(images); + + return debugMeta; + } + + @Override + public void close() throws IOException { + if (tombstoneStream != null) { + tombstoneStream.close(); + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java index 4e657c9c64c..3f05beeceb2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java @@ -8,6 +8,7 @@ import android.net.Network; import android.net.NetworkCapabilities; import android.os.Build; +import android.os.Handler; import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import io.sentry.IConnectionStatusProvider; @@ -42,6 +43,7 @@ public final class AndroidConnectionStatusProvider private final @NotNull BuildInfoProvider buildInfoProvider; private final @NotNull ICurrentDateProvider timeProvider; private final @NotNull List connectionStatusObservers; + private final @Nullable Handler handler; private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); private volatile @Nullable NetworkCallback networkCallback; @@ -68,16 +70,26 @@ public final class AndroidConnectionStatusProvider private static final long CACHE_TTL_MS = 2 * 60 * 1000L; // 2 minutes private final @NotNull AtomicBoolean isConnected = new AtomicBoolean(false); - @SuppressLint("InlinedApi") public AndroidConnectionStatusProvider( @NotNull Context context, @NotNull SentryOptions options, @NotNull BuildInfoProvider buildInfoProvider, @NotNull ICurrentDateProvider timeProvider) { + this(context, options, buildInfoProvider, timeProvider, null); + } + + @SuppressLint("InlinedApi") + public AndroidConnectionStatusProvider( + @NotNull Context context, + @NotNull SentryOptions options, + @NotNull BuildInfoProvider buildInfoProvider, + @NotNull ICurrentDateProvider timeProvider, + @Nullable Handler handler) { this.context = ContextUtils.getApplicationContext(context); this.options = options; this.buildInfoProvider = buildInfoProvider; this.timeProvider = timeProvider; + this.handler = handler; this.connectionStatusObservers = new ArrayList<>(); capabilities[0] = NetworkCapabilities.NET_CAPABILITY_INTERNET; @@ -326,7 +338,8 @@ private boolean hasSignificantTransportChanges( } }; - if (registerNetworkCallback(context, options.getLogger(), buildInfoProvider, callback)) { + if (registerNetworkCallback( + context, options.getLogger(), buildInfoProvider, handler, callback)) { networkCallback = callback; options.getLogger().log(SentryLevel.DEBUG, "Network callback registered successfully"); } else { @@ -744,6 +757,7 @@ static boolean registerNetworkCallback( final @NotNull Context context, final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider, + final @Nullable Handler handler, final @NotNull NetworkCallback networkCallback) { if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { logger.log(SentryLevel.DEBUG, "NetworkCallbacks need Android N+."); @@ -758,7 +772,11 @@ static boolean registerNetworkCallback( return false; } try { - connectivityManager.registerDefaultNetworkCallback(networkCallback); + if (handler != null) { + connectivityManager.registerDefaultNetworkCallback(networkCallback, handler); + } else { + connectivityManager.registerDefaultNetworkCallback(networkCallback); + } } catch (Throwable t) { logger.log(SentryLevel.WARNING, "registerDefaultNetworkCallback failed", t); return false; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidThreadChecker.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidThreadChecker.java index ccd4a92b276..7228c849cf7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidThreadChecker.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidThreadChecker.java @@ -1,5 +1,6 @@ package io.sentry.android.core.internal.util; +import android.os.Build; import android.os.Handler; import android.os.Looper; import android.os.Process; @@ -24,14 +25,32 @@ private AndroidThreadChecker() { new Handler(Looper.getMainLooper()).post(() -> mainThreadSystemId = Process.myTid()); } + /** + * Gets the thread ID in a way that's compatible across Android versions. + * + *

    Uses {@link Thread#threadId()} on Android 16 (API 36) and above, and falls back to {@link + * Thread#getId()} on older versions. + * + * @param thread the thread to get the ID for + * @return the thread ID + */ + @SuppressWarnings("deprecation") + public static long getThreadId(final @NotNull Thread thread) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + return thread.threadId(); + } else { + return thread.getId(); + } + } + @Override public boolean isMainThread(final long threadId) { - return Looper.getMainLooper().getThread().getId() == threadId; + return getThreadId(Looper.getMainLooper().getThread()) == threadId; } @Override public boolean isMainThread(final @NotNull Thread thread) { - return isMainThread(thread.getId()); + return isMainThread(getThreadId(thread)); } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CpuInfoUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CpuInfoUtils.java index 019db99fc7d..ae94f410dab 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CpuInfoUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CpuInfoUtils.java @@ -51,8 +51,6 @@ private CpuInfoUtils() {} if (!cpuDir.getName().matches("cpu[0-9]+")) continue; File cpuMaxFreqFile = new File(cpuDir, CPUINFO_MAX_FREQ_PATH); - if (!cpuMaxFreqFile.exists() || !cpuMaxFreqFile.canRead()) continue; - long khz; try { String content = FileUtils.readText(cpuMaxFreqFile); @@ -77,12 +75,16 @@ String getSystemCpuPath() { @TestOnly public void setCpuMaxFrequencies(List frequencies) { - cpuMaxFrequenciesMhz.clear(); - cpuMaxFrequenciesMhz.addAll(frequencies); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + cpuMaxFrequenciesMhz.clear(); + cpuMaxFrequenciesMhz.addAll(frequencies); + } } @TestOnly - final void clear() { - cpuMaxFrequenciesMhz.clear(); + public void clear() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + cpuMaxFrequenciesMhz.clear(); + } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java index f2612b4aa84..0629b7a4908 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java @@ -112,7 +112,14 @@ public void onDraw() { // OnDrawListeners cannot be removed within onDraw, so we remove it with a // GlobalLayoutListener view.getViewTreeObserver() - .addOnGlobalLayoutListener(() -> view.getViewTreeObserver().removeOnDrawListener(this)); + .addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + view.getViewTreeObserver().removeOnGlobalLayoutListener(this); + view.getViewTreeObserver().removeOnDrawListener(FirstDrawDoneListener.this); + } + }); mainThreadHandler.postAtFrontOfQueue(callback); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/NativeEventUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/NativeEventUtils.java new file mode 100644 index 00000000000..c5e766b3c44 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/NativeEventUtils.java @@ -0,0 +1,44 @@ +package io.sentry.android.core.internal.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class NativeEventUtils { + + public static byte[] readBytes(final @NotNull InputStream stream) throws IOException { + try (final ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + int nRead; + final byte[] data = new byte[1024]; + while ((nRead = stream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + } + return buffer.toByteArray(); + } + } + + @Nullable + public static String buildIdToDebugId(final @NotNull String buildId) { + try { + // Abuse BigInteger as a hex string parser. Extra byte needed to handle leading zeros. + final ByteBuffer buf = ByteBuffer.wrap(new BigInteger("10" + buildId, 16).toByteArray()); + buf.get(); + return String.format( + "%08x-%04x-%04x-%04x-%04x%08x", + buf.order(ByteOrder.LITTLE_ENDIAN).getInt(), + buf.getShort(), + buf.getShort(), + buf.order(ByteOrder.BIG_ENDIAN).getShort(), + buf.getShort(), + buf.getInt()); + } catch (NumberFormatException | BufferUnderflowException e) { + return null; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java index db2b12122a5..092caa040db 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java @@ -18,7 +18,7 @@ import java.io.ByteArrayOutputStream; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -97,9 +97,8 @@ public class ScreenshotUtils { } try { - // ARGB_8888 -> This configuration is very flexible and offers the best quality final Bitmap bitmap = - Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888); + Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.RGB_565); final @NotNull CountDownLatch latch = new CountDownLatch(1); @@ -110,21 +109,21 @@ public class ScreenshotUtils { thread.start(); boolean success = false; + final AtomicInteger copyResultCode = new AtomicInteger(-1); try { final Handler handler = new Handler(thread.getLooper()); - final AtomicBoolean copyResultSuccess = new AtomicBoolean(false); PixelCopy.request( window, bitmap, copyResult -> { - copyResultSuccess.set(copyResult == PixelCopy.SUCCESS); + copyResultCode.set(copyResult); latch.countDown(); }, handler); - success = - latch.await(CAPTURE_TIMEOUT_MS, TimeUnit.MILLISECONDS) && copyResultSuccess.get(); + final boolean completed = latch.await(CAPTURE_TIMEOUT_MS, TimeUnit.MILLISECONDS); + success = completed && copyResultCode.get() == PixelCopy.SUCCESS; } catch (Throwable e) { // ignored logger.log(SentryLevel.ERROR, "Taking screenshot using PixelCopy failed.", e); @@ -133,6 +132,10 @@ public class ScreenshotUtils { } if (!success) { + logger.log( + SentryLevel.WARNING, + "PixelCopy failed for screenshot capture (result=%d).", + copyResultCode.get()); return null; } } else { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java index 55342c0e4c0..2c0ae246558 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java @@ -14,17 +14,22 @@ import android.view.Window; import androidx.annotation.RequiresApi; import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SentryUUID; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; +import io.sentry.android.core.SentryFramesDelayResult; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.lang.ref.WeakReference; import java.lang.reflect.Field; +import java.util.Iterator; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentSkipListSet; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; @@ -35,12 +40,15 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLifecycleCallbacks { private static final long oneSecondInNanos = TimeUnit.SECONDS.toNanos(1); private static final long frozenFrameThresholdNanos = TimeUnit.MILLISECONDS.toNanos(700); + private static final int MAX_FRAMES_COUNT = 3600; + private static final long MAX_FRAME_AGE_NANOS = 5L * 60 * 1_000_000_000L; // 5 minutes private final @NotNull BuildInfoProvider buildInfoProvider; private final @NotNull Set trackedWindows = new CopyOnWriteArraySet<>(); private final @NotNull ILogger logger; - private @Nullable Handler handler; + private volatile @Nullable Handler handler; + private final @NotNull AutoClosableReentrantLock handlerLock = new AutoClosableReentrantLock(); private @Nullable WeakReference currentWindow; private final @NotNull Map listenerMap = new ConcurrentHashMap<>(); @@ -48,11 +56,15 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLi private final WindowFrameMetricsManager windowFrameMetricsManager; private @Nullable Window.OnFrameMetricsAvailableListener frameMetricsAvailableListener; - private @Nullable Choreographer choreographer; - private @Nullable Field choreographerLastFrameTimeField; + private volatile @Nullable Choreographer choreographer; + private volatile @Nullable Field choreographerLastFrameTimeField; private long lastFrameStartNanos = 0; private long lastFrameEndNanos = 0; + // frame buffer for getFramesDelay queries, sorted by frame end time + private final @NotNull ConcurrentSkipListSet delayedFrames = + new ConcurrentSkipListSet<>(); + @SuppressLint("NewApi") public SentryFrameMetricsCollector( final @NotNull Context context, @@ -79,7 +91,7 @@ public SentryFrameMetricsCollector( } @SuppressWarnings("deprecation") - @SuppressLint({"NewApi", "PrivateApi"}) + @SuppressLint({"NewApi", "PrivateApi", "DiscouragedPrivateApi"}) public SentryFrameMetricsCollector( final @NotNull Context context, final @NotNull ILogger logger, @@ -104,12 +116,8 @@ public SentryFrameMetricsCollector( } isAvailable = true; - HandlerThread handlerThread = - new HandlerThread("io.sentry.android.core.internal.util.SentryFrameMetricsCollector"); - handlerThread.setUncaughtExceptionHandler( - (thread, e) -> logger.log(SentryLevel.ERROR, "Error during frames measurements.", e)); - handlerThread.start(); - handler = new Handler(handlerThread.getLooper()); + // The frame metrics HandlerThread is started lazily on the first startCollection() call. + // Starting it here would block the main thread on HandlerThread.getLooper() during SDK init. // We have to register the lifecycle callback, even if no profile is started, otherwise when we // start a profile, we wouldn't have the current activity and couldn't get the frameMetrics. @@ -118,7 +126,8 @@ public SentryFrameMetricsCollector( // Most considerations regarding timestamps of frames are inspired from JankStats library: // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi24Impl.kt - // The Choreographer instance must be accessed on the main thread + // The Choreographer instance should be initialized asynchronously on the main thread to avoid + // reflection during SDK init. new Handler(Looper.getMainLooper()) .post( () -> { @@ -130,15 +139,19 @@ public SentryFrameMetricsCollector( "Error retrieving Choreographer instance. Slow and frozen frames will not be reported.", e); } + + // Let's get the last frame timestamp from the choreographer private field + try { + choreographerLastFrameTimeField = + Choreographer.class.getDeclaredField("mLastFrameTimeNanos"); + choreographerLastFrameTimeField.setAccessible(true); + } catch (NoSuchFieldException e) { + logger.log( + SentryLevel.ERROR, + "Unable to get the frame timestamp from the choreographer: ", + e); + } }); - // Let's get the last frame timestamp from the choreographer private field - try { - choreographerLastFrameTimeField = Choreographer.class.getDeclaredField("mLastFrameTimeNanos"); - choreographerLastFrameTimeField.setAccessible(true); - } catch (NoSuchFieldException e) { - logger.log( - SentryLevel.ERROR, "Unable to get the frame timestamp from the choreographer: ", e); - } frameMetricsAvailableListener = (window, frameMetrics, dropCountSinceLastInvocation) -> { @@ -157,7 +170,8 @@ public SentryFrameMetricsCollector( final long delayNanos = Math.max(0, cpuDuration - expectedFrameDuration); long startTime = getFrameStartTimestamp(frameMetrics); - // If we couldn't get the timestamp through reflection, we use current time + // If we couldn't get the timestamp through FrameMetrics or reflection, we use the current + // time. if (startTime < 0) { startTime = now - cpuDuration; } @@ -177,6 +191,16 @@ public SentryFrameMetricsCollector( isSlow(cpuDuration, (long) ((float) oneSecondInNanos / (refreshRate - 1.0f))); final boolean isFrozen = isSlow && isFrozen(cpuDuration); + final long frameStartTime = startTime; + + // store frames with delay for getFramesDelay queries + if (delayNanos > 0) { + pruneOldFrames(lastFrameEndNanos); + if (delayedFrames.size() < MAX_FRAMES_COUNT) { + delayedFrames.add(new DelayedFrame(frameStartTime, lastFrameEndNanos, delayNanos)); + } + } + for (FrameMetricsCollectorListener l : listenerMap.values()) { l.onFrameMetricCollected( startTime, @@ -199,8 +223,8 @@ public static boolean isSlow(long frameDuration, final long expectedFrameDuratio } /** - * Return the internal timestamp in the choreographer of the last frame start timestamp through - * reflection. On Android O the value is read from the frameMetrics itself. + * Return the frame start timestamp. On API 26+, this value is read directly from {@link + * FrameMetrics}; older APIs use the reflected Choreographer timestamp. */ @SuppressLint("NewApi") private long getFrameStartTimestamp(final @NotNull FrameMetrics frameMetrics) { @@ -262,12 +286,34 @@ public void onActivityDestroyed(@NotNull Activity activity) {} if (!isAvailable) { return null; } + ensureHandlerThreadStarted(); final String uid = SentryUUID.generateSentryId(); listenerMap.put(uid, listener); trackCurrentWindow(); return uid; } + /** + * Lazily starts the background HandlerThread used to receive frame metrics. Deferred out of the + * constructor because {@link HandlerThread#getLooper()} blocks the caller (the main thread during + * SDK init) until the thread is ready, and the handler is only needed once collection starts. + */ + private void ensureHandlerThreadStarted() { + if (handler != null) { + return; + } + try (final @NotNull ISentryLifecycleToken ignored = handlerLock.acquire()) { + if (handler == null) { + final HandlerThread handlerThread = + new HandlerThread("io.sentry.android.core.internal.util.SentryFrameMetricsCollector"); + handlerThread.setUncaughtExceptionHandler( + (thread, e) -> logger.log(SentryLevel.ERROR, "Error during frames measurements.", e)); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + } + } + } + public void stopCollection(final @Nullable String listenerId) { if (!isAvailable) { return; @@ -354,6 +400,89 @@ public long getLastKnownFrameStartTimeNanos() { return -1; } + /** + * Queries the frame delay for a given time range. + * + *

    This is useful for external consumers (e.g. React Native SDK) that need to query frame delay + * for an arbitrary time range without registering their own frame listener. + * + * @param startSystemNanos start of the time range in {@link System#nanoTime()} units + * @param endSystemNanos end of the time range in {@link System#nanoTime()} units + * @return a {@link SentryFramesDelayResult} with the delay in seconds and the number of frames + * contributing to delay, or a result with delaySeconds=-1 if incalculable + */ + public @NotNull SentryFramesDelayResult getFramesDelay( + final long startSystemNanos, final long endSystemNanos) { + if (!isAvailable) { + return new SentryFramesDelayResult(-1, 0); + } + + if (endSystemNanos <= startSystemNanos) { + return new SentryFramesDelayResult(-1, 0); + } + + long totalDelayNanos = 0; + int delayFrameCount = 0; + + if (!delayedFrames.isEmpty()) { + final Iterator iterator = + delayedFrames.tailSet(new DelayedFrame(startSystemNanos)).iterator(); + + while (iterator.hasNext()) { + final @NotNull DelayedFrame frame = iterator.next(); + + if (frame.startNanos >= endSystemNanos) { + break; + } + + // The delay portion of a frame is at the end: [frameEnd - delay, frameEnd] + final long delayStart = frame.endNanos - frame.delayNanos; + final long delayEnd = frame.endNanos; + + // Intersect the delay interval with the query range + final long overlapStart = Math.max(delayStart, startSystemNanos); + final long overlapEnd = Math.min(delayEnd, endSystemNanos); + + if (overlapEnd > overlapStart) { + totalDelayNanos += (overlapEnd - overlapStart); + delayFrameCount++; + } + } + } + + final double delaySeconds = totalDelayNanos / 1e9d; + return new SentryFramesDelayResult(delaySeconds, delayFrameCount); + } + + private void pruneOldFrames(final long currentNanos) { + final long cutoff = currentNanos - MAX_FRAME_AGE_NANOS; + delayedFrames.headSet(new DelayedFrame(cutoff)).clear(); + } + + private static class DelayedFrame implements Comparable { + final long startNanos; + final long endNanos; + final long delayNanos; + + /** Sentinel constructor for set range queries (tailSet/headSet). */ + DelayedFrame(final long timestampNanos) { + this(timestampNanos, timestampNanos, 0); + } + + DelayedFrame(final long startNanos, final long endNanos, final long delayNanos) { + this.startNanos = startNanos; + this.endNanos = endNanos; + this.delayNanos = delayNanos; + } + + @Override + public int compareTo(final @NotNull DelayedFrame o) { + int cmp = Long.compare(this.endNanos, o.endNanos); + if (cmp != 0) return cmp; + return Long.compare(this.startNanos, o.startNanos); + } + } + @ApiStatus.Internal public interface FrameMetricsCollectorListener { /** diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelper.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelper.java index fead459ba88..accb56db0dc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelper.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelper.java @@ -8,6 +8,7 @@ import io.sentry.SpanDataConvention; import io.sentry.SpanStatus; import io.sentry.android.core.AndroidDateUtils; +import io.sentry.android.core.internal.util.AndroidThreadChecker; import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -129,7 +130,9 @@ public void clear() { } private void setDefaultStartSpanData(final @NotNull ISpan span) { - span.setData(SpanDataConvention.THREAD_ID, Looper.getMainLooper().getThread().getId()); + span.setData( + SpanDataConvention.THREAD_ID, + AndroidThreadChecker.getThreadId(Looper.getMainLooper().getThread())); span.setData(SpanDataConvention.THREAD_NAME, "main"); span.setData(SpanDataConvention.CONTRIBUTES_TTID, true); span.setData(SpanDataConvention.CONTRIBUTES_TTFD, true); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index add5762fbd4..eb1a10dd646 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -1,25 +1,34 @@ package io.sentry.android.core.performance; import android.app.Activity; +import android.app.ActivityManager; import android.app.Application; +import android.app.ApplicationStartInfo; import android.content.ContentProvider; +import android.content.Context; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.os.SystemClock; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; import io.sentry.IContinuousProfiler; import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; import io.sentry.NoOpLogger; +import io.sentry.SentryDate; import io.sentry.TracesSamplingDecision; +import io.sentry.android.core.AppStartExtension; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.internal.util.FirstDrawDoneListener; +import io.sentry.protocol.SentryId; import io.sentry.util.AutoClosableReentrantLock; import java.util.ArrayList; import java.util.Collections; @@ -43,6 +52,10 @@ */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { + public interface HeadlessAppStartListener { + void onHeadlessAppStart(); + } + public enum AppStartType { UNKNOWN, COLD, @@ -56,7 +69,8 @@ public enum AppStartType { new AutoClosableReentrantLock(); private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; - private boolean appLaunchedInForeground; + private @Nullable volatile Boolean appLaunchedInForeground; + private volatile long firstIdle = -1; private final @NotNull TimeSpan appStartSpan; private final @NotNull TimeSpan sdkInitTimeSpan; @@ -67,9 +81,19 @@ public enum AppStartType { private @Nullable IContinuousProfiler appStartContinuousProfiler = null; private @Nullable TracesSamplingDecision appStartSamplingDecision = null; private boolean isCallbackRegistered = false; - private boolean shouldSendStartMeasurements = true; + private volatile boolean shouldSendStartMeasurements = true; private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); + private final AtomicBoolean headlessAppStartCheckPending = new AtomicBoolean(false); + private final AtomicBoolean headlessAppStartListenerInvoked = new AtomicBoolean(false); + private volatile @Nullable HeadlessAppStartListener headlessAppStartListener; + // Captures a headless app.start so a later ui.load can share its trace. + private @Nullable SentryId appStartTraceId; + private @Nullable String appStartSentryTraceHeader; + private @Nullable String appStartBaggageHeader; + private @Nullable SentryDate appStartEndTime; + private @Nullable ApplicationStartInfo cachedStartInfo; + private final @NotNull AppStartExtension appStartExtension = new AppStartExtension(this); public static @NotNull AppStartMetrics getInstance() { if (instance == null) { @@ -89,7 +113,6 @@ public AppStartMetrics() { applicationOnCreate = new TimeSpan(); contentProviderOnCreates = new HashMap<>(); activityLifecycles = new ArrayList<>(); - appLaunchedInForeground = ContextUtils.isForegroundImportance(); } /** @@ -139,8 +162,76 @@ public void setAppStartType(final @NotNull AppStartType appStartType) { return appStartType; } + /** + * The reason the OS started the process, mapped from {@link ApplicationStartInfo#getReason()}. + * Only available on API 35+ (when {@link #cachedStartInfo} was resolved); returns {@code null} + * otherwise or for an unmapped reason. + */ + public @Nullable String getAppStartReason() { + if (cachedStartInfo == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + return null; + } + switch (cachedStartInfo.getReason()) { + case ApplicationStartInfo.START_REASON_ALARM: + return "alarm"; + case ApplicationStartInfo.START_REASON_BACKUP: + return "backup"; + case ApplicationStartInfo.START_REASON_BOOT_COMPLETE: + return "boot_complete"; + case ApplicationStartInfo.START_REASON_BROADCAST: + return "broadcast"; + case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: + return "content_provider"; + case ApplicationStartInfo.START_REASON_JOB: + return "job"; + case ApplicationStartInfo.START_REASON_LAUNCHER: + return "launcher"; + case ApplicationStartInfo.START_REASON_LAUNCHER_RECENTS: + return "launcher_recents"; + case ApplicationStartInfo.START_REASON_PUSH: + return "push"; + case ApplicationStartInfo.START_REASON_SERVICE: + return "service"; + case ApplicationStartInfo.START_REASON_START_ACTIVITY: + return "start_activity"; + case ApplicationStartInfo.START_REASON_OTHER: + return "other"; + default: + return null; + } + } + + /** + * Whether {@link ApplicationStartInfo#getReason()} indicates the OS spawned the app process + * because of an intentional user interaction. + * + * @return true if the user actively launched the app, false if the app was launched in + * background, and null if unknown. + */ + @RequiresApi(api = Build.VERSION_CODES.VANILLA_ICE_CREAM) + private static @Nullable Boolean isForegroundStartReason(final int reason) { + switch (reason) { + case ApplicationStartInfo.START_REASON_LAUNCHER: + case ApplicationStartInfo.START_REASON_LAUNCHER_RECENTS: + case ApplicationStartInfo.START_REASON_START_ACTIVITY: + return true; + case ApplicationStartInfo.START_REASON_ALARM: + case ApplicationStartInfo.START_REASON_BACKUP: + case ApplicationStartInfo.START_REASON_BOOT_COMPLETE: + case ApplicationStartInfo.START_REASON_BROADCAST: + case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: + case ApplicationStartInfo.START_REASON_JOB: + case ApplicationStartInfo.START_REASON_PUSH: + case ApplicationStartInfo.START_REASON_SERVICE: + return false; + case ApplicationStartInfo.START_REASON_OTHER: + default: + return null; + } + } + public boolean isAppLaunchedInForeground() { - return appLaunchedInForeground; + return Boolean.TRUE.equals(appLaunchedInForeground); } @VisibleForTesting @@ -148,6 +239,48 @@ public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground = appLaunchedInForeground; } + public void setHeadlessAppStartListener(final @Nullable HeadlessAppStartListener listener) { + this.headlessAppStartListener = listener; + if (listener != null + && isCallbackRegistered + && activeActivitiesCounter.get() == 0 + && !firstDrawDone.get()) { + scheduleHeadlessAppStartCheckOnMain(); + } + } + + public @Nullable SentryId getAppStartTraceId() { + return appStartTraceId; + } + + public void setAppStartTraceId(final @Nullable SentryId traceId) { + this.appStartTraceId = traceId; + } + + public @Nullable String getAppStartSentryTraceHeader() { + return appStartSentryTraceHeader; + } + + public void setAppStartSentryTraceHeader(final @Nullable String appStartSentryTraceHeader) { + this.appStartSentryTraceHeader = appStartSentryTraceHeader; + } + + public @Nullable String getAppStartBaggageHeader() { + return appStartBaggageHeader; + } + + public void setAppStartBaggageHeader(final @Nullable String appStartBaggageHeader) { + this.appStartBaggageHeader = appStartBaggageHeader; + } + + public @Nullable SentryDate getAppStartEndTime() { + return appStartEndTime; + } + + public void setAppStartEndTime(final @Nullable SentryDate appStartEndTime) { + this.appStartEndTime = appStartEndTime; + } + /** * Provides all collected content provider onCreate time spans * @@ -173,16 +306,32 @@ public void onAppStartSpansSent() { shouldSendStartMeasurements = false; contentProviderOnCreates.clear(); activityLifecycles.clear(); + appStartExtension.clear(); + } + + public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) { + return shouldSendStartMeasurements && (ignoreForegroundCheck || isAppLaunchedInForeground()); } public boolean shouldSendStartMeasurements() { - return shouldSendStartMeasurements && appLaunchedInForeground; + return shouldSendStartMeasurements(false); } public long getClassLoadedUptimeMs() { return CLASS_LOADED_UPTIME_MS; } + /** + * Returns a valid app start time span, bypassing the foreground check. Tries appStartSpan first, + * falls back to sdkInitTimeSpan. Used for headless starts where appLaunchedInForeground is false. + */ + public @NotNull TimeSpan getAppStartTimeSpanForHeadless() { + if (appStartSpan.hasStarted() && appStartSpan.hasStopped()) { + return appStartSpan; + } + return sdkInitTimeSpan; + } + /** * @return the app start time span if it was started and perf-2 is enabled, falls back to the sdk * init time span otherwise @@ -191,7 +340,7 @@ public long getClassLoadedUptimeMs() { final @NotNull SentryAndroidOptions options) { // If the app start type was never determined or app wasn't launched in foreground, // the app start is considered invalid - if (appStartType != AppStartType.UNKNOWN && appLaunchedInForeground) { + if (appStartType != AppStartType.UNKNOWN && isAppLaunchedInForeground()) { if (options.isEnablePerformanceV2()) { // Only started when sdk version is >= N final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); @@ -212,6 +361,31 @@ public long getClassLoadedUptimeMs() { return new TimeSpan(); } + public @NotNull AppStartExtension getAppStartExtension() { + return appStartExtension; + } + + /** + * Whether the app start can still be extended: measurements haven't been sent yet, no activity + * has been created, and the first frame hasn't been drawn. The foreground check is ignored so + * headless app starts (broadcast/service) can also be extended. + */ + public boolean canExtendAppStart() { + return shouldSendStartMeasurements(true) + && activeActivitiesCounter.get() == 0 + && !firstDrawDone.get(); + } + + @TestOnly + void setFirstIdle(final long firstIdle) { + this.firstIdle = firstIdle; + } + + @TestOnly + long getFirstIdle() { + return firstIdle; + } + @TestOnly public void clear() { appStartType = AppStartType.UNKNOWN; @@ -229,11 +403,21 @@ public void clear() { } appStartContinuousProfiler = null; appStartSamplingDecision = null; - appLaunchedInForeground = false; + appLaunchedInForeground = null; isCallbackRegistered = false; shouldSendStartMeasurements = true; firstDrawDone.set(false); activeActivitiesCounter.set(0); + firstIdle = -1; + headlessAppStartCheckPending.set(false); + headlessAppStartListenerInvoked.set(false); + headlessAppStartListener = null; + appStartTraceId = null; + appStartSentryTraceHeader = null; + appStartBaggageHeader = null; + appStartEndTime = null; + cachedStartInfo = null; + appStartExtension.clear(); } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -268,6 +452,12 @@ public void setClassLoadedUptimeMs(final long classLoadedUptimeMs) { CLASS_LOADED_UPTIME_MS = classLoadedUptimeMs; } + @TestOnly + @ApiStatus.Internal + public void setCachedStartInfo(final @Nullable ApplicationStartInfo cachedStartInfo) { + this.cachedStartInfo = cachedStartInfo; + } + /** * Called by instrumentation * @@ -301,7 +491,8 @@ public static void onApplicationPostCreate(final @NotNull Application applicatio } /** - * Register a callback to check if an activity was started after the application was created + * Register a callback to check if an activity was started after the application was created. Must + * be called from the main thread. * * @param application The application object to register the callback to */ @@ -310,58 +501,196 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { return; } isCallbackRegistered = true; - appLaunchedInForeground = appLaunchedInForeground || ContextUtils.isForegroundImportance(); + appLaunchedInForeground = null; application.registerActivityLifecycleCallbacks(instance); - // We post on the main thread a task to post a check on the main thread. On Pixel devices - // (possibly others) the first task posted on the main thread is called before the - // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate - // callback is called before the application one. - new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain()); - } - - private void checkCreateTimeOnMain() { - new Handler(Looper.getMainLooper()) - .post( - () -> { - // if no activity has ever been created, app was launched in background - if (activeActivitiesCounter.get() == 0) { - appLaunchedInForeground = false; - - // we stop the app start profilers, as they are useless and likely to timeout - if (appStartProfiler != null && appStartProfiler.isRunning()) { - appStartProfiler.close(); - appStartProfiler = null; - } - if (appStartContinuousProfiler != null && appStartContinuousProfiler.isRunning()) { - appStartContinuousProfiler.close(true); - appStartContinuousProfiler = null; - } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + final @Nullable ActivityManager activityManager = + (ActivityManager) application.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager != null) { + try { + final List historicalProcessStartReasons = + activityManager.getHistoricalProcessStartReasons(1); + if (!historicalProcessStartReasons.isEmpty()) { + final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0); + cachedStartInfo = info; + if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) { + if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { + appStartType = AppStartType.COLD; + } else { + appStartType = AppStartType.WARM; } - }); + appLaunchedInForeground = isForegroundStartReason(info.getReason()); + } + } + } catch (RuntimeException ignored) { + // getHistoricalProcessStartReasons may throw different kinds of exceptions, namely: + // - SecurityException when called from an isolated process + // - IllegalArgumentException when called with a wrong userId + // - others + // See impl: + // https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java;l=10866-10893 + Log.w("AppStartMetrics", ignored); // no logger instance here, so we just Log + } + } + } + // Fallback, if no matching ApplicationStartInfo is available + if (appLaunchedInForeground == null) { + appLaunchedInForeground = ContextUtils.isForegroundImportance(); + } + + if (appStartType == AppStartType.UNKNOWN || headlessAppStartListener != null) { + scheduleHeadlessAppStartCheckOnMain(); + } + } + + private void scheduleHeadlessAppStartCheckOnMain() { + if (!headlessAppStartCheckPending.compareAndSet(false, true)) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Looper.getMainLooper() + .getQueue() + .addIdleHandler( + () -> { + firstIdle = SystemClock.uptimeMillis(); + headlessAppStartCheckPending.set(false); + handleHeadlessAppStartIfNeededOnMain(); + return false; + }); + } else { + final Handler handler = new Handler(Looper.getMainLooper()); + handler.post( + () -> { + firstIdle = SystemClock.uptimeMillis(); + handler.post( + () -> { + headlessAppStartCheckPending.set(false); + handleHeadlessAppStartIfNeededOnMain(); + }); + }); + } + } + + /** + * Checks whether startup reached an Activity after the main looper had a chance to create one. If + * not, handles the headless app start path. Must be called on the main thread. + */ + private void handleHeadlessAppStartIfNeededOnMain() { + if (activeActivitiesCounter.get() == 0) { + // SDK init happened after Application.onCreate (e.g. deferred/late init inside an Activity): + // we missed the Activity's onActivityCreated, but a foreground process means it was a real + // launch, not a headless start. Gated on the listener so only the standalone-app-start path + // (which is what could emit a headless transaction) is affected. + if (headlessAppStartListener != null && ContextUtils.isForegroundImportance()) { + return; + } + + appLaunchedInForeground = false; + + // Headless starts have no Activity signal for the pre-API 35 warm/cold heuristic. + // If ApplicationStartInfo did not resolve the type, classify the process start as cold. + if (appStartType == AppStartType.UNKNOWN) { + appStartType = AppStartType.COLD; + } + + // we stop the app start profilers, as they are useless and likely to timeout + if (appStartProfiler != null && appStartProfiler.isRunning()) { + appStartProfiler.close(); + appStartProfiler = null; + } + if (appStartContinuousProfiler != null && appStartContinuousProfiler.isRunning()) { + appStartContinuousProfiler.close(true); + appStartContinuousProfiler = null; + } + + final @Nullable HeadlessAppStartListener listener = headlessAppStartListener; + if (listener != null && headlessAppStartListenerInvoked.compareAndSet(false, true)) { + resolveHeadlessAppStartEndTime(); + listener.onHeadlessAppStart(); + } + } + } + + private void resolveHeadlessAppStartEndTime() { + // Priority 1: Gradle plugin instrumented onApplicationPostCreate + if (applicationOnCreate.hasStopped()) { + final long stopUptimeMs = + applicationOnCreate.getStartUptimeMs() + applicationOnCreate.getDurationMs(); + stopHeadlessAppStartAt(stopUptimeMs); + return; + } + + // Priority 2: API 35+ ApplicationStartInfo (cached from registerLifecycleCallbacks) + if (cachedStartInfo != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + try { + final @NotNull Map timestamps = cachedStartInfo.getStartupTimestamps(); + final @Nullable Long onCreateStartNanos = + timestamps.get(ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); + if (onCreateStartNanos != null) { + // The framework captures this timestamp with SystemClock.uptimeNanos() right *before* + // invoking Application.onCreate (see ActivityThread.handleBindApplication), so it marks + // the onCreate start, not its end. Without plugin instrumentation there is no onCreate + // end signal, so this is the best available lower bound for the app start end time. + // Same clock base as TimeSpan, so it can be used directly without re-anchoring. + final long onCreateStartUptimeMs = TimeUnit.NANOSECONDS.toMillis(onCreateStartNanos); + stopHeadlessAppStartAt(onCreateStartUptimeMs); + return; + } + } catch (Throwable ignored) { + // Best effort: never let optional startup timestamp enrichment break app startup. + } + } + + // Priority 3: Process init end time (CLASS_LOADED_UPTIME_MS) + stopHeadlessAppStartAt(CLASS_LOADED_UPTIME_MS); + } + + private void stopHeadlessAppStartAt(final long stopUptimeMs) { + if (appStartSpan.hasStarted()) { + if (appStartSpan.hasNotStopped()) { + appStartSpan.setStoppedAt(stopUptimeMs); + } + } else if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { + sdkInitTimeSpan.setStoppedAt(stopUptimeMs); + } } @Override public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { + final long activityCreatedUptimeMillis = SystemClock.uptimeMillis(); CurrentActivityHolder.getInstance().setActivity(activity); // the first activity determines the app start type if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) { final long nowUptimeMs = SystemClock.uptimeMillis(); - // If the app (process) was launched more than 1 minute ago, it's likely wrong + // If the app (process) was launched more than 1 minute ago, consider it a warm start. + // NOTE: meaningless in standalone app start mode, where a headless start is already its own + // standalone transaction and therefore cannot be re-classified as warm. final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); - if (!appLaunchedInForeground || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { + // An active extension explicitly keeps the launch alive: resetting the span here would make + // the extended vital measure from the activity while the eager app.start transaction stays + // anchored at process start. + if ((!isAppLaunchedInForeground() + || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) + && !appStartExtension.isActive()) { appStartType = AppStartType.WARM; - shouldSendStartMeasurements = true; appStartSpan.reset(); - appStartSpan.start(); - appStartSpan.setStartedAt(nowUptimeMs); - CLASS_LOADED_UPTIME_MS = nowUptimeMs; + appStartSpan.setStartedAt(activityCreatedUptimeMillis); + CLASS_LOADED_UPTIME_MS = activityCreatedUptimeMillis; contentProviderOnCreates.clear(); applicationOnCreate.reset(); - } else { - appStartType = savedInstanceState == null ? AppStartType.COLD : AppStartType.WARM; + } else if (appStartType == AppStartType.UNKNOWN) { + // pre API 35 handling + if (savedInstanceState != null) { + appStartType = AppStartType.WARM; + } else if (firstIdle != -1 && activityCreatedUptimeMillis > firstIdle) { + appStartType = AppStartType.WARM; + } else { + appStartType = AppStartType.COLD; + } } } appLaunchedInForeground = true; @@ -401,11 +730,16 @@ public void onActivityStopped(@NonNull Activity activity) { public void onActivityDestroyed(@NonNull Activity activity) { CurrentActivityHolder.getInstance().clearActivity(activity); - final int remainingActivities = activeActivitiesCounter.decrementAndGet(); + int remainingActivities = activeActivitiesCounter.decrementAndGet(); + if (remainingActivities < 0) { + activeActivitiesCounter.set(0); + remainingActivities = 0; + } // if the app is moving into background - // as the next Activity is considered like a new app start + // as the next onActivityCreated will treat it as a new warm app start if (remainingActivities == 0 && !activity.isChangingConfigurations()) { - appLaunchedInForeground = false; + appStartType = AppStartType.WARM; + appLaunchedInForeground = true; shouldSendStartMeasurements = true; firstDrawDone.set(false); } diff --git a/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml b/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml index e6f77b90a7f..370c37fa0e9 100644 --- a/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml +++ b/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml @@ -4,6 +4,7 @@ android:id="@+id/sentry_dialog_user_feedback_layout" android:layout_width="match_parent" android:layout_height="match_parent" + android:filterTouchesWhenObscured="true" tools:ignore="HardcodedText,RtlHardcoded" android:theme="?android:attr/dialogTheme" android:padding="24dp"> @@ -46,6 +47,7 @@ android:layout_height="wrap_content" android:hint="Your Name" android:inputType="textPersonName" + android:autofillHints="name" android:background="@drawable/sentry_edit_text_border" android:paddingHorizontal="8dp" android:layout_below="@id/sentry_dialog_user_feedback_txt_name" /> @@ -65,6 +67,7 @@ android:layout_height="wrap_content" android:hint="your.email@example.org" android:inputType="textEmailAddress" + android:autofillHints="emailAddress" android:background="@drawable/sentry_edit_text_border" android:paddingHorizontal="8dp" android:layout_below="@id/sentry_dialog_user_feedback_txt_email" /> @@ -84,6 +87,7 @@ android:layout_height="wrap_content" android:lines="6" android:inputType="textMultiLine" + android:importantForAutofill="no" android:gravity="top|left" android:hint="What's the bug? What did you expect?" android:background="@drawable/sentry_edit_text_border" diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt index c2c3d384c40..2f3b6791c5b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt @@ -58,8 +58,8 @@ class ANRWatchDogTest { } while (anr == null && waitCount++ < 100) assertNotNull(anr) - assertEquals(expectedState, anr!!.thread.state) - assertEquals(stacktrace.className, anr!!.stackTrace[0].className) + assertEquals(expectedState, anr.thread!!.state) + assertEquals(stacktrace.className, anr.stackTrace[0].className) } finally { sut.interrupt() es.shutdown() @@ -137,8 +137,8 @@ class ANRWatchDogTest { } while (anr == null && waitCount++ < 100) assertNotNull(anr) - assertEquals(expectedState, anr!!.thread.state) - assertEquals(stacktrace.className, anr!!.stackTrace[0].className) + assertEquals(expectedState, anr.thread!!.state) + assertEquals(stacktrace.className, anr.stackTrace[0].className) } finally { sut.interrupt() es.shutdown() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityFramesTrackerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityFramesTrackerTest.kt index 8bfeb1c726d..55af0d71b0e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityFramesTrackerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityFramesTrackerTest.kt @@ -5,8 +5,10 @@ import android.util.SparseIntArray import androidx.core.app.FrameMetricsAggregator import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger +import io.sentry.SentryOptions import io.sentry.protocol.MeasurementValue import io.sentry.protocol.SentryId +import io.sentry.util.LazyEvaluator import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -34,12 +36,14 @@ class ActivityFramesTrackerTest { options.isEnablePerformanceV2 = false } - fun getSut(mockAggregator: Boolean = true): ActivityFramesTracker = - if (mockAggregator) { - ActivityFramesTracker(loadClass, options, handler, aggregator) - } else { - ActivityFramesTracker(loadClass, options, handler) - } + fun getSut(isAndroidxAvailable: Boolean = true): ActivityFramesTracker { + whenever(loadClass.isClassAvailableLazy(any(), any())) + .thenReturn(LazyEvaluator { isAndroidxAvailable }) + whenever(loadClass.isClassAvailableLazy(any(), any())) + .thenReturn(LazyEvaluator { isAndroidxAvailable }) + + return ActivityFramesTracker(loadClass, options, handler, aggregator) + } } private val fixture = Fixture() @@ -340,7 +344,6 @@ class ActivityFramesTrackerTest { @Test fun `addActivity does not throw if no AndroidX`() { - whenever(fixture.loadClass.isClassAvailable(any(), any())).thenReturn(false) val sut = fixture.getSut(false) sut.addActivity(fixture.activity) @@ -348,7 +351,6 @@ class ActivityFramesTrackerTest { @Test fun `setMetrics does not throw if no AndroidX`() { - whenever(fixture.loadClass.isClassAvailable(any(), any())).thenReturn(false) val sut = fixture.getSut(false) sut.setMetrics(fixture.activity, fixture.sentryId) @@ -356,7 +358,6 @@ class ActivityFramesTrackerTest { @Test fun `addActivity and setMetrics combined do not throw if no AndroidX`() { - whenever(fixture.loadClass.isClassAvailable(any(), any())).thenReturn(false) val sut = fixture.getSut(false) sut.addActivity(fixture.activity) @@ -373,7 +374,6 @@ class ActivityFramesTrackerTest { @Test fun `stop does not throw if no AndroidX`() { - whenever(fixture.loadClass.isClassAvailable(any(), any())).thenReturn(false) val sut = fixture.getSut(false) sut.stop() @@ -390,9 +390,13 @@ class ActivityFramesTrackerTest { @Test fun `takeMetrics returns null if no AndroidX`() { - whenever(fixture.loadClass.isClassAvailable(any(), any())).thenReturn(false) val sut = fixture.getSut(false) + whenever(fixture.aggregator.metrics).thenReturn(emptyArray(), getArray()) + + sut.addActivity(fixture.activity) + sut.setMetrics(fixture.activity, fixture.sentryId) + assertNull(sut.takeMetrics(fixture.sentryId)) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 9e94d7b9905..c79d418efe5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -4,9 +4,11 @@ import android.app.Activity import android.app.ActivityManager import android.app.ActivityManager.RunningAppProcessInfo import android.app.Application +import android.app.ApplicationStartInfo import android.content.Context import android.os.Build import android.os.Bundle +import android.os.Handler import android.os.Looper import android.view.View import android.view.ViewTreeObserver @@ -21,22 +23,26 @@ import io.sentry.Scopes import io.sentry.Sentry import io.sentry.SentryDate import io.sentry.SentryDateProvider +import io.sentry.SentryExecutorService import io.sentry.SentryNanotimeDate +import io.sentry.SentryTraceHeader import io.sentry.SentryTracer import io.sentry.Span +import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.SpanStatus.OK import io.sentry.TraceContext +import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.TransactionFinishedCallback import io.sentry.TransactionOptions import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.core.performance.AppStartMetrics.AppStartType import io.sentry.protocol.MeasurementValue +import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource import io.sentry.test.DeferredExecutorService import io.sentry.test.getProperty -import java.util.Date import java.util.concurrent.Future import java.util.concurrent.TimeUnit import kotlin.test.AfterTest @@ -52,6 +58,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.ArgumentCaptor +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor @@ -64,6 +71,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager @@ -83,6 +91,9 @@ class ActivityLifecycleIntegrationTest { // start it var transaction: SentryTracer = mock() val buildInfo = mock() + val createdTransactions = mutableListOf() + val capturedContexts = mutableListOf() + val capturedOptions = mutableListOf() fun getSut( apiVersion: Int = Build.VERSION_CODES.Q, @@ -102,8 +113,13 @@ class ActivityLifecycleIntegrationTest { val contextCaptor = argumentCaptor() whenever(scopes.startTransaction(contextCaptor.capture(), optionCaptor.capture())) .thenAnswer { - val t = SentryTracer(contextCaptor.lastValue, scopes, optionCaptor.lastValue) + val context = contextCaptor.lastValue + val options = optionCaptor.lastValue + val t = SentryTracer(context, scopes, options) transaction = t + createdTransactions.add(t) + capturedContexts.add(context) + capturedOptions.add(options) return@thenAnswer t } whenever(buildInfo.sdkInfoVersion).thenReturn(apiVersion) @@ -225,6 +241,527 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + fun `Standalone app start transaction op is app start`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + verify(fixture.scopes, times(2)).startTransaction(any(), any()) + + val contexts = fixture.capturedContexts + val appStartContext = contexts.single { + it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("App Start", appStartContext.name) + assertEquals(TransactionNameSource.COMPONENT, appStartContext.transactionNameSource) + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("Activity", appStartTransaction.getData("app.vitals.start.screen")) + assertTrue(contexts.any { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP }) + assertFalse( + contexts.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) + fun `Standalone app start transaction carries app start reason when available`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + val startInfo = + mock().apply { + whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER) + } + AppStartMetrics.getInstance().setCachedStartInfo(startInfo) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("launcher", appStartTransaction.getData("app.vitals.start.reason")) + } + + @Test + fun `Standalone app start transaction has no app start reason when unavailable`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertNull(appStartTransaction.getData("app.vitals.start.reason")) + } + + @Test + fun `extendAppStart eagerly creates a standalone app start transaction with the extended span`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertTrue( + appStartTransaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_EXTENDED_OP + } + ) + assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) + assertNotNull(AppStartMetrics.getInstance().appStartExtension.extendedAppStartSpan) + } + + @Test + fun `extended app start continues the trace into ui load without a second app start transaction`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransactions = + fixture.createdTransactions.filter { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals(1, appStartTransactions.size) + assertEquals("Activity", appStartTransactions.single().getData("app.vitals.start.screen")) + val uiLoadTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.UI_LOAD_OP + } + assertEquals( + appStartTransactions.single().spanContext.traceId, + uiLoadTransaction.spanContext.traceId, + ) + } + + @Test + fun `extended app start trace is not reused by a later activity`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val firstActivity = mock() + sut.onActivityCreated(firstActivity, fixture.bundle) + val appStartTraceId = + fixture.createdTransactions + .single { it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP } + .spanContext + .traceId + + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + AppStartMetrics.getInstance().onAppStartSpansSent() + + val secondActivity = mock() + sut.onActivityPaused(firstActivity) + sut.onActivityCreated(secondActivity, fixture.bundle) + + assertNotEquals(appStartTraceId, fixture.createdTransactions.last().spanContext.traceId) + } + + @Test + fun `extended app start screen is not overwritten by a later activity`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val firstActivity = mock() + sut.onActivityCreated(firstActivity, fixture.bundle) + + sut.onActivityPaused(firstActivity) + sut.onActivityCreated(mock(), fixture.bundle) + + val appStart = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("Activity", appStart.getData("app.vitals.start.screen")) + } + + @Test + fun `extended standalone app start transaction stays open until finishExtendedAppStart`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + + appStartTransaction.finish(SpanStatus.OK) + assertFalse(appStartTransaction.isFinished) + + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + assertTrue(appStartTransaction.isFinished) + } + + @Test + fun `extended headless app start transaction stays open until finishExtendedAppStart`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + driveHeadlessAppStart() + + val transaction = fixture.createdTransactions.single() + assertTrue( + transaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_EXTENDED_OP + } + ) + assertFalse(transaction.isFinished) + + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + assertTrue(transaction.isFinished) + } + + @Test + fun `extended headless app start persists the app start end time`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + driveHeadlessAppStart() + + assertNotNull(AppStartMetrics.getInstance().getAppStartEndTime()) + } + + @Test + fun `finished eager extended app start persists the app start end time`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + assertNull(AppStartMetrics.getInstance().getAppStartEndTime()) + + AppStartMetrics.getInstance().appStartExtension.finishTransaction(SentryNanotimeDate()) + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + + assertNotNull(AppStartMetrics.getInstance().getAppStartEndTime()) + } + + @Test + fun `activity long after the eager extended app start finished starts a fresh trace`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + // the eager extension starts at launch and finishes before any activity exists + setAppStartTime(date = SentryNanotimeDate(1, 0)) + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + val appStartTraceId = fixture.capturedContexts.single().traceId + AppStartMetrics.getInstance() + .appStartExtension + .extendedAppStartSpan!! + .finish(SpanStatus.OK, SentryNanotimeDate(2, 0)) + AppStartMetrics.getInstance().appStartExtension.finishTransaction(SentryNanotimeDate(2, 0)) + + // the first activity opens more than a minute after the extension finished + setAppStartTime(date = SentryNanotimeDate(TimeUnit.MINUTES.toMillis(2), 0)) + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val uiLoadContext = + fixture.capturedContexts.last { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP } + // too far apart: the ui.load gets its own fresh trace, not the finished app.start one + assertNotEquals(appStartTraceId, uiLoadContext.traceId) + // stored continuation state is still consumed so nothing reuses it + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `extended headless app start does not create a duplicate when the extension already finished`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + AppStartMetrics.getInstance().onAppStartSpansSent() + val transactionsBefore = fixture.createdTransactions.size + + driveHeadlessAppStart() + + assertEquals(transactionsBefore, fixture.createdTransactions.size) + } + + @Test + fun `extendAppStart is a no-op when standalone tracing is disabled`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + assertFalse(AppStartMetrics.getInstance().appStartExtension.isActive) + assertNull(AppStartMetrics.getInstance().appStartExtension.extendedAppStartSpan) + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `extended app start transaction is owned by the extension and survives activity destroy`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) + + sut.onActivityDestroyed(activity) + assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) + fun `Headless standalone app start transaction carries app start reason when available`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + val startInfo = + mock().apply { + whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_BROADCAST) + } + AppStartMetrics.getInstance().setCachedStartInfo(startInfo) + + driveHeadlessAppStart() + + val transaction = fixture.createdTransactions.single() + assertEquals("broadcast", transaction.getData("app.vitals.start.reason")) + } + + @Test + fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.UNKNOWN) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.STANDALONE_APP_START_OP, + fixture.capturedContexts.single().operation, + ) + assertEquals("App Start", fixture.capturedContexts.single().name) + } + + @Test + fun `HeadlessAppStartListener is not registered when standalone flag is off`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `HeadlessAppStartListener is not registered when performance is disabled`() { + val sut = fixture.getSut { it.isEnableStandaloneAppStartTracing = true } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `close clears HeadlessAppStartListener`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + sut.close() + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `onHeadlessAppStart creates standalone App Start transaction and stashes trace id`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + assertEquals("auto.app.start", options.origin) + assertFalse(options.isBindToScope) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun `onHeadlessAppStart creates standalone App Start transaction on API 23`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessSdkInitAppStart() + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + fun `onHeadlessAppStart creates standalone App Start transaction when appStartType is WARM`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.WARM) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + } + + @Test + fun `onHeadlessAppStart does nothing when appStartTimeSpan is incomplete`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + AppStartMetrics.getInstance().appStartTimeSpan.reset() + AppStartMetrics.getInstance().sdkInitTimeSpan.reset() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + @Test fun `Activity transaction uses custom deadline timeout when autoTransactionDeadlineTimeoutMillis is set to positive value`() { val sut = fixture.getSut() @@ -383,6 +920,8 @@ class ActivityLifecycleIntegrationTest { it.idleTimeout = 100 } ) + // the transaction idle timeout is scheduled on the dedicated timer executor + fixture.options.timerExecutorService = SentryExecutorService() sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, fixture.bundle) @@ -528,6 +1067,27 @@ class ActivityLifecycleIntegrationTest { assertTrue(span.isFinished) } + @Test + fun `When Activity is destroyed, sets standalone appStartTransaction status to cancelled and finish it`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + sut.onActivityDestroyed(activity) + + val appStartTransaction = + fixture.createdTransactions[ + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP)] + assertEquals(SpanStatus.CANCELLED, appStartTransaction.status) + assertTrue(appStartTransaction.isFinished) + } + @Test fun `When Activity is destroyed, sets appStartSpan to null`() { val sut = fixture.getSut() @@ -713,7 +1273,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(false) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) fixture.options.dateProvider = SentryDateProvider { date } @@ -738,7 +1298,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(false) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) val activity = mock() @@ -761,8 +1321,8 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(false) - val date = SentryNanotimeDate(Date(1), 0) - val date2 = SentryNanotimeDate(Date(2), 2) + val date = SentryNanotimeDate(1, 0) + val date2 = SentryNanotimeDate(2, 2) setAppStartTime(date) val activity = mock() @@ -788,7 +1348,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut { it.tracesSampleRate = 1.0 } sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(true) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) val activity = mock() @@ -807,8 +1367,8 @@ class ActivityLifecycleIntegrationTest { sut.setFirstActivityCreated(false) // usually set by SentryPerformanceProvider - val date = SentryNanotimeDate(Date(1), 0) - val date2 = SentryNanotimeDate(Date(2), 2) + val date = SentryNanotimeDate(1, 0) + val date2 = SentryNanotimeDate(2, 2) val activity = mock() // Activity onCreate date will be used @@ -833,7 +1393,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually set by SentryPerformanceProvider - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) val activity = mock() @@ -857,7 +1417,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually set by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) val appStartMetrics = AppStartMetrics.getInstance() appStartMetrics.appStartType = AppStartType.WARM @@ -882,6 +1442,274 @@ class ActivityLifecycleIntegrationTest { assertNull(appStartSpan) } + @Test + fun `launcher activity emits ui load and standalone App Start sharing trace id`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + val firstFrameDate = SentryNanotimeDate(1499, 0) + fixture.options.dateProvider = SentryDateProvider { firstFrameDate } + setAppStartTime(SentryNanotimeDate(1, 0)) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(2, fixture.capturedContexts.size) + val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val uiLoadTransaction = fixture.createdTransactions[uiLoadIndex] + val appStartTransaction = fixture.createdTransactions[appStartIndex] + + assertEquals(uiLoadTransaction.spanContext.traceId, appStartTransaction.spanContext.traceId) + assertEquals("auto.app.start", fixture.capturedOptions[appStartIndex].origin) + assertEquals("auto.ui.activity", fixture.capturedOptions[uiLoadIndex].origin) + assertFalse(fixture.capturedOptions[appStartIndex].isBindToScope) + assertFalse( + uiLoadTransaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + + sut.onActivityPostCreated(activity, fixture.bundle) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + sut.onActivityPostStarted(activity) + + assertTrue(appStartTransaction.children.any { it.operation == "activity.load" }) + + sut.onActivityResumed(activity) + runFirstDraw(fixture.createView()) + + val ttidSpan = + uiLoadTransaction.children.single { it.operation == ActivityLifecycleIntegration.TTID_OP } + assertTrue(ttidSpan.isFinished) + assertTrue(appStartTransaction.isFinished) + assertEquals(ttidSpan.finishDate, appStartTransaction.finishDate) + assertEquals( + ttidSpan.measurements[MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY]!!.value, + AppStartMetrics.getInstance().appStartTimeSpan.durationMs, + ) + } + + @Test + fun `launcher activity attaches lifecycle spans before finishing stopped standalone App Start`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + val appStartEndDate = SentryNanotimeDate(499, 0) + setAppStartTime(SentryNanotimeDate(1, 0), appStartEndDate) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityCreated(activity, fixture.bundle) + + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val appStartTransaction = fixture.createdTransactions[appStartIndex] + assertFalse(appStartTransaction.isFinished) + + sut.onActivityPostCreated(activity, fixture.bundle) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + sut.onActivityPostStarted(activity) + + val activityLoadSpans = appStartTransaction.children.filter { it.operation == "activity.load" } + assertEquals(2, activityLoadSpans.size) + assertTrue(activityLoadSpans.all { it.isFinished }) + assertTrue(appStartTransaction.isFinished) + assertEquals(appStartEndDate.nanoTimestamp(), appStartTransaction.finishDate!!.nanoTimestamp()) + } + + @Test + fun `activity following a headless start reuses trace id and does not emit second standalone`() { + val storedTraceId = SentryId() + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + // headless start always stores the trace header alongside the trace id; the ui.load txn + // continues that trace via continueTrace, sharing the trace id. + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + assertEquals(storedTraceId, context.traceId) + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `activity within a minute of the headless start continues the same trace`() { + val storedTraceId = SentryId() + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + // headless start ended right before the activity opens + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(0, 0) + sut.register(fixture.scopes, fixture.options) + setAppStartTime(date = SentryNanotimeDate(1, 0)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + assertEquals(storedTraceId, context.traceId) + } + + @Test + fun `activity more than a minute after the headless start starts a fresh trace`() { + val storedTraceId = SentryId() + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + // headless start ended at epoch, but the activity opens more than a minute later + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(0, 0) + sut.register(fixture.scopes, fixture.options) + setAppStartTime(date = SentryNanotimeDate(TimeUnit.MINUTES.toMillis(2), 0)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + // too far apart: the ui.load gets its own fresh trace, not the stored one + assertNotEquals(storedTraceId, context.traceId) + // stored continuation state is still consumed so nothing reuses it + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `onHeadlessAppStart stores sentry-trace and baggage headers for continuation`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + + driveHeadlessAppStart() + + val transaction = fixture.createdTransactions.single() + val metrics = AppStartMetrics.getInstance() + val sentryTraceHeader = metrics.appStartSentryTraceHeader + val baggageHeader = metrics.appStartBaggageHeader + assertNotNull(sentryTraceHeader) + assertNotNull(baggageHeader) + // sentry-trace carries the standalone app.start trace id so a later ui.load txn can continue it + assertTrue(sentryTraceHeader.startsWith(transaction.spanContext.traceId.toString())) + } + + @Test + fun `launcher activity shares standalone App Start trace and sampleRand as a sibling`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + // the app-start sampling decision carries the sampleRand the whole trace should share + AppStartMetrics.getInstance() + .setAppStartSamplingDecision(TracesSamplingDecision(true, 1.0, 0.42)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(2, fixture.capturedContexts.size) + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) + // app.start is created first so it roots the trace; ui.load shares it + assertTrue(appStartIndex < uiLoadIndex) + + val appStartContext = fixture.capturedContexts[appStartIndex] + val uiLoadContext = fixture.capturedContexts[uiLoadIndex] + assertEquals(appStartContext.traceId, uiLoadContext.traceId) + // both share the same sampleRand + assertEquals(0.42, appStartContext.baggage?.sampleRand) + assertEquals(0.42, uiLoadContext.baggage?.sampleRand) + // siblings, not parent/child: ui.load has no parent span id + assertNull(uiLoadContext.parentSpanId) + } + + @Test + fun `activity following a headless start shares stored trace and sampleRand as a sibling and clears headers`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + // 1) a headless start emits the standalone app.start and stores its trace headers + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + driveHeadlessAppStart() + val appStartTransaction = fixture.createdTransactions.single() + + // 2) an activity opens and shares the stored trace instead of emitting a second standalone + setAppStartTime() + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val uiLoadContext = + fixture.capturedContexts.last { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP } + assertFalse( + fixture.capturedContexts.drop(1).any { + it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + ) + assertEquals(appStartTransaction.spanContext.traceId, uiLoadContext.traceId) + // siblings, not parent/child: ui.load has no parent span id + assertNull(uiLoadContext.parentSpanId) + + // stored continuation state is consumed + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + assertNull(AppStartMetrics.getInstance().appStartSentryTraceHeader) + assertNull(AppStartMetrics.getInstance().appStartBaggageHeader) + } + + @Test + fun `standalone flag off launcher activity emits single ui load with nested app start cold child`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.UI_LOAD_OP, + fixture.capturedContexts.single().operation, + ) + assertTrue( + fixture.createdTransactions.single().children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD + } + ) + } + @Test fun `When SentryPerformanceProvider is disabled, app start time span is still created`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) @@ -890,7 +1718,7 @@ class ActivityLifecycleIntegrationTest { // usually done by SentryPerformanceProvider, if disabled it's done by // SentryAndroid.init - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.WARM @@ -916,7 +1744,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.WARM AppStartMetrics.getInstance().sdkInitTimeSpan.setStoppedAt(1234) @@ -940,7 +1768,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.WARM @@ -975,7 +1803,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(true) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime() val activity = mock() @@ -1147,6 +1975,8 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true + // the timeout has to be really scheduled for cancelling it to be observable + fixture.options.executorService = DeferredExecutorService() sut.register(fixture.scopes, fixture.options) val activity = mock() val activity2 = mock() @@ -1489,14 +2319,14 @@ class ActivityLifecycleIntegrationTest { @Test fun `When sentry is initialized mid activity lifecycle, last paused time should be used in favor of app start time`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) - val now = SentryNanotimeDate(Date(1234), 456) + val now = SentryNanotimeDate(1234, 456) fixture.options.tracesSampleRate = 1.0 fixture.options.dateProvider = SentryDateProvider { now } sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(5678), 910) + val startDate = SentryNanotimeDate(5678, 910) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.COLD @@ -1520,7 +2350,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.scopes, fixture.options) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) assertTrue(sut.activitySpanHelpers.isEmpty()) @@ -1537,8 +2367,8 @@ class ActivityLifecycleIntegrationTest { fun `Creates activity lifecycle spans`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) - val startDate = SentryNanotimeDate(Date(2), 0) + val appStartDate = SentryNanotimeDate(1, 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -1577,7 +2407,7 @@ class ActivityLifecycleIntegrationTest { fun `Creates activity lifecycle spans even when no app start span is available`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val startDate = SentryNanotimeDate(Date(2), 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -1635,8 +2465,8 @@ class ActivityLifecycleIntegrationTest { fun `Creates activity lifecycle spans on API lower than 29`() { val sut = fixture.getSut(apiVersion = Build.VERSION_CODES.P) fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) - val startDate = SentryNanotimeDate(Date(2), 0) + val appStartDate = SentryNanotimeDate(1, 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -1688,8 +2518,8 @@ class ActivityLifecycleIntegrationTest { fun `Does not add activity lifecycle spans when firstActivityCreated is true`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) - val startDate = SentryNanotimeDate(Date(2), 0) + val appStartDate = SentryNanotimeDate(1, 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -1710,7 +2540,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is false and app start span has stopped, restart app start to current date`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) + val appStartDate = SentryNanotimeDate(1, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() setAppStartTime(appStartDate) @@ -1737,8 +2567,61 @@ class ActivityLifecycleIntegrationTest { shadowOf(Looper.getMainLooper()).idle() } + private fun driveHeadlessAppStart() { + // A headless start (broadcast/service) runs in a non-foreground-importance process. The + // foreground guard in AppStartMetrics suppresses the headless path for foreground processes + // (deferred init inside an Activity), so headless scenarios must simulate background + // importance. + mockStatic(ContextUtils::class.java).use { contextUtils -> + contextUtils.`when` { ContextUtils.isForegroundImportance() }.thenReturn(false) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + } + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + shadowOf(Looper.getMainLooper()).idle() + } + + private fun prepareHeadlessAppStart( + appStartType: AppStartType = AppStartType.COLD, + startUptimeMs: Long = 100, + endUptimeMs: Long = 200, + ) { + AppStartMetrics.getInstance().apply { + this.appStartType = appStartType + setClassLoadedUptimeMs(endUptimeMs) + appStartTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + sdkInitTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + } + } + + private fun prepareHeadlessSdkInitAppStart(startUptimeMs: Long = 100, endUptimeMs: Long = 200) { + AppStartMetrics.getInstance().apply { + appStartTimeSpan.reset() + sdkInitTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + setClassLoadedUptimeMs(endUptimeMs) + } + } + + private fun transactionIndexForOperation(operation: String): Int { + val index = fixture.capturedContexts.indexOfFirst { it.operation == operation } + assertTrue(index >= 0) + return index + } + private fun setAppStartTime( - date: SentryDate = SentryNanotimeDate(Date(1), 0), + date: SentryDate = SentryNanotimeDate(1, 0), stopDate: SentryDate? = null, ) { // set by SentryPerformanceProvider so forcing it here @@ -1762,3 +2645,5 @@ class ActivityLifecycleIntegrationTest { } } } + +private open class SecondAppStartActivity : Activity() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt index 60a5ab530fc..caaa30152a4 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt @@ -5,7 +5,6 @@ import android.os.Build import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.CompositePerformanceCollector -import io.sentry.DataCategory import io.sentry.IConnectionStatusProvider import io.sentry.ILogger import io.sentry.IScopes @@ -18,10 +17,8 @@ import io.sentry.TracesSampler import io.sentry.TransactionContext import io.sentry.android.core.internal.util.SentryFrameMetricsCollector import io.sentry.profilemeasurements.ProfileMeasurement -import io.sentry.protocol.SentryId import io.sentry.test.DeferredExecutorService import io.sentry.test.getProperty -import io.sentry.transport.RateLimiter import java.io.File import java.util.concurrent.Future import kotlin.test.AfterTest @@ -30,12 +27,10 @@ import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import org.junit.runner.RunWith -import org.mockito.Mockito import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.check @@ -51,6 +46,7 @@ import org.mockito.kotlin.whenever class AndroidContinuousProfilerTest { private lateinit var context: Context private val fixture = Fixture() + private lateinit var mocks: ProfilerMocks private class Fixture { private val mockDsn = "http://key@localhost/proj" @@ -98,7 +94,7 @@ class AndroidContinuousProfilerTest { options.logger, options.profilingTracesDirPath, options.profilingTracesHz, - options.executorService, + { options.executorService }, ) } } @@ -134,6 +130,7 @@ class AndroidContinuousProfilerTest { buildInfoProvider, loadClass, activityFramesTracker, + false, ) // Profiler doesn't start if the folder doesn't exists. // Usually it's generated when calling Sentry.init, but for tests we can create it manually. @@ -142,6 +139,8 @@ class AndroidContinuousProfilerTest { Sentry.setCurrentScopes(fixture.scopes) fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + mocks = + ProfilerMocks(fixture.executor, fixture.mockTracesSampler, fixture.mockLogger, fixture.scopes) } @AfterTest @@ -150,110 +149,148 @@ class AndroidContinuousProfilerTest { fixture.mockedSentry.close() } + // -- TODO: Could be shared with PerfettoContinuousProfiler with some refactoring -- + @Test - fun `isRunning reflects profiler status`() { - val profiler = fixture.getSut() + fun `profiler ignores profilesSampleRate`() { + val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertTrue(profiler.isRunning) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - fixture.executor.runAll() - assertFalse(profiler.isRunning) } @Test - fun `stopProfiler stops the profiler after chunk is finished`() { + fun `profiler starts performance collector on start`() { + val performanceCollector = mock() + fixture.options.compositePerformanceCollector = performanceCollector val profiler = fixture.getSut() + verify(performanceCollector, never()).start(any()) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We are scheduling the profiler to stop at the end of the chunk, so it should still be running + verify(performanceCollector).start(any()) + } + + @Test + fun `profiler stops performance collector on stop`() { + val performanceCollector = mock() + fixture.options.compositePerformanceCollector = performanceCollector + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + verify(performanceCollector, never()).stop(any()) profiler.stopProfiler(ProfileLifecycle.MANUAL) - assertTrue(profiler.isRunning) - assertNotEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertNotEquals(SentryId.EMPTY_ID, profiler.chunkId) - // We run the executor service to trigger the chunk finish, and the profiler shouldn't restart fixture.executor.runAll() - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) + verify(performanceCollector).stop(any()) } @Test - fun `profiler multiple starts are ignored in manual mode`() { + fun `profiler stops collecting frame metrics when it stops`() { val profiler = fixture.getSut() + val frameMetricsCollectorId = "id" + whenever(fixture.frameMetricsCollector.startCollection(any())) + .thenReturn(frameMetricsCollectorId) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - verify(fixture.mockLogger, never()) - .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockLogger).log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) - assertTrue(profiler.isRunning) - assertEquals(0, profiler.rootSpanCounter) + verify(fixture.frameMetricsCollector, never()).stopCollection(frameMetricsCollectorId) + profiler.stopProfiler(ProfileLifecycle.MANUAL) + fixture.executor.runAll() + verify(fixture.frameMetricsCollector).stopCollection(frameMetricsCollectorId) } @Test - fun `profiler multiple starts are accepted in trace mode`() { - val profiler = fixture.getSut() + fun `profiler sends chunk with measurements`() { + val performanceCollector = mock() + val collectionData = PerformanceCollectionData(10) - // rootSpanCounter is incremented when the profiler starts in trace mode - assertEquals(0, profiler.rootSpanCounter) - profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) - assertEquals(1, profiler.rootSpanCounter) - assertTrue(profiler.isRunning) - profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) - verify(fixture.mockLogger, never()) - .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) - assertTrue(profiler.isRunning) - assertEquals(2, profiler.rootSpanCounter) + collectionData.usedHeapMemory = 2 + collectionData.usedNativeMemory = 3 + collectionData.cpuUsagePercentage = 3.0 + whenever(performanceCollector.stop(any())).thenReturn(listOf(collectionData)) - // rootSpanCounter is decremented when the profiler stops in trace mode, and keeps running until - // rootSpanCounter is 0 - profiler.stopProfiler(ProfileLifecycle.TRACE) + fixture.options.compositePerformanceCollector = performanceCollector + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + profiler.stopProfiler(ProfileLifecycle.MANUAL) fixture.executor.runAll() - assertEquals(1, profiler.rootSpanCounter) - assertTrue(profiler.isRunning) - - // only when rootSpanCounter is 0 the profiler stops - profiler.stopProfiler(ProfileLifecycle.TRACE) fixture.executor.runAll() - assertEquals(0, profiler.rootSpanCounter) - assertFalse(profiler.isRunning) + verify(fixture.scopes) + .captureProfileChunk( + check { + assertContains(it.measurements, ProfileMeasurement.ID_CPU_USAGE) + assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_FOOTPRINT) + assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT) + } + ) } + // -- Shared tests (see ContinuousProfilerTestCases.kt) -- + @Test - fun `profiler logs a warning on start if not sampled`() { - val profiler = fixture.getSut() - whenever(fixture.mockTracesSampler.sampleSessionProfile(any())).thenReturn(false) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertFalse(profiler.isRunning) - verify(fixture.mockLogger) - .log(eq(SentryLevel.DEBUG), eq("Profiler was not started due to sampling decision.")) - } + fun `isRunning reflects profiler status`() = fixture.getSut().testIsRunningReflectsStatus(mocks) @Test - fun `profiler evaluates sessionSampleRate only the first time`() { - val profiler = fixture.getSut() - verify(fixture.mockTracesSampler, never()).sampleSessionProfile(any()) - // The first time the profiler is started, the sessionSampleRate is evaluated - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - // Then, the sessionSampleRate is not evaluated again - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - } + fun `stopProfiler stops the profiler after chunk is finished`() = + fixture.getSut().testStopProfilerStopsAfterChunkFinished(mocks) + + @Test + fun `profiler multiple starts are accepted in trace mode`() = + fixture.getSut().testMultipleStartsAcceptedInTraceMode(mocks) + + @Test + fun `profiler logs a warning on start if not sampled`() = + fixture.getSut().testLogsWarningIfNotSampled(mocks) + + @Test + fun `profiler evaluates sessionSampleRate only the first time`() = + fixture.getSut().testEvaluatesSessionSampleRateOnlyOnce(mocks) + + @Test + fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() = + fixture.getSut().testReevaluateSamplingOnNextStart(mocks) + + @Test + fun `profiler stops and restart for each chunk`() = + fixture.getSut().testStopsAndRestartsForEachChunk(mocks) + + @Test + fun `profiler sends chunk on each restart`() = fixture.getSut().testSendsChunkOnRestart(mocks) + + @Test fun `profiler sends another chunk on stop`() = fixture.getSut().testSendsChunkOnStop(mocks) + + @Test + fun `close without terminating stops all profiles after chunk is finished`() = + fixture.getSut().testCloseWithoutTerminatingStopsAfterChunk(mocks) + + @Test + fun `profiler does not send chunks after close`() = + fixture.getSut().testDoesNotSendChunksAfterClose(mocks) + + @Test fun `profiler stops when rate limited`() = fixture.getSut().testStopsWhenRateLimited(mocks) + + @Test + fun `profiler does not start when rate limited`() = + fixture.getSut().testDoesNotStartWhenRateLimited(mocks) @Test - fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() { + fun `profiler does not start when offline`() = + fixture + .getSut { + it.connectionStatusProvider = mock { provider -> + whenever(provider.connectionStatus) + .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) + } + } + .testDoesNotStartWhenOffline(mocks) + + // -- Legacy-specific tests (AndroidContinuousProfiler only) -- + + @Test + fun `profiler multiple starts are ignored in manual mode`() { val profiler = fixture.getSut() - verify(fixture.mockTracesSampler, never()).sampleSessionProfile(any()) - // The first time the profiler is started, the sessionSampleRate is evaluated profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - // When reevaluateSampling is called, the sessionSampleRate is not evaluated immediately - profiler.reevaluateSampling() - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - // Then, when the profiler starts again, the sessionSampleRate is reevaluated + assertTrue(profiler.isRunning) + verify(fixture.mockLogger, never()) + .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(2)).sampleSessionProfile(any()) + verify(fixture.mockLogger).log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) + assertTrue(profiler.isRunning) + assertEquals(0, profiler.rootSpanCounter) } @Test @@ -267,25 +304,14 @@ class AndroidContinuousProfilerTest { assertFalse(profiler.isRunning) } - @Test - fun `profiler ignores profilesSampleRate`() { - val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - } - @Test fun `profiler evaluates profilingTracesDirPath options only on first start`() { - // We create the profiler, and nothing goes wrong val profiler = fixture.getSut { it.cacheDirPath = null } verify(fixture.mockLogger, never()) .log( SentryLevel.WARNING, "Disabling profiling because no profiling traces dir path is defined in options.", ) - - // Regardless of how many times the profiler is started, the option is evaluated and logged only - // once profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) verify(fixture.mockLogger, times(1)) @@ -297,13 +323,9 @@ class AndroidContinuousProfilerTest { @Test fun `profiler evaluates profilingTracesHz options only on first start`() { - // We create the profiler, and nothing goes wrong val profiler = fixture.getSut { it.profilingTracesHz = 0 } verify(fixture.mockLogger, never()) .log(SentryLevel.WARNING, "Disabling profiling because trace rate is set to %d", 0) - - // Regardless of how many times the profiler is started, the option is evaluated and logged only - // once profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) verify(fixture.mockLogger, times(1)) @@ -337,47 +359,11 @@ class AndroidContinuousProfilerTest { profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.stopProfiler(ProfileLifecycle.MANUAL) fixture.executor.runAll() - // We assert that no trace files are written assertTrue(File(fixture.options.profilingTracesDirPath!!).list()!!.isEmpty()) verify(fixture.mockLogger) .log(eq(SentryLevel.ERROR), eq("Error while stopping profiling: "), any()) } - @Test - fun `profiler starts performance collector on start`() { - val performanceCollector = mock() - fixture.options.compositePerformanceCollector = performanceCollector - val profiler = fixture.getSut() - verify(performanceCollector, never()).start(any()) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(performanceCollector).start(any()) - } - - @Test - fun `profiler stops performance collector on stop`() { - val performanceCollector = mock() - fixture.options.compositePerformanceCollector = performanceCollector - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(performanceCollector, never()).stop(any()) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - fixture.executor.runAll() - verify(performanceCollector).stop(any()) - } - - @Test - fun `profiler stops collecting frame metrics when it stops`() { - val profiler = fixture.getSut() - val frameMetricsCollectorId = "id" - whenever(fixture.frameMetricsCollector.startCollection(any())) - .thenReturn(frameMetricsCollectorId) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.frameMetricsCollector, never()).stopCollection(frameMetricsCollectorId) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - fixture.executor.runAll() - verify(fixture.frameMetricsCollector).stopCollection(frameMetricsCollectorId) - } - @Test fun `profiler stops profiling and clear scheduled job on close`() { val profiler = fixture.getSut() @@ -387,7 +373,6 @@ class AndroidContinuousProfilerTest { profiler.close(true) assertFalse(profiler.isRunning) - // The timeout scheduled job should be cleared val androidProfiler = profiler.getProperty("profiler") val scheduledJob = androidProfiler?.getProperty?>("scheduledFinish") assertNull(scheduledJob) @@ -397,166 +382,8 @@ class AndroidContinuousProfilerTest { assertTrue(stopFuture.isCancelled || stopFuture.isDone) } - @Test - fun `profiler stops and restart for each chunk`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - val oldChunkId = profiler.chunkId - - fixture.executor.runAll() - verify(fixture.mockLogger) - .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) - assertTrue(profiler.isRunning) - - fixture.executor.runAll() - verify(fixture.mockLogger, times(2)) - .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) - assertTrue(profiler.isRunning) - assertNotEquals(oldChunkId, profiler.chunkId) - } - - @Test - fun `profiler sends chunk on each restart`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We run the executor service to trigger the profiler restart (chunk finish) - fixture.executor.runAll() - verify(fixture.scopes, never()).captureProfileChunk(any()) - // Now the executor is used to send the chunk - fixture.executor.runAll() - verify(fixture.scopes).captureProfileChunk(any()) - } - - @Test - fun `profiler sends chunk with measurements`() { - val performanceCollector = mock() - val collectionData = PerformanceCollectionData(10) - - collectionData.usedHeapMemory = 2 - collectionData.usedNativeMemory = 3 - collectionData.cpuUsagePercentage = 3.0 - whenever(performanceCollector.stop(any())).thenReturn(listOf(collectionData)) - - fixture.options.compositePerformanceCollector = performanceCollector - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - // We run the executor service to stop the profiler - fixture.executor.runAll() - // Then we run it again to send the profile chunk - fixture.executor.runAll() - verify(fixture.scopes) - .captureProfileChunk( - check { - assertContains(it.measurements, ProfileMeasurement.ID_CPU_USAGE) - assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_FOOTPRINT) - assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT) - } - ) - } - - @Test - fun `profiler sends another chunk on stop`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We run the executor service to trigger the profiler restart (chunk finish) - fixture.executor.runAll() - verify(fixture.scopes, never()).captureProfileChunk(any()) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - // We stop the profiler, which should send a chunk - fixture.executor.runAll() - verify(fixture.scopes).captureProfileChunk(any()) - } - - @Test - fun `close without terminating stops all profiles after chunk is finished`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We are scheduling the profiler to stop at the end of the chunk, so it should still be running - profiler.close(false) - assertTrue(profiler.isRunning) - // However, close() already resets the rootSpanCounter - assertEquals(0, profiler.rootSpanCounter) - - // We run the executor service to trigger the chunk finish, and the profiler shouldn't restart - fixture.executor.runAll() - assertFalse(profiler.isRunning) - } - - @Test - fun `profiler does not send chunks after close`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - - // We close the profiler, which should prevent sending additional chunks - profiler.close(true) - - // The executor used to send the chunk doesn't do anything - fixture.executor.runAll() - verify(fixture.scopes, never()).captureProfileChunk(any()) - } - - @Test - fun `profiler stops when rate limited`() { - val profiler = fixture.getSut() - val rateLimiter = mock() - whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) - - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - - // If the SDK is rate limited, the profiler should stop - profiler.onRateLimitChanged(rateLimiter) - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) - verify(fixture.mockLogger) - .log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) - } - - @Test - fun `profiler does not start when rate limited`() { - val profiler = fixture.getSut() - val rateLimiter = mock() - whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) - whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) - - // If the SDK is rate limited, the profiler should never start - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) - verify(fixture.mockLogger) - .log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) - } - - @Test - fun `profiler does not start when offline`() { - val profiler = - fixture.getSut { - it.connectionStatusProvider = mock { provider -> - whenever(provider.connectionStatus) - .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) - } - } - - // If the device is offline, the profiler should never start - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) - verify(fixture.mockLogger) - .log(eq(SentryLevel.WARNING), eq("Device is offline. Stopping profiler.")) - } - fun withMockScopes(closure: () -> Unit) = - Mockito.mockStatic(Sentry::class.java).use { + mockStatic(Sentry::class.java).use { it.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) closure.invoke() } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidCpuCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidCpuCollectorTest.kt index 1b855aabc6c..422eb1ca804 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidCpuCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidCpuCollectorTest.kt @@ -1,13 +1,11 @@ package io.sentry.android.core +import com.google.common.truth.Truth.assertThat import io.sentry.ILogger import io.sentry.PerformanceCollectionData import io.sentry.test.getCtor import kotlin.test.Test import kotlin.test.assertFailsWith -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull import org.mockito.kotlin.mock class AndroidCpuCollectorTest { @@ -30,7 +28,7 @@ class AndroidCpuCollectorTest { fun `collect works only after setup`() { val data = PerformanceCollectionData(10) fixture.getSut().collect(data) - assertNull(data.cpuUsagePercentage) + assertThat(data.hasCpuUsagePercentage()).isFalse() } @Test @@ -39,8 +37,7 @@ class AndroidCpuCollectorTest { val collector = fixture.getSut() collector.setup() collector.collect(data) - val cpuData = data.cpuUsagePercentage - assertNotNull(cpuData) - assertNotEquals(0.0, cpuData) + assertThat(data.hasCpuUsagePercentage()).isTrue() + assertThat(data.cpuUsagePercentage).isNotEqualTo(0.0) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorFactoryTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorFactoryTest.kt new file mode 100644 index 00000000000..33d66c2b9ad --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorFactoryTest.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 AndroidLoggerBatchProcessorFactoryTest { + + @Test + fun `create returns AndroidLoggerBatchProcessor instance`() { + val factory = AndroidLoggerBatchProcessorFactory() + 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/AndroidLoggerBatchProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorTest.kt new file mode 100644 index 00000000000..369f7f6a148 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorTest.kt @@ -0,0 +1,96 @@ +package io.sentry.android.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ISentryClient +import io.sentry.SentryLogEvent +import io.sentry.SentryLogLevel +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 AndroidLoggerBatchProcessorTest { + + private class Fixture { + val options = SentryAndroidOptions() + val client: ISentryClient = mock() + + fun getSut( + useImmediateExecutor: Boolean = false, + config: ((SentryOptions) -> Unit)? = null, + ): AndroidLoggerBatchProcessor { + if (useImmediateExecutor) { + options.executorService = ImmediateExecutorService() + } + config?.invoke(options) + return AndroidLoggerBatchProcessor(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 logEvent = SentryLogEvent(SentryId(), 1.0, "test", SentryLogLevel.INFO) + sut.add(logEvent) + + sut.onBackground() + + verify(fixture.client).captureBatchedLogEvents(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/AndroidMemoryCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMemoryCollectorTest.kt index 23214c040c8..4a7a621ede2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMemoryCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMemoryCollectorTest.kt @@ -1,11 +1,9 @@ package io.sentry.android.core import android.os.Debug +import com.google.common.truth.Truth.assertThat import io.sentry.PerformanceCollectionData import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull class AndroidMemoryCollectorTest { private val fixture = Fixture() @@ -21,10 +19,9 @@ class AndroidMemoryCollectorTest { val usedNativeMemory = Debug.getNativeHeapSize() - Debug.getNativeHeapFreeSize() val usedMemory = fixture.runtime.totalMemory() - fixture.runtime.freeMemory() fixture.collector.collect(data) - assertNotNull(data.usedHeapMemory) - assertNotNull(data.usedNativeMemory) - assertNotEquals(-1, data.usedNativeMemory) - assertEquals(usedNativeMemory, data.usedNativeMemory) - assertEquals(usedMemory, data.usedHeapMemory) + assertThat(data.hasUsedHeapMemory()).isTrue() + assertThat(data.hasUsedNativeMemory()).isTrue() + assertThat(data.usedNativeMemory).isEqualTo(usedNativeMemory) + assertThat(data.usedHeapMemory).isEqualTo(usedMemory) } } 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..7d85502d149 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt @@ -0,0 +1,95 @@ +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/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index d49a905772d..6df1ed7167e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -18,7 +18,7 @@ import io.sentry.MainEventProcessor import io.sentry.NoOpContinuousProfiler import io.sentry.NoOpTransactionProfiler import io.sentry.SentryOptions -import io.sentry.android.core.SentryAndroidOptions.AndroidUserFeedbackIDialogHandler +import io.sentry.android.core.SentryAndroidOptions.AndroidUserFeedbackFormHandler import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator @@ -116,6 +116,7 @@ class AndroidOptionsInitializerTest { if (useRealContext) context else mockContext, loadClass, activityFramesTracker, + false, ) } @@ -161,6 +162,7 @@ class AndroidOptionsInitializerTest { buildInfo, loadClass, activityFramesTracker, + isReplayAvailable, ) } @@ -256,9 +258,10 @@ class AndroidOptionsInitializerTest { } @Test - fun `AnrV2EventProcessor added to processors list`() { + fun `ApplicationExitInfoProcessor added to processors list`() { fixture.initSut() - val actual = fixture.sentryOptions.eventProcessors.firstOrNull { it is AnrV2EventProcessor } + val actual = + fixture.sentryOptions.eventProcessors.firstOrNull { it is ApplicationExitInfoEventProcessor } assertNotNull(actual) } @@ -373,6 +376,27 @@ class AndroidOptionsInitializerTest { assertTrue(fixture.sentryOptions.continuousProfiler is AndroidContinuousProfiler) } + @Config(sdk = [35]) + @Test + fun `init on API 35+ always sets PerfettoContinuousProfiler`() { + fixture.initSut() + assertTrue(fixture.sentryOptions.continuousProfiler is PerfettoContinuousProfiler) + } + + @Config(sdk = [34]) + @Test + fun `init below API 35 with enableLegacyProfiling true sets AndroidContinuousProfiler`() { + fixture.initSut(configureOptions = { isEnableLegacyProfiling = true }) + assertTrue(fixture.sentryOptions.continuousProfiler is AndroidContinuousProfiler) + } + + @Config(sdk = [34]) + @Test + fun `init below API 35 with enableLegacyProfiling false noops profiler`() { + fixture.initSut(configureOptions = { isEnableLegacyProfiling = false }) + assertTrue(fixture.sentryOptions.continuousProfiler is NoOpContinuousProfiler) + } + @Test fun `init with profilesSampleRate should set Android transaction profiler`() { fixture.initSut(configureOptions = { profilesSampleRate = 1.0 }) @@ -400,6 +424,51 @@ class AndroidOptionsInitializerTest { assertEquals(fixture.sentryOptions.continuousProfiler, NoOpContinuousProfiler.getInstance()) } + @Test + fun `init with profilesSampleRate and enableLegacyProfiling false noops both profilers`() { + fixture.initSut( + configureOptions = { + profilesSampleRate = 1.0 + isEnableLegacyProfiling = false + } + ) + + assertEquals(NoOpTransactionProfiler.getInstance(), fixture.sentryOptions.transactionProfiler) + assertEquals(NoOpContinuousProfiler.getInstance(), fixture.sentryOptions.continuousProfiler) + } + + @Test + fun `init with profilesSampler and enableLegacyProfiling false noops both profilers`() { + fixture.initSut( + configureOptions = { + profilesSampler = mock() + isEnableLegacyProfiling = false + } + ) + + assertEquals(NoOpTransactionProfiler.getInstance(), fixture.sentryOptions.transactionProfiler) + assertEquals(NoOpContinuousProfiler.getInstance(), fixture.sentryOptions.continuousProfiler) + } + + @Test + fun `init with profilesSampleRate and enableLegacyProfiling false closes app start profiler`() { + val appStartProfiler = mock() + AppStartMetrics.getInstance().appStartProfiler = appStartProfiler + fixture.initSut( + configureOptions = { + profilesSampleRate = 1.0 + isEnableLegacyProfiling = false + } + ) + + assertEquals(NoOpTransactionProfiler.getInstance(), fixture.sentryOptions.transactionProfiler) + verify(appStartProfiler).close() + + // AppStartMetrics should be cleared + assertNull(AppStartMetrics.getInstance().appStartProfiler) + assertNull(AppStartMetrics.getInstance().appStartContinuousProfiler) + } + @Test fun `init reuses transaction profiler of appStartMetrics, if exists`() { val appStartProfiler = mock() @@ -771,6 +840,15 @@ class AndroidOptionsInitializerTest { assertTrue { fixture.sentryOptions.socketTagger is AndroidSocketTagger } } + @Test + fun `AndroidLoggerBatchProcessorFactory is set to options`() { + fixture.initSut() + + assertTrue { + fixture.sentryOptions.logs.loggerBatchProcessorFactory is AndroidLoggerBatchProcessorFactory + } + } + @Test fun `does not install ComposeGestureTargetLocator, if sentry-compose is not available`() { fixture.initSutWithClassLoader() @@ -831,6 +909,21 @@ class AndroidOptionsInitializerTest { assertTrue { fixture.sentryOptions.optionsObservers.any { it is PersistingOptionsObserver } } } + @Test + fun `options cache generation observer is set when app update time is valid`() { + val buildInfo = mock() + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.LOLLIPOP) + ContextUtils.getPackageInfo(fixture.context, buildInfo)!!.lastUpdateTime = 1_000L + + fixture.initSut(useRealContext = true) + + assertTrue { + fixture.sentryOptions.optionsObservers.any { + it is PersistingOptionsCacheGenerationObserver + } + } + } + @Test fun `when cacheDir is not set, persisting observers are not set to options`() { fixture.initSut(configureOptions = { cacheDirPath = null }) @@ -870,9 +963,9 @@ class AndroidOptionsInitializerTest { } @Test - fun `AndroidUserFeedbackIDialogHandler is set as feedback dialog handler`() { + fun `AndroidUserFeedbackFormHandler is set as feedback form handler`() { fixture.initSut() - assertIs(fixture.sentryOptions.feedbackOptions.dialogHandler) + assertIs(fixture.sentryOptions.feedbackOptions.formHandler) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt index dce66ae2b5e..f402af24f80 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt @@ -12,6 +12,7 @@ import io.sentry.android.core.internal.util.SentryFrameMetricsCollector import io.sentry.profilemeasurements.ProfileMeasurement import io.sentry.test.getCtor import io.sentry.test.getProperty +import io.sentry.util.LazyEvaluator import java.io.File import java.util.concurrent.Callable import java.util.concurrent.Future @@ -44,7 +45,7 @@ class AndroidProfilerTest { String::class.java, Int::class.java, SentryFrameMetricsCollector::class.java, - ISentryExecutorService::class.java, + LazyEvaluator.Evaluator::class.java, ILogger::class.java, ) private val fixture = Fixture() @@ -80,8 +81,6 @@ class AndroidProfilerTest { override fun close(timeoutMillis: Long) {} override fun isClosed() = false - - override fun prewarm() = Unit } val options = @@ -100,7 +99,7 @@ class AndroidProfilerTest { options.profilingTracesDirPath!!, interval, frameMetricsCollector, - options.executorService, + { options.executorService }, options.logger, ) } @@ -136,6 +135,7 @@ class AndroidProfilerTest { buildInfoProvider, loadClass, activityFramesTracker, + false, ) // Profiler doesn't start if the folder doesn't exists. // Usually it's generated when calling Sentry.init, but for tests we can create it manually. @@ -153,19 +153,19 @@ class AndroidProfilerTest { assertFailsWith { ctor.newInstance( - arrayOf(null, 0, mock(), mock(), mock()) + arrayOf(null, 0, mock(), { mock() }, mock()) ) } assertFailsWith { ctor.newInstance( - arrayOf("mock", 0, null, mock(), mock()) + arrayOf("mock", 0, null, { mock() }, mock()) ) } assertFailsWith { ctor.newInstance(arrayOf("mock", 0, mock(), null, mock())) } assertFailsWith { - ctor.newInstance(arrayOf("mock", 0, mock(), mock(), null)) + ctor.newInstance(arrayOf("mock", 0, mock(), { mock() }, null)) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt index 50c7ba3c7d3..b37a6bbdee5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt @@ -89,8 +89,6 @@ class AndroidTransactionProfilerTest { override fun close(timeoutMillis: Long) {} override fun isClosed() = false - - override fun prewarm() = Unit } val options = @@ -152,6 +150,7 @@ class AndroidTransactionProfilerTest { buildInfoProvider, loadClass, activityFramesTracker, + false, ) // Profiler doesn't start if the folder doesn't exists. // Usually it's generated when calling Sentry.init, but for tests we can create it manually. @@ -185,7 +184,7 @@ class AndroidTransactionProfilerTest { fun `profiler start update inner counter`() { val profiler = fixture.getSut(context) profiler.start() - assertEquals(1, profiler.transactionsCounter) + assertTrue(profiler.isRunning) } @Test @@ -193,10 +192,10 @@ class AndroidTransactionProfilerTest { val profiler = fixture.getSut(context) profiler.start() profiler.bindTransaction(fixture.transaction1) - assertEquals(1, profiler.transactionsCounter) + assertTrue(profiler.isRunning) assertTrue(profiler.isRunning) profiler.onTransactionFinish(fixture.transaction1, null, fixture.options) - assertEquals(0, profiler.transactionsCounter) + assertFalse(profiler.isRunning) assertFalse(profiler.isRunning) } @@ -205,19 +204,19 @@ class AndroidTransactionProfilerTest { val profiler = fixture.getSut(context) profiler.start() profiler.start() - assertEquals(1, profiler.transactionsCounter) + assertTrue(profiler.isRunning) } @Test fun `profiler bind set current transaction`() { val profiler = fixture.getSut(context) - assertEquals(0, profiler.transactionsCounter) + assertFalse(profiler.isRunning) profiler.start() profiler.bindTransaction(fixture.transaction1) - assertEquals(1, profiler.transactionsCounter) + assertTrue(profiler.isRunning) val profilingTraceData = profiler.onTransactionFinish(fixture.transaction1, null, fixture.options) - assertEquals(0, profiler.transactionsCounter) + assertFalse(profiler.isRunning) assertNotNull(profilingTraceData) assertEquals(profilingTraceData.transactionId, fixture.transaction1.eventId.toString()) @@ -226,18 +225,18 @@ class AndroidTransactionProfilerTest { @Test fun `profiler multiple binds are ignored`() { val profiler = fixture.getSut(context) - assertEquals(0, profiler.transactionsCounter) + assertFalse(profiler.isRunning) profiler.start() profiler.bindTransaction(fixture.transaction1) profiler.bindTransaction(fixture.transaction2) - assertEquals(1, profiler.transactionsCounter) + assertTrue(profiler.isRunning) val profilingTraceData2 = profiler.onTransactionFinish(fixture.transaction2, null, fixture.options) - assertEquals(1, profiler.transactionsCounter) + assertTrue(profiler.isRunning) val profilingTraceData = profiler.onTransactionFinish(fixture.transaction1, null, fixture.options) - assertEquals(0, profiler.transactionsCounter) + assertFalse(profiler.isRunning) assertNotNull(profilingTraceData) assertNull(profilingTraceData2) @@ -252,7 +251,7 @@ class AndroidTransactionProfilerTest { } val profiler = fixture.getSut(context, buildInfo) profiler.start() - assertEquals(0, profiler.transactionsCounter) + assertFalse(profiler.isRunning) } @Test @@ -260,7 +259,7 @@ class AndroidTransactionProfilerTest { fixture.options.apply { profilesSampleRate = 0.0 } val profiler = fixture.getSut(context) profiler.start() - assertEquals(0, profiler.transactionsCounter) + assertFalse(profiler.isRunning) } @Test @@ -323,7 +322,7 @@ class AndroidTransactionProfilerTest { fixture.options.apply { cacheDirPath = null } val profiler = fixture.getSut(context) profiler.start() - assertEquals(0, profiler.transactionsCounter) + assertFalse(profiler.isRunning) } @Test @@ -331,7 +330,7 @@ class AndroidTransactionProfilerTest { fixture.options.apply { cacheDirPath = null } val profiler = fixture.getSut(context) profiler.start() - assertEquals(0, profiler.transactionsCounter) + assertFalse(profiler.isRunning) } @Test @@ -339,7 +338,7 @@ class AndroidTransactionProfilerTest { fixture.options.apply { profilingTracesHz = 0 } val profiler = fixture.getSut(context) profiler.start() - assertEquals(0, profiler.transactionsCounter) + assertFalse(profiler.isRunning) } @Test @@ -507,10 +506,10 @@ class AndroidTransactionProfilerTest { val profiler = fixture.getSut(context) profiler.start() profiler.bindTransaction(fixture.transaction1) - assertEquals(1, profiler.transactionsCounter) + assertTrue(profiler.isRunning) profiler.close() - assertEquals(0, profiler.transactionsCounter) + assertFalse(profiler.isRunning) // The timeout scheduled job should be cleared val androidProfiler = profiler.getProperty("profiler") @@ -527,10 +526,10 @@ class AndroidTransactionProfilerTest { fun `profiler stops profiling on close, even if not bound to a transaction`() { val profiler = fixture.getSut(context) profiler.start() - assertEquals(1, profiler.transactionsCounter) + assertTrue(profiler.isRunning) profiler.close() - assertEquals(0, profiler.transactionsCounter) + assertFalse(profiler.isRunning) // The timeout scheduled job should be cleared val androidProfiler = profiler.getProperty("profiler") diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt index af2c208440d..d9fd9c1889e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt @@ -2,331 +2,176 @@ package io.sentry.android.core import android.app.ActivityManager import android.app.ApplicationExitInfo -import android.content.Context -import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Hint -import io.sentry.ILogger -import io.sentry.IScopes -import io.sentry.SentryEnvelope -import io.sentry.SentryLevel +import io.sentry.SentryEvent import io.sentry.android.core.AnrV2Integration.AnrV2Hint import io.sentry.android.core.cache.AndroidEnvelopeCache -import io.sentry.cache.EnvelopeCache -import io.sentry.hints.DiskFlushNotification -import io.sentry.hints.SessionStartHint -import io.sentry.protocol.SentryId -import io.sentry.test.ImmediateExecutorService import io.sentry.util.HintUtils import java.io.File -import java.util.concurrent.TimeUnit -import kotlin.concurrent.thread -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import org.junit.Rule -import org.junit.rules.TemporaryFolder +import org.junit.After import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat -import org.mockito.kotlin.atMost import org.mockito.kotlin.check -import org.mockito.kotlin.inOrder -import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.spy -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config -import org.robolectric.shadow.api.Shadow -import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder @RunWith(AndroidJUnit4::class) @Config(sdk = [30]) -class AnrV2IntegrationTest { - @get:Rule val tmpDir = TemporaryFolder() - - class Fixture { - lateinit var context: Context - lateinit var shadowActivityManager: ShadowActivityManager - lateinit var lastReportedAnrFile: File - - val options = SentryAndroidOptions() - val scopes = mock() - val logger = mock() - - fun getSut( - dir: TemporaryFolder?, - useImmediateExecutorService: Boolean = true, - isAnrEnabled: Boolean = true, - flushTimeoutMillis: Long = 0L, - sessionFlushTimeoutMillis: Long = 0L, - lastReportedAnrTimestamp: Long? = null, - lastEventId: SentryId = SentryId(), - sessionTrackingEnabled: Boolean = true, - reportHistoricalAnrs: Boolean = true, - attachAnrThreadDump: Boolean = false, - ): AnrV2Integration { - options.run { - setLogger(this@Fixture.logger) - isDebug = true - cacheDirPath = dir?.newFolder()?.absolutePath - executorService = if (useImmediateExecutorService) ImmediateExecutorService() else mock() - this.isAnrEnabled = isAnrEnabled - this.flushTimeoutMillis = flushTimeoutMillis - this.sessionFlushTimeoutMillis = sessionFlushTimeoutMillis - this.isEnableAutoSessionTracking = sessionTrackingEnabled - this.isReportHistoricalAnrs = reportHistoricalAnrs - this.isAttachAnrThreadDump = attachAnrThreadDump - addInAppInclude("io.sentry.samples") - setEnvelopeDiskCache(EnvelopeCache.create(this)) - } - options.cacheDirPath?.let { cacheDir -> - lastReportedAnrFile = File(cacheDir, AndroidEnvelopeCache.LAST_ANR_REPORT) - lastReportedAnrFile.writeText(lastReportedAnrTimestamp.toString()) - } - whenever(scopes.captureEvent(any(), anyOrNull())).thenReturn(lastEventId) - return AnrV2Integration(context) - } - - fun addAppExitInfo( - reason: Int? = ApplicationExitInfo.REASON_ANR, - timestamp: Long? = null, - importance: Int? = null, - addTrace: Boolean = true, - addBadTrace: Boolean = false, - ) { - val builder = ApplicationExitInfoBuilder.newBuilder() - if (reason != null) { - builder.setReason(reason) - } - if (timestamp != null) { - builder.setTimestamp(timestamp) - } - if (importance != null) { - builder.setImportance(importance) - } - val exitInfo = - spy(builder.build()) { - if (!addTrace) { - return - } - if (addBadTrace) { - whenever(mock.traceInputStream) - .thenReturn( - """ - Subject: Input dispatching timed out (7985007 com.example.app/com.example.app.ui.MainActivity (server) is not responding. Waited 5000ms for FocusEvent(hasFocus=false)) - Here are no Binder-related exception messages available. - Pid(12233) have D state thread(tid:12236 name:Signal Catcher) - - - RssHwmKb: 823716 - RssKb: 548348 - RssAnonKb: 382156 - RssShmemKb: 13304 - VmSwapKb: 82484 - - - --- CriticalEventLog --- - capacity: 20 - timestamp_ms: 1731507490032 - window_ms: 300000 - - ----- dumping pid: 12233 at 313446151 - libdebuggerd_client: unexpected registration response: 0 - - ----- Waiting Channels: pid 12233 at 2024-11-13 19:48:09.980104540+0530 ----- - Cmd line: com.example.app:mainProcess - """ - .trimIndent() - .byteInputStream() - ) - } else { - whenever(mock.traceInputStream) - .thenReturn( - """ -"main" prio=5 tid=1 Blocked - | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 - | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 - | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 - | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB - | held mutexes= - at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) - - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 - at android.os.Handler.handleCallback(Handler.java:942) - at android.os.Handler.dispatchMessage(Handler.java:99) - at android.os.Looper.loopOnce(Looper.java:201) - at android.os.Looper.loop(Looper.java:288) - at android.app.ActivityThread.main(ActivityThread.java:7872) - at java.lang.reflect.Method.invoke(Native method) - at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) - at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) - -"perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) - | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 - | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 - | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 - | stack=0x7b20124000-0x7b20126000 stackSize=991KB - | held mutexes= - native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) - native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - (no managed stack frames) - """ - .trimIndent() - .byteInputStream() - ) +class AnrV2IntegrationTest : ApplicationExitIntegrationTestBase() { + + override val config = + IntegrationTestConfig( + setEnabledFlag = { isAnrEnabled = it }, + setReportHistoricalFlag = { isReportHistoricalAnrs = it }, + createIntegration = { context -> AnrV2Integration(context) }, + lastReportedFileName = AndroidEnvelopeCache.LAST_ANR_REPORT, + defaultExitReason = ApplicationExitInfo.REASON_ANR, + hintAccessors = + HintAccessors( + cast = { it as AnrV2Hint }, + shouldEnrich = { it.shouldEnrich() }, + timestamp = { it.timestamp() }, + ), + addExitInfo = { reason, timestamp, importance, addTrace, addBadTrace -> + val builder = ApplicationExitInfoBuilder.newBuilder() + reason?.let { builder.setReason(it) } + timestamp?.let { builder.setTimestamp(it) } + importance?.let { builder.setImportance(it) } + val exitInfo = + spy(builder.build()) { + if (!addTrace) { + return@spy + } + if (addBadTrace) { + whenever(mock.traceInputStream) + .thenReturn( + """ + Subject: Input dispatching timed out (7985007 com.example.app/com.example.app.ui.MainActivity (server) is not responding. Waited 5000ms for FocusEvent(hasFocus=false)) + Here are no Binder-related exception messages available. + Pid(12233) have D state thread(tid:12236 name:Signal Catcher) + + + RssHwmKb: 823716 + RssKb: 548348 + RssAnonKb: 382156 + RssShmemKb: 13304 + VmSwapKb: 82484 + + + --- CriticalEventLog --- + capacity: 20 + timestamp_ms: 1731507490032 + window_ms: 300000 + + ----- dumping pid: 12233 at 313446151 + libdebuggerd_client: unexpected registration response: 0 + + ----- Waiting Channels: pid 12233 at 2024-11-13 19:48:09.980104540+0530 ----- + Cmd line: com.example.app:mainProcess + """ + .trimIndent() + .byteInputStream() + ) + } else { + whenever(mock.traceInputStream) + .thenReturn( + """ + "main" prio=5 tid=1 Blocked + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) + - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 + at android.os.Handler.handleCallback(Handler.java:942) + at android.os.Handler.dispatchMessage(Handler.java:99) + at android.os.Looper.loopOnce(Looper.java:201) + at android.os.Looper.loop(Looper.java:288) + at android.app.ActivityThread.main(ActivityThread.java:7872) + at java.lang.reflect.Method.invoke(Native method) + at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) + at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) + + "perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) + | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 + | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 + | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 + | stack=0x7b20124000-0x7b20126000 stackSize=991KB + | held mutexes= + native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) + native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + (no managed stack frames) + """ + .trimIndent() + .byteInputStream() + ) + } } - } - shadowActivityManager.addApplicationExitInfo(exitInfo) - } - } - - private val fixture = Fixture() - private val oldTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(10) - private val newTimestamp = oldTimestamp + TimeUnit.DAYS.toMillis(5) - - @BeforeTest - fun `set up`() { - fixture.context = ApplicationProvider.getApplicationContext() - val activityManager = - fixture.context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? - fixture.shadowActivityManager = Shadow.extract(activityManager) - } - - @Test - fun `when cacheDir is not set, does not process historical exits`() { - val integration = fixture.getSut(null, useImmediateExecutorService = false) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.options.executorService, never()).submit(any()) - } - - @Test - fun `when anr tracking is not enabled, does not process historical exits`() { - val integration = - fixture.getSut(tmpDir, isAnrEnabled = false, useImmediateExecutorService = false) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.options.executorService, never()).submit(any()) - } - - @Test - fun `when historical exit list is empty, does not process historical exits`() { - val integration = fixture.getSut(tmpDir) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) - } - - @Test - fun `when there are no ANRs in historical exits, does not capture events`() { - val integration = fixture.getSut(tmpDir) - fixture.addAppExitInfo(reason = null) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) - } - - @Test - fun `when latest ANR is older than 90 days, does not capture events`() { - val oldTimestamp = - System.currentTimeMillis() - - AnrV2Integration.NINETY_DAYS_THRESHOLD - - TimeUnit.DAYS.toMillis(2) - val integration = fixture.getSut(tmpDir) - fixture.addAppExitInfo(timestamp = oldTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) - } - - @Test - fun `when latest ANR has already been reported, does not capture events`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) - fixture.addAppExitInfo(timestamp = oldTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) - } - - @Test - fun `when no ANRs have ever been reported, captures events`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = null) - fixture.addAppExitInfo(timestamp = oldTimestamp) - - integration.register(fixture.scopes, fixture.options) + shadowActivityManager.addApplicationExitInfo(exitInfo) + }, + flushLogPrefix = "Timed out waiting to flush ANR event to disk.", + ) - verify(fixture.scopes).captureEvent(any(), anyOrNull()) - } - - @Test - fun `when latest ANR has not been reported, captures event with enriching`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes) - .captureEvent( - check { - assertEquals(newTimestamp, it.timestamp.time) - assertEquals(SentryLevel.FATAL, it.level) - val mainThread = it.threads!!.first() - assertEquals("main", mainThread.name) - assertEquals(1, mainThread.id) - assertEquals("Blocked", mainThread.state) - assertEquals(true, mainThread.isCrashed) - assertEquals(true, mainThread.isMain) - assertEquals("0x0d3a2f0a", mainThread.heldLocks!!.values.first().address) - assertEquals(5, mainThread.heldLocks!!.values.first().threadId) - val lastFrame = mainThread.stacktrace!!.frames!!.last() - assertEquals("io.sentry.samples.android.MainActivity$2", lastFrame.module) - assertEquals("MainActivity.java", lastFrame.filename) - assertEquals("run", lastFrame.function) - assertEquals(177, lastFrame.lineno) - assertEquals(true, lastFrame.isInApp) - val otherThread = it.threads!![1] - assertEquals("perfetto_hprof_listener", otherThread.name) - assertEquals(7, otherThread.id) - assertEquals("Native", otherThread.state) - assertEquals(false, otherThread.isCrashed) - assertEquals(false, otherThread.isMain) - val firstFrame = otherThread.stacktrace!!.frames!!.first() - assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", firstFrame.`package`) - assertEquals("__start_thread", firstFrame.function) - assertEquals(64, firstFrame.lineno) - assertEquals("0x00000000000530b8", firstFrame.instructionAddr) - assertEquals("native", firstFrame.platform) - assertEquals("rel:741f3301-bbb0-b92c-58bd-c15282b8ec7b", firstFrame.addrMode) - - val image = - it.debugMeta?.images?.find { it.debugId == "741f3301-bbb0-b92c-58bd-c15282b8ec7b" } - assertNotNull(image) - assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", image.codeFile) - }, - argThat { - val hint = HintUtils.getSentrySdkHint(this) - (hint as AnrV2Hint).shouldEnrich() - }, - ) + override fun assertEnrichedEvent(event: SentryEvent) { + val mainThread = event.threads!!.first() + assertEquals("main", mainThread.name) + assertEquals(1, mainThread.id) + assertEquals("Blocked", mainThread.state) + assertEquals(true, mainThread.isCrashed) + assertEquals(true, mainThread.isMain) + assertEquals("0x0d3a2f0a", mainThread.heldLocks!!.values.first().address) + assertEquals(5, mainThread.heldLocks!!.values.first().threadId) + + val lastFrame = mainThread.stacktrace!!.frames!!.last() + assertEquals("io.sentry.samples.android.MainActivity$2", lastFrame.module) + assertEquals("MainActivity.java", lastFrame.filename) + assertEquals("run", lastFrame.function) + assertEquals(177, lastFrame.lineno) + assertEquals(true, lastFrame.isInApp) + + val otherThread = event.threads!![1] + assertEquals("perfetto_hprof_listener", otherThread.name) + assertEquals(7, otherThread.id) + assertEquals("Native", otherThread.state) + assertEquals(false, otherThread.isCrashed) + assertEquals(false, otherThread.isMain) + + val firstFrame = otherThread.stacktrace!!.frames!!.first() + assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", firstFrame.`package`) + assertEquals("__start_thread", firstFrame.function) + assertEquals(64, firstFrame.lineno) + assertEquals("0x00000000000530b8", firstFrame.instructionAddr) + assertEquals("native", firstFrame.platform) + assertEquals("rel:741f3301-bbb0-b92c-58bd-c15282b8ec7b", firstFrame.addrMode) + + val image = + event.debugMeta?.images?.find { it.debugId == "741f3301-bbb0-b92c-58bd-c15282b8ec7b" } + assertNotNull(image) + assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", image.codeFile) + } + + @After + fun cleanup() { + fixture.options.cacheDirPath?.let { File(it).deleteRecursively() } } @Test fun `when latest ANR has foreground importance, sets abnormal mechanism to anr_foreground`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, sessionTrackingEnabled = true) fixture.addAppExitInfo( timestamp = newTimestamp, importance = ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, @@ -344,145 +189,9 @@ class AnrV2IntegrationTest { ) } - @Test - fun `waits for ANR events to be flushed on disk`() { - val integration = - fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp, flushTimeoutMillis = 500L) - fixture.addAppExitInfo(timestamp = newTimestamp) - - whenever(fixture.scopes.captureEvent(any(), any())).thenAnswer { invocation -> - val hint = HintUtils.getSentrySdkHint(invocation.getArgument(1)) as DiskFlushNotification - thread { - Thread.sleep(200L) - hint.markFlushed() - } - SentryId() - } - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes).captureEvent(any(), anyOrNull()) - // shouldn't fall into timed out state, because we marked event as flushed on another thread - verify(fixture.logger, never()) - .log( - any(), - argThat { startsWith("Timed out waiting to flush ANR event to disk.") }, - any(), - ) - } - - @Test - fun `when latest ANR event was dropped, does not block flushing`() { - val integration = - fixture.getSut( - tmpDir, - lastReportedAnrTimestamp = oldTimestamp, - lastEventId = SentryId.EMPTY_ID, - ) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes).captureEvent(any(), anyOrNull()) - // we do not call markFlushed, hence it should time out waiting for flush, but because - // we drop the event, it should not even come to this if-check - verify(fixture.logger, never()) - .log( - any(), - argThat { startsWith("Timed out waiting to flush ANR event to disk.") }, - any(), - ) - } - - @Test - fun `historical ANRs are reported non-enriched`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) - fixture.addAppExitInfo(timestamp = newTimestamp - 2 * 60 * 1000) - fixture.addAppExitInfo(timestamp = newTimestamp - 1 * 60 * 1000) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes, times(2)) - .captureEvent( - any(), - argThat { - val hint = HintUtils.getSentrySdkHint(this) - !(hint as AnrV2Hint).shouldEnrich() - }, - ) - } - - @Test - fun `when historical ANRs flag is disabled, does not report`() { - val integration = - fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp, reportHistoricalAnrs = false) - fixture.addAppExitInfo(timestamp = newTimestamp - 2 * 60 * 1000) - fixture.addAppExitInfo(timestamp = newTimestamp - 1 * 60 * 1000) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - // only the latest anr is reported which should be enrichable - verify(fixture.scopes, atMost(1)) - .captureEvent( - any(), - argThat { - val hint = HintUtils.getSentrySdkHint(this) - (hint as AnrV2Hint).shouldEnrich() - }, - ) - } - - @Test - fun `historical ANRs are reported in reverse order to keep track of last reported ANR in a marker file`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) - // robolectric uses addFirst when adding exit infos, so the last one here will be the first on - // the list - fixture.addAppExitInfo(timestamp = newTimestamp - TimeUnit.DAYS.toMillis(2)) - fixture.addAppExitInfo(timestamp = newTimestamp - TimeUnit.DAYS.toMillis(1)) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - // the order is reverse here, so the oldest ANR will be reported first to keep track of - // last reported ANR in a marker file - inOrder(fixture.scopes) { - verify(fixture.scopes) - .captureEvent( - argThat { timestamp.time == newTimestamp - TimeUnit.DAYS.toMillis(2) }, - anyOrNull(), - ) - verify(fixture.scopes) - .captureEvent( - argThat { timestamp.time == newTimestamp - TimeUnit.DAYS.toMillis(1) }, - anyOrNull(), - ) - verify(fixture.scopes) - .captureEvent(argThat { timestamp.time == newTimestamp }, anyOrNull()) - } - } - - @Test - fun `ANR timestamp is passed with the hint`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes) - .captureEvent( - any(), - argThat { - val hint = HintUtils.getSentrySdkHint(this) - (hint as AnrV2Hint).timestamp() == newTimestamp - }, - ) - } - @Test fun `abnormal mechanism is passed with the hint`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) fixture.addAppExitInfo(timestamp = newTimestamp) integration.register(fixture.scopes, fixture.options) @@ -498,94 +207,23 @@ class AnrV2IntegrationTest { } @Test - fun `awaits for previous session flush if cache is EnvelopeCache`() { - val integration = - fixture.getSut( - tmpDir, - lastReportedAnrTimestamp = oldTimestamp, - sessionFlushTimeoutMillis = 500L, - ) - fixture.addAppExitInfo(timestamp = newTimestamp) - - thread { - Thread.sleep(200L) - val sessionHint = HintUtils.createWithTypeCheckHint(SessionStartHint()) - fixture.options.envelopeDiskCache.store( - SentryEnvelope(SentryId.EMPTY_ID, null, emptyList()), - sessionHint, - ) - } - - integration.register(fixture.scopes, fixture.options) - - // we store envelope with StartSessionHint on different thread after some delay, which - // triggers the previous session flush, so no timeout - verify(fixture.logger, never()) - .log( - any(), - argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, - any(), - ) - } - - @Test - fun `does not await for previous session flush, if session tracking is disabled`() { - val integration = - fixture.getSut( - tmpDir, - lastReportedAnrTimestamp = oldTimestamp, - sessionFlushTimeoutMillis = 500L, - sessionTrackingEnabled = false, - ) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.logger, never()) - .log( - any(), - argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, - any(), - ) - verify(fixture.scopes).captureEvent(any(), any()) - } - - @Test - fun `flushes previous session latch, if timed out waiting`() { + fun `attaches plain thread dump, if enabled`() { val integration = fixture.getSut( tmpDir, - lastReportedAnrTimestamp = oldTimestamp, - sessionFlushTimeoutMillis = 500L, + lastReportedTimestamp = oldTimestamp, + extraOptions = { opts -> opts.isAttachAnrThreadDump = true }, ) fixture.addAppExitInfo(timestamp = newTimestamp) integration.register(fixture.scopes, fixture.options) - verify(fixture.logger) - .log( - any(), - argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, - any(), - ) - // should return true, because latch is 0 now - assertTrue((fixture.options.envelopeDiskCache as EnvelopeCache).waitPreviousSessionFlush()) - } - - @Test - fun `attaches plain thread dump, if enabled`() { - val integration = - fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp, attachAnrThreadDump = true) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - verify(fixture.scopes).captureEvent(any(), check { assertNotNull(it.threadDump) }) } @Test fun `when traceInputStream is null, does not report ANR`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) fixture.addAppExitInfo(timestamp = newTimestamp, addTrace = false) integration.register(fixture.scopes, fixture.options) @@ -595,7 +233,7 @@ class AnrV2IntegrationTest { @Test fun `when traceInputStream has bad data, does not report ANR`() { - val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) fixture.addAppExitInfo(timestamp = newTimestamp, addBadTrace = true) integration.register(fixture.scopes, fixture.options) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt new file mode 100644 index 00000000000..7fbbca4a3a5 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt @@ -0,0 +1,259 @@ +package io.sentry.android.core + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ISpan +import io.sentry.ITransaction +import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate +import io.sentry.SpanStatus +import io.sentry.android.core.performance.AppStartMetrics +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.N]) +class AppStartExtensionTest { + + private val metrics = mock() + + private fun extension(windowOpen: Boolean = true): AppStartExtension { + whenever(metrics.canExtendAppStart()).thenReturn(windowOpen) + return AppStartExtension(metrics) + } + + /** Simulates the integration's listener: hands a transaction + span back to the extension. */ + private fun AppStartExtension.registerHandOver( + txn: ITransaction = mock(), + span: ISpan = mock(), + ): Pair { + setExtendAppStartListener { AppStartExtension.ExtendedAppStart(txn, span) } + return txn to span + } + + @Test + fun `extendAppStart fires the listener when the window is open`() { + val ext = extension(windowOpen = true) + val calls = AtomicInteger() + ext.setExtendAppStartListener { + calls.incrementAndGet() + null + } + ext.extendAppStart() + assertEquals(1, calls.get()) + } + + @Test + fun `extendAppStart does not fire the listener when the window is closed`() { + val ext = extension(windowOpen = false) + val calls = AtomicInteger() + ext.setExtendAppStartListener { + calls.incrementAndGet() + null + } + ext.extendAppStart() + assertEquals(0, calls.get()) + } + + @Test + fun `extendAppStart is inert when no listener is registered`() { + val ext = extension(windowOpen = true) + ext.extendAppStart() + assertNull(ext.extendedAppStartSpan) + assertFalse(ext.isActive) + } + + @Test + fun `extendAppStart is ignored when already extending`() { + val ext = extension(windowOpen = true) + val calls = AtomicInteger() + val txn = mock() + val span = mock() + ext.setExtendAppStartListener { + calls.incrementAndGet() + AppStartExtension.ExtendedAppStart(txn, span) + } + ext.extendAppStart() + ext.extendAppStart() + assertEquals(1, calls.get()) + } + + @Test + fun `getExtendedAppStartSpan returns null when no extension is active`() { + assertNull(extension().extendedAppStartSpan) + } + + @Test + fun `getExtendedAppStartSpan returns the span while extending`() { + val ext = extension(windowOpen = true) + val (_, span) = ext.registerHandOver() + ext.extendAppStart() + assertSame(span, ext.extendedAppStartSpan) + } + + @Test + fun `finishExtendedAppStart finishes the extended span`() { + val ext = extension(windowOpen = true) + val (_, span) = ext.registerHandOver() + ext.extendAppStart() + ext.finishExtendedAppStart() + verify(span).finish(SpanStatus.OK) + } + + @Test + fun `finishExtendedAppStart does not finish an already finished span`() { + val ext = extension(windowOpen = true) + val span = mock() + whenever(span.isFinished).thenReturn(true) + ext.registerHandOver(span = span) + ext.extendAppStart() + ext.finishExtendedAppStart() + verify(span, never()).finish(any()) + } + + @Test + fun `isActive reflects the transaction state`() { + val ext = extension(windowOpen = true) + assertFalse(ext.isActive) + val (txn, _) = ext.registerHandOver() + ext.extendAppStart() + assertTrue(ext.isActive) + whenever(txn.isFinished).thenReturn(true) + assertFalse(ext.isActive) + } + + @Test + fun `isExtended stays true once extended, even after the transaction finishes`() { + val ext = extension(windowOpen = true) + assertFalse(ext.isExtended) + val (txn, _) = ext.registerHandOver() + ext.extendAppStart() + assertTrue(ext.isExtended) + whenever(txn.isFinished).thenReturn(true) + assertFalse(ext.isActive) + assertTrue(ext.isExtended) + } + + @Test + fun `finishTransaction finishes the transaction at the given timestamp`() { + val ext = extension(windowOpen = true) + val (txn, _) = ext.registerHandOver() + ext.extendAppStart() + val endTimestamp = SentryNanotimeDate() + ext.finishTransaction(endTimestamp) + verify(txn).finish(SpanStatus.OK, endTimestamp) + } + + @Test + fun `finishTransaction does not finish an already finished transaction`() { + val ext = extension(windowOpen = true) + val txn = mock() + whenever(txn.isFinished).thenReturn(true) + ext.registerHandOver(txn = txn) + ext.extendAppStart() + ext.finishTransaction(SentryNanotimeDate()) + verify(txn, never()).finish(any(), any()) + } + + @Test + fun `finishTransaction ends at the extended span end when it finished after the given timestamp`() { + // Headless: the extended span can finish (in onCreate) before finishTransaction runs (at idle) + // with a finish date later than the headless end. The transaction must end there so it contains + // the extended span and its duration matches the app start vital. + val ext = extension(windowOpen = true) + val txn = mock() + val span = mock() + val spanEnd = SentryLongDate(2_000_000_000L) + whenever(span.finishDate).thenReturn(spanEnd) + ext.registerHandOver(txn = txn, span = span) + ext.extendAppStart() + ext.finishTransaction(SentryLongDate(1_000_000_000L)) + verify(txn).finish(SpanStatus.OK, spanEnd) + } + + @Test + fun `getExtendedEndTime is null while the span is unfinished`() { + val ext = extension(windowOpen = true) + ext.registerHandOver() + ext.extendAppStart() + assertNull(ext.extendedEndTime) + } + + @Test + fun `getExtendedEndTime is null when the extension finished via deadline`() { + val ext = extension(windowOpen = true) + val span = mock() + whenever(span.isFinished).thenReturn(true) + whenever(span.status).thenReturn(SpanStatus.DEADLINE_EXCEEDED) + whenever(span.finishDate).thenReturn(SentryNanotimeDate()) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertNull(ext.extendedEndTime) + } + + @Test + fun `getExtendedEndTime returns the finish date on a user finish`() { + val ext = extension(windowOpen = true) + val finishDate = SentryNanotimeDate() + val span = mock() + whenever(span.isFinished).thenReturn(true) + whenever(span.status).thenReturn(SpanStatus.OK) + whenever(span.finishDate).thenReturn(finishDate) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertSame(finishDate, ext.extendedEndTime) + } + + @Test + fun `getExtendedEndTime returns the finish date even when the span still reports unfinished`() { + // Reproduces the waitForChildren reentrancy: finishing the extended span completes the + // transaction and runs the event processor before the span's isFinished() flips, while the + // finish timestamp is already set. getExtendedEndTime() must read the finish date, not the + // flag. + val ext = extension(windowOpen = true) + val finishDate = SentryNanotimeDate() + val span = mock() + whenever(span.isFinished).thenReturn(false) + whenever(span.status).thenReturn(SpanStatus.OK) + whenever(span.finishDate).thenReturn(finishDate) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertSame(finishDate, ext.extendedEndTime) + } + + @Test + fun `clear clears the extension state`() { + val ext = extension(windowOpen = true) + ext.registerHandOver() + ext.extendAppStart() + assertTrue(ext.isActive) + ext.clear() + assertFalse(ext.isActive) + assertNull(ext.extendedAppStartSpan) + } + + @Test + fun `getExtendedAppStartSpan returns null once the finish date is set even if still unfinished`() { + // Same waitForChildren reentrancy as getExtendedEndTime: the finish timestamp is set before the + // span's isFinished() flips, so the span must not be handed out for new children anymore. + val ext = extension(windowOpen = true) + val span = mock() + whenever(span.isFinished).thenReturn(false) + whenever(span.finishDate).thenReturn(SentryNanotimeDate()) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertNull(ext.extendedAppStartSpan) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt similarity index 54% rename from sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt rename to sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index b80d2838c4e..176ca460eb1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -8,13 +8,19 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb import io.sentry.Hint +import io.sentry.IScopes import io.sentry.IpAddressUtils import io.sentry.NoOpLogger +import io.sentry.ProfileChunk +import io.sentry.Sentry import io.sentry.SentryBaseEvent import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.SentryLevel.DEBUG import io.sentry.SpanContext +import io.sentry.android.core.anr.AnrProfileManager +import io.sentry.android.core.anr.AnrProfileRotationHelper +import io.sentry.android.core.anr.AnrStackTrace import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE @@ -22,6 +28,7 @@ import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME +import io.sentry.cache.PersistingOptionsObserver.TAGS_FILENAME as OPTIONS_TAGS_FILENAME import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME @@ -47,6 +54,7 @@ import io.sentry.protocol.OperatingSystem import io.sentry.protocol.Request import io.sentry.protocol.Response 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 @@ -66,7 +74,11 @@ import kotlin.test.assertTrue import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow @@ -74,7 +86,7 @@ import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowBuild @RunWith(AndroidJUnit4::class) -class AnrV2EventProcessorTest { +class ApplicationExitInfoEventProcessorTest { @get:Rule val tmpDir = TemporaryFolder() class Fixture { @@ -93,7 +105,7 @@ class AnrV2EventProcessorTest { populateOptionsCache: Boolean = false, replayErrorSampleRate: Double? = null, isSendDefaultPii: Boolean = true, - ): AnrV2EventProcessor { + ): ApplicationExitInfoEventProcessor { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" options.isSendDefaultPii = isSendDefaultPii @@ -144,13 +156,13 @@ class AnrV2EventProcessorTest { persistOptions(SDK_VERSION_FILENAME, SdkVersion("sentry.java.android", "6.15.0")) persistOptions(DIST_FILENAME, "232") persistOptions(ENVIRONMENT_FILENAME, "debug") - persistOptions(TAGS_FILENAME, mapOf("option" to "tag")) + persistOptions(OPTIONS_TAGS_FILENAME, mapOf("option" to "tag")) replayErrorSampleRate?.let { persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, it.toString()) } } - return AnrV2EventProcessor(context, options, buildInfo) + return ApplicationExitInfoEventProcessor(context, options, buildInfo) } fun persistScope(filename: String, entity: T) { @@ -190,6 +202,7 @@ class AnrV2EventProcessorTest { @BeforeTest fun `set up`() { DeviceInfoUtil.resetInstance() + ContextUtils.resetInstance() fixture.context = ApplicationProvider.getApplicationContext() } @@ -204,7 +217,7 @@ class AnrV2EventProcessorTest { @Test fun `when backfillable event is not enrichable, sets different mechanism`() { - val hint = HintUtils.createWithTypeCheckHint(BackfillableHint(shouldEnrich = false)) + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false)) val processed = processEvent(hint) @@ -213,7 +226,7 @@ class AnrV2EventProcessorTest { @Test fun `when backfillable event is not enrichable, sets platform`() { - val hint = HintUtils.createWithTypeCheckHint(BackfillableHint(shouldEnrich = false)) + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false)) val processed = processEvent(hint) @@ -277,7 +290,7 @@ class AnrV2EventProcessorTest { @Test fun `when backfillable event is enrichable, still sets static data`() { - val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) val processed = processEvent(hint) @@ -352,11 +365,20 @@ class AnrV2EventProcessorTest { assertEquals("io.sentry.android.core.test", processed.contexts.app!!.appName) assertEquals("1.2.0", processed.contexts.app!!.appVersion) assertEquals("232", processed.contexts.app!!.appBuild) - assertEquals(true, processed.contexts.app!!.inForeground) + assertNull(processed.contexts.app!!.inForeground) // tags assertEquals("tag", processed.tags!!["option"]) } + @Test + fun `when ANR event is enrichable, sets foreground flag`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + + val processed = processEvent(hint, populateOptionsCache = true) + + assertEquals(true, processed.contexts.app!!.inForeground) + } + @Test fun `if release is in wrong format, does not crash and leaves app version and build empty`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -373,14 +395,199 @@ class AnrV2EventProcessorTest { } @Test - fun `if environment is not persisted, uses environment from options`() { - val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + fun `if environment is not persisted and app was not updated, uses environment from options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + setLastUpdateTime(1_000) val processed = processEvent(hint) assertEquals("release", processed.environment) } + @Test + fun `if release is not persisted and app was not updated, uses release from options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.2.0+232", processed.release) + } + + @Test + fun `if release is not persisted and app was updated, leaves release empty`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 1_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(2_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + } + + @Test + fun `if exit timestamp is unknown, leaves release empty`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + } + + @Test + fun `if last update time is invalid, leaves release empty`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 1_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(-1) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + } + + @Test + fun `if dist is not persisted and app was not updated, uses version code from options release`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("232", processed.dist) + } + + @Test + fun `if app version is not persisted and app was not updated, uses options release`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("1.2.0", processed.contexts.app!!.appVersion) + assertEquals("232", processed.contexts.app!!.appBuild) + } + + @Test + fun `historical event uses current options when app was not updated`() { + val hint = + HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false, timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + fixture.options.environment = "production" + fixture.options.dist = "custom-dist" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.2.0+232", processed.release) + assertEquals("production", processed.environment) + assertEquals("custom-dist", processed.dist) + val app = processed.contexts.app!! + assertEquals("1.2.0", app.appVersion) + assertEquals("232", app.appBuild) + assertNull(app.appName) + assertNull(app.appIdentifier) + } + + @Test + fun `if options cache is from an older app update, uses current options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 3_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@2.0.0+300" + fixture.options.environment = "current-user" + fixture.options.dist = "current-dist" + fixture.options.proguardUuid = "current-uuid" + fixture.options.sdkVersion = SdkVersion("current-sdk", "2.0.0") + fixture.options.setTag("account", "current-tag") + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100") + fixture.persistOptions(ENVIRONMENT_FILENAME, "previous-user") + fixture.persistOptions(DIST_FILENAME, "previous-dist") + fixture.persistOptions(PROGUARD_UUID_FILENAME, "previous-uuid") + fixture.persistOptions(SDK_VERSION_FILENAME, SdkVersion("previous-sdk", "1.0.0")) + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "previous-tag")) + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(2_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@2.0.0+300", processed.release) + assertEquals("current-user", processed.environment) + assertEquals("current-dist", processed.dist) + assertEquals("current-uuid", processed.debugMeta!!.images!![0].uuid) + assertEquals("current-sdk", processed.sdk!!.name) + assertEquals("current-tag", processed.tags!!["account"]) + } + + @Test + fun `if options cache is from current app update, uses persisted options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.0.0+100" + fixture.options.environment = "current-user" + fixture.options.dist = "current-dist" + fixture.options.setTag("account", "current-tag") + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100") + fixture.persistOptions(ENVIRONMENT_FILENAME, "crashed-user") + fixture.persistOptions(DIST_FILENAME, "crashed-dist") + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "crashed-tag")) + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.0.0+100", processed.release) + assertEquals("crashed-user", processed.environment) + assertEquals("crashed-dist", processed.dist) + assertEquals("crashed-tag", processed.tags!!["account"]) + } + + @Test + fun `if options cache was written after the exit, ignores persisted options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@2.0.0+200") + fixture.persistOptions(ENVIRONMENT_FILENAME, "newer-user") + fixture.persistOptions(DIST_FILENAME, "newer-dist") + fixture.persistOptions(PROGUARD_UUID_FILENAME, "newer-uuid") + fixture.persistOptions(SDK_VERSION_FILENAME, SdkVersion("newer-sdk", "2.0.0")) + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "newer-tag")) + PersistingOptionsCacheGenerationObserver(fixture.options, 2_500L).setRelease(null) + setLastUpdateTime(3_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + assertNull(processed.environment) + assertNull(processed.dist) + assertTrue(processed.debugMeta!!.images!!.isEmpty()) + assertNull(processed.sdk) + assertNull(processed.tags?.get("account")) + } + + @Test + fun `historical event leaves release empty when app was updated`() { + val hint = + HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false, timestamp = 1_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(2_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + assertNull(processed.contexts.app) + } + @Test fun `if dist is not persisted, backfills it from release`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -586,15 +793,305 @@ class AnrV2EventProcessorTest { fun `sets default fingerprint to distinguish between background and foreground ANRs`() { val backgroundHint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_background")) - val processedBackground = processEvent(backgroundHint, populateScopeCache = false) + val processedBackground = + processEvent(backgroundHint, populateScopeCache = false) { + exceptions = + listOf( + SentryException().apply { + this.stacktrace = + SentryStackTrace( + listOf( + SentryStackFrame().apply { + module = "io.sentry.samples.MainActivity" + function = "run" + } + ) + ) + } + ) + } assertEquals(listOf("{{ default }}", "background-anr"), processedBackground.fingerprints) val foregroundHint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) - val processedForeground = processEvent(foregroundHint, populateScopeCache = false) + val processedForeground = + processEvent(foregroundHint, populateScopeCache = false) { + exceptions = + listOf( + SentryException().apply { + this.stacktrace = + SentryStackTrace( + listOf( + SentryStackFrame().apply { + module = "io.sentry.samples.MainActivity" + function = "run" + } + ) + ) + } + ) + } assertEquals(listOf("{{ default }}", "foreground-anr"), processedForeground.fingerprints) } + @Test + fun `tombstone hint does not override platform or exceptions`() { + val hint = + HintUtils.createWithTypeCheckHint( + TombstoneIntegration.TombstoneHint( + fixture.options.flushTimeoutMillis, + NoOpLogger.getInstance(), + 0, + true, + ) + ) + + val processed = + processEvent(hint, populateScopeCache = false, populateOptionsCache = false) { + platform = "native" + exceptions = listOf(SentryException().apply { type = "NativeCrash" }) + } + + assertEquals("native", processed.platform) + assertEquals("NativeCrash", processed.exceptions!!.first().type) + assertNull(processed.fingerprints) + } + + @Test + fun `sets system-frames-only fingerprint when ANR fingerprinting enabled and no app frames`() { + fixture.options.isEnableAnrFingerprinting = true + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + + val processed = + processEvent(hint, populateScopeCache = false) { + threads = + listOf( + SentryThread().apply { + name = "main" + stacktrace = + SentryStackTrace().apply { + frames = + listOf( + SentryStackFrame().apply { + module = "java.lang" + filename = "Thread.java" + function = "run" + } + ) + } + } + ) + } + + assertEquals(listOf("system-frames-only-anr", "foreground-anr"), processed.fingerprints) + } + + @Test + fun `does not set system-frames-only fingerprint when ANR fingerprinting is disabled and no app frames are present`() { + fixture.options.isEnableAnrFingerprinting = false + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + + val processed = + processEvent(hint, populateScopeCache = false) { + threads = + listOf( + SentryThread().apply { + name = "main" + stacktrace = + SentryStackTrace().apply { + frames = + listOf( + SentryStackFrame().apply { + module = "java.lang" + filename = "Thread.java" + function = "run" + } + ) + } + } + ) + } + + assertEquals(listOf("{{ default }}", "foreground-anr"), processed.fingerprints) + } + + @Test + fun `sets default fingerprint when ANR fingerprinting enabled and app frames are present`() { + fixture.options.isEnableAnrFingerprinting = true + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + + val processed = + processEvent(hint, populateScopeCache = false) { + threads = + listOf( + SentryThread().apply { + name = "main" + stacktrace = + SentryStackTrace().apply { + frames = + listOf( + SentryStackFrame().apply { + module = "com.example.MyApp" + function = "onCreate" + } + ) + } + } + ) + } + + assertEquals(listOf("{{ default }}", "foreground-anr"), processed.fingerprints) + } + + @Test + fun `does not set profile context when ANR profiling is disabled`() { + fixture.options.anrProfilingSampleRate = null + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val processed = + processEvent(hint, populateScopeCache = false) { + threads = + listOf( + SentryThread().apply { + name = "main" + stacktrace = + SentryStackTrace().apply { + frames = + listOf( + SentryStackFrame().apply { + module = "com.example.MyApp" + function = "onCreate" + } + ) + } + } + ) + } + assertNull(processed.contexts.profile) + } + + @Test + fun `applies ANR profile if available`() { + fixture.options.anrProfilingSampleRate = 1.0 + val processor = + fixture.getSut( + tmpDir, + populateScopeCache = false, + populateOptionsCache = false, + isSendDefaultPii = false, + ) + + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + + AnrProfileManager( + fixture.options, + AnrProfileRotationHelper.getFileForRecording(File(fixture.options.cacheDirPath!!)), + ) + .apply { + add( + AnrStackTrace( + System.currentTimeMillis(), + arrayOf( + StackTraceElement( + "android.view.Choreographer", + "doFrame", + "Choreographer.java", + 1234, + ), + StackTraceElement("android.os.Handler", "dispatchMessage", "Handler.java", 5678), + ), + ) + ) + close() + } + AnrProfileRotationHelper.rotate() + + val scopes = mock() + whenever(scopes.captureProfileChunk(any())).thenReturn(SentryId()) + + mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + + val processed = processor.process(SentryEvent(), hint) + val chunkCaptor = argumentCaptor() + verify(scopes).captureProfileChunk(chunkCaptor.capture()) + val sentryProfile = chunkCaptor.firstValue.sentryProfile + + assertNotNull(processed?.contexts?.profile) + assertNotNull(processed.contexts.profile?.profilerId) + assertNotNull(sentryProfile) + // Two samples are present b/c the converter adds a synthetic one to keep Relay happy. + assertEquals(2, sentryProfile.samples.size) + } + } + + @Test + fun `uses persisted proguard uuid for ANR profile chunk after app update`() { + fixture.options.anrProfilingSampleRate = 1.0 + fixture.options.proguardUuid = "current-uuid" + val processor = + fixture.getSut( + tmpDir, + populateScopeCache = false, + populateOptionsCache = false, + isSendDefaultPii = false, + ) + fixture.persistOptions(PROGUARD_UUID_FILENAME, "previous-uuid") + setLastUpdateTime(2_000) + + val hint = + HintUtils.createWithTypeCheckHint( + AbnormalExitHint(mechanism = "anr_foreground", timestamp = 1_000) + ) + + AnrProfileManager( + fixture.options, + AnrProfileRotationHelper.getFileForRecording(File(fixture.options.cacheDirPath!!)), + ) + .apply { + add( + AnrStackTrace( + 1_000, + arrayOf( + StackTraceElement("com.example.MyApp", "blocked", "MyApp.java", 42), + StackTraceElement("android.os.Handler", "dispatchMessage", "Handler.java", 5678), + ), + ) + ) + close() + } + AnrProfileRotationHelper.rotate() + + val scopes = mock() + whenever(scopes.captureProfileChunk(any())).thenReturn(SentryId()) + + mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + + processor.process(SentryEvent(), hint) + + val chunkCaptor = argumentCaptor() + verify(scopes).captureProfileChunk(chunkCaptor.capture()) + val images = chunkCaptor.firstValue.debugMeta!!.images!! + assertEquals(1, images.size) + assertEquals(DebugImage.PROGUARD, images[0].type) + assertEquals("previous-uuid", images[0].uuid) + } + } + + @Test + fun `does not crash when ANR profiling is enabled but cache dir is null`() { + fixture.options.anrProfilingSampleRate = 1.0 + fixture.options.cacheDirPath = null + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val original = SentryEvent() + + val processor = fixture.getSut(tmpDir) + val processed = processor.process(original, hint) + + assertNotNull(processed) + assertNull(processed.contexts.profile) + } + @Test fun `sets replayId when replay folder exists`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -643,6 +1140,39 @@ class AnrV2EventProcessorTest { assertNull(processed.contexts[Contexts.REPLAY_ID]) } + @Test + fun `if options cache is current, uses persisted replay error sample rate`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + fixture.options.sessionReplay.onErrorSampleRate = 1.0 + fixture.persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, "0.0") + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.contexts[Contexts.REPLAY_ID]) + } + + @Test + fun `if options cache is stale, uses current replay error sample rate`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 3_000)) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + fixture.options.sessionReplay.onErrorSampleRate = 1.0 + fixture.persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, "0.0") + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(2_000) + val replayId = SentryId() + File(fixture.options.cacheDirPath, "replay_$replayId").also { + it.mkdirs() + it.setLastModified(1_000) + } + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals(replayId.toString(), processed.contexts[Contexts.REPLAY_ID].toString()) + } + @Test fun `set replayId of the last modified folder`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -691,14 +1221,23 @@ class AnrV2EventProcessorTest { return processor.process(original, hint)!! } - internal class AbnormalExitHint(val mechanism: String? = null) : AbnormalExit, Backfillable { + private fun setLastUpdateTime(lastUpdateTime: Long) { + ContextUtils.getPackageInfo(fixture.context, fixture.buildInfo)!!.lastUpdateTime = + lastUpdateTime + } + + internal class AbnormalExitHint( + val mechanism: String? = null, + private val shouldEnrich: Boolean = true, + private val timestamp: Long? = null, + ) : AbnormalExit, Backfillable { override fun mechanism(): String? = mechanism override fun ignoreCurrentThread(): Boolean = false - override fun timestamp(): Long? = null + override fun timestamp(): Long? = timestamp - override fun shouldEnrich(): Boolean = true + override fun shouldEnrich(): Boolean = shouldEnrich } internal class BackfillableHint(private val shouldEnrich: Boolean = true) : Backfillable { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt new file mode 100644 index 00000000000..edb2ce1df24 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt @@ -0,0 +1,443 @@ +package io.sentry.android.core + +import android.app.ActivityManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import io.sentry.Hint +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.Integration +import io.sentry.SentryEnvelope +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.cache.EnvelopeCache +import io.sentry.hints.DiskFlushNotification +import io.sentry.hints.SessionStartHint +import io.sentry.protocol.SentryId +import io.sentry.test.ImmediateExecutorService +import io.sentry.util.HintUtils +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.atMost +import org.mockito.kotlin.check +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivityManager + +abstract class ApplicationExitIntegrationTestBase { + + protected abstract val config: IntegrationTestConfig + + @get:Rule val tmpDir = TemporaryFolder() + + protected val fixture: ApplicationExitTestFixture by lazy { + ApplicationExitTestFixture(config) + } + protected val oldTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(10) + protected val newTimestamp = oldTimestamp + TimeUnit.DAYS.toMillis(5) + + @BeforeTest + fun `set up`() { + val context = ApplicationProvider.getApplicationContext() + // the integration test app has no native library and as such we have to inject one here + context.applicationInfo.nativeLibraryDir = + "/data/app/~~gu-2hA9_Zg6tfIuDAbLpKA==/io.sentry.samples.android-MFqmKAMnl9AjNlHcO3mejA==/lib/arm64" + fixture.init(context) + } + + @Test + fun `when cacheDir is not set, does not process historical exits`() { + val integration = fixture.getSut(null, useImmediateExecutorService = false) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.options.executorService, never()).submit(any()) + } + + @Test + fun `when integration is not enabled, does not process historical exits`() { + val integration = fixture.getSut(tmpDir, enabled = false, useImmediateExecutorService = false) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.options.executorService, never()).submit(any()) + } + + @Test + fun `when historical exit list is empty, does not process historical exits`() { + val integration = fixture.getSut(tmpDir) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when there are no matching exits, does not capture events`() { + val integration = fixture.getSut(tmpDir) + fixture.addAppExitInfo(reason = null) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when latest exit is older than 90 days, does not capture events`() { + val oldTimestamp = + System.currentTimeMillis() - + ApplicationExitInfoHistoryDispatcher.NINETY_DAYS_THRESHOLD - + TimeUnit.DAYS.toMillis(2) + val integration = fixture.getSut(tmpDir) + fixture.addAppExitInfo(timestamp = oldTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when latest exit has already been reported, does not capture events`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = oldTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when no exits have ever been reported, captures events`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = null) + fixture.addAppExitInfo(timestamp = oldTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when latest exit has not been reported, captures event with enriching`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + check { event -> + assertEquals(event.timestamp!!.time, newTimestamp) + assertEquals(event.level, SentryLevel.FATAL) + assertEnrichedEvent(event) + }, + argThat { + val hint = config.hintAccessors.cast(HintUtils.getSentrySdkHint(this)) + config.hintAccessors.shouldEnrich(hint) + }, + ) + } + + @Test + fun `waits for events to be flushed on disk`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, flushTimeoutMillis = 500L) + fixture.addAppExitInfo(timestamp = newTimestamp) + + whenever(fixture.scopes.captureEvent(any(), any())).thenAnswer { invocation -> + val hint = HintUtils.getSentrySdkHint(invocation.getArgument(1)) as DiskFlushNotification + thread { + Thread.sleep(200L) + hint.markFlushed() + } + SentryId() + } + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + verify(fixture.logger, never()) + .log(any(), argThat { startsWith(config.flushLogPrefix) }, any()) + } + + @Test + fun `when latest event was dropped, does not block flushing`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, lastEventId = SentryId.EMPTY_ID) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + verify(fixture.logger, never()) + .log(any(), argThat { startsWith(config.flushLogPrefix) }, any()) + } + + @Test + fun `historical exits are reported non-enriched`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp - 2 * 60 * 1000) + fixture.addAppExitInfo(timestamp = newTimestamp - 1 * 60 * 1000) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, times(2)) + .captureEvent( + any(), + argThat { + val hint = config.hintAccessors.cast(HintUtils.getSentrySdkHint(this)) + !config.hintAccessors.shouldEnrich(hint) + }, + ) + } + + @Test + fun `when historical flag is disabled, does not report`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, reportHistorical = false) + fixture.addAppExitInfo(timestamp = newTimestamp - 2 * 60 * 1000) + fixture.addAppExitInfo(timestamp = newTimestamp - 1 * 60 * 1000) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, atMost(1)) + .captureEvent( + any(), + argThat { + val hint = config.hintAccessors.cast(HintUtils.getSentrySdkHint(this)) + config.hintAccessors.shouldEnrich(hint) + }, + ) + } + + @Test + fun `historical exits are reported in reverse order to keep track of last reported exit in a marker file`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp - TimeUnit.DAYS.toMillis(2)) + fixture.addAppExitInfo(timestamp = newTimestamp - TimeUnit.DAYS.toMillis(1)) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + inOrder(fixture.scopes) { + verify(fixture.scopes) + .captureEvent( + argThat { timestamp.time == newTimestamp - TimeUnit.DAYS.toMillis(2) }, + anyOrNull(), + ) + verify(fixture.scopes) + .captureEvent( + argThat { timestamp.time == newTimestamp - TimeUnit.DAYS.toMillis(1) }, + anyOrNull(), + ) + verify(fixture.scopes) + .captureEvent(argThat { timestamp.time == newTimestamp }, anyOrNull()) + } + } + + @Test + fun `timestamp is passed with the hint`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + any(), + argThat { + val hint = config.hintAccessors.cast(HintUtils.getSentrySdkHint(this)) + config.hintAccessors.timestamp(hint) == newTimestamp + }, + ) + } + + @Test + fun `awaits for previous session flush if cache is EnvelopeCache`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, sessionFlushTimeoutMillis = 500L) + fixture.addAppExitInfo(timestamp = newTimestamp) + + thread { + Thread.sleep(200L) + val sessionHint = HintUtils.createWithTypeCheckHint(SessionStartHint()) + fixture.options.envelopeDiskCache.storeEnvelope( + SentryEnvelope(SentryId.EMPTY_ID, null, emptyList()), + sessionHint, + ) + } + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.logger, never()) + .log( + any(), + argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, + any(), + ) + } + + @Test + fun `does not await for previous session flush, if session tracking is disabled`() { + val integration = + fixture.getSut( + tmpDir, + lastReportedTimestamp = oldTimestamp, + sessionFlushTimeoutMillis = 500L, + sessionTrackingEnabled = false, + ) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.logger, never()) + .log( + any(), + argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, + any(), + ) + verify(fixture.scopes).captureEvent(any(), any()) + } + + @Test + fun `flushes previous session latch, if timed out waiting`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, sessionFlushTimeoutMillis = 500L) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.logger) + .log( + any(), + argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, + any(), + ) + assertTrue((fixture.options.envelopeDiskCache as EnvelopeCache).waitPreviousSessionFlush()) + } + + @Test + fun `when traceInputStream is null, does not report`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp, addTrace = false) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when traceInputStream has bad data, does not report`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp, addBadTrace = true) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + protected open fun assertEnrichedEvent(event: SentryEvent) {} + + protected data class HintAccessors( + val cast: (Any?) -> THint, + val shouldEnrich: (THint) -> Boolean, + val timestamp: (THint) -> Long, + ) + + protected data class IntegrationTestConfig( + val setEnabledFlag: SentryAndroidOptions.(Boolean) -> Unit, + val setReportHistoricalFlag: SentryAndroidOptions.(Boolean) -> Unit, + val createIntegration: (Context) -> Integration, + val lastReportedFileName: String, + val defaultExitReason: Int, + val hintAccessors: HintAccessors, + val addExitInfo: + ApplicationExitTestFixture.( + reason: Int?, + timestamp: Long?, + importance: Int?, + addTrace: Boolean, + addBadTrace: Boolean, + ) -> Unit, + val flushLogPrefix: String, + ) + + protected class ApplicationExitTestFixture( + private val config: IntegrationTestConfig + ) { + lateinit var context: Context + lateinit var shadowActivityManager: ShadowActivityManager + lateinit var lastReportedFile: File + + val options = SentryAndroidOptions() + val scopes = mock() + val logger = mock() + + fun init(appContext: Context) { + context = appContext + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + shadowActivityManager = Shadow.extract(activityManager) + } + + fun getSut( + dir: TemporaryFolder?, + useImmediateExecutorService: Boolean = true, + enabled: Boolean = true, + flushTimeoutMillis: Long = 0L, + sessionFlushTimeoutMillis: Long = 0L, + lastReportedTimestamp: Long? = null, + lastEventId: SentryId = SentryId(), + sessionTrackingEnabled: Boolean = true, + reportHistorical: Boolean = true, + extraOptions: (SentryAndroidOptions) -> Unit = {}, + ): Integration { + options.run { + setLogger(this@ApplicationExitTestFixture.logger) + isDebug = true + cacheDirPath = dir?.newFolder()?.absolutePath + executorService = if (useImmediateExecutorService) ImmediateExecutorService() else mock() + config.setEnabledFlag(this, enabled) + this.flushTimeoutMillis = flushTimeoutMillis + this.sessionFlushTimeoutMillis = sessionFlushTimeoutMillis + this.isEnableAutoSessionTracking = sessionTrackingEnabled + config.setReportHistoricalFlag(this, reportHistorical) + addInAppInclude("io.sentry.samples") + setEnvelopeDiskCache(EnvelopeCache.create(this)) + extraOptions(this) + } + options.cacheDirPath?.let { cacheDir -> + File(cacheDir).mkdirs() + lastReportedFile = File(cacheDir, config.lastReportedFileName) + lastReportedFile.writeText(lastReportedTimestamp.toString()) + } + whenever(scopes.captureEvent(any(), anyOrNull())).thenReturn(lastEventId) + return config.createIntegration(context) + } + + fun addAppExitInfo( + reason: Int? = config.defaultExitReason, + timestamp: Long? = null, + importance: Int? = null, + addTrace: Boolean = true, + addBadTrace: Boolean = false, + ) { + config.addExitInfo(this, reason, timestamp, importance, addTrace, addBadTrace) + } + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ChunkMeasurementCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ChunkMeasurementCollectorTest.kt new file mode 100644 index 00000000000..0841f974961 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ChunkMeasurementCollectorTest.kt @@ -0,0 +1,147 @@ +package io.sentry.android.core + +import io.sentry.CompositePerformanceCollector +import io.sentry.PerformanceCollectionData +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector +import io.sentry.profilemeasurements.ProfileMeasurement +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class ChunkMeasurementCollectorTest { + + /** + * Drives [PerfettoContinuousProfiler.ChunkMeasurementCollector] through two full `start -> + * collect -> stop` cycles to assert that the metrics collected are correct. + */ + @Test + fun `each start-stop cycle returns its own independent measurements`() { + val frameMetricsCollector: SentryFrameMetricsCollector = mock() + val performanceCollector: CompositePerformanceCollector = mock() + val collector = PerfettoContinuousProfiler.ChunkMeasurementCollector(frameMetricsCollector) + val listenerCaptor = argumentCaptor() + + // Return distinct performance data for each stop() call. + whenever(performanceCollector.stop(any())) + .thenReturn( + // Cycle 1: 2 samples, both with cpu + heap, only first with native. + listOf( + perfData(nanos = 100L, cpu = 10.0, heap = 1_000L, native = 500L), + perfData(nanos = 200L, cpu = 20.0, heap = 2_000L, native = null), + ), + // Cycle 2: 3 samples, all with heap, only some with cpu/native. + listOf( + perfData(nanos = 1_000L, cpu = 30.0, heap = 3_000L, native = null), + perfData(nanos = 1_100L, cpu = null, heap = 4_000L, native = 800L), + perfData(nanos = 1_200L, cpu = 50.0, heap = 5_000L, native = 900L), + ), + ) + + // --- Cycle 1 --- + collector.start(performanceCollector, "chunk-1") + verify(frameMetricsCollector).startCollection(listenerCaptor.capture()) + // frameEndNanos comes from System.nanoTime(), so it must be based on the current reading for + // the resulting chunk-relative timestamp to be non-negative. + var frameEnd = futureFrameEndNanos() + // onFrameMetricCollected(frameStart, frameEnd, duration, delay, isSlow, isFrozen, refreshRate) + listenerCaptor.lastValue.apply { + onFrameMetricCollected(0L, frameEnd, 100L, 0L, true, false, 60.0f) // slow + onFrameMetricCollected(0L, frameEnd, 800L, 0L, false, true, 60.0f) // frozen + onFrameMetricCollected(0L, frameEnd, 50L, 0L, false, false, 90.0f) // refresh change + } + val chunk1 = collector.stop() + + // --- Cycle 2 --- + collector.start(performanceCollector, "chunk-2") + verify(frameMetricsCollector, times(2)).startCollection(listenerCaptor.capture()) + frameEnd = futureFrameEndNanos() + listenerCaptor.lastValue.apply { + onFrameMetricCollected(0L, frameEnd, 150L, 0L, true, false, 60.0f) // slow + onFrameMetricCollected(0L, frameEnd, 200L, 0L, true, false, 60.0f) // slow + onFrameMetricCollected(0L, frameEnd, 900L, 0L, false, true, 60.0f) // frozen + } + val chunk2 = collector.stop() + + // Cycle 1: 1 slow, 1 frozen; refresh rate goes 0 -> 60 -> 90 (2 changes recorded); + // 2 cpu samples, 2 heap samples, 1 native sample. + assertChunkCounts(chunk1, slow = 1, frozen = 1, refreshRate = 2, cpu = 2, heap = 2, native = 1) + // Cycle 2: 2 slow, 1 frozen; refresh rate goes 0 -> 60 (1 change recorded); + // 2 cpu samples (one was null), 3 heap samples, 2 native samples. + assertChunkCounts(chunk2, slow = 2, frozen = 1, refreshRate = 1, cpu = 2, heap = 3, native = 2) + } + + @Test + fun `frames ending before the chunk started are dropped`() { + val frameMetricsCollector: SentryFrameMetricsCollector = mock() + val collector = PerfettoContinuousProfiler.ChunkMeasurementCollector(frameMetricsCollector) + val listenerCaptor = argumentCaptor() + + collector.start(null, "chunk-1") + verify(frameMetricsCollector).startCollection(listenerCaptor.capture()) + + val staleFrameEnd = System.nanoTime() - TimeUnit.HOURS.toNanos(1) + listenerCaptor.lastValue.apply { + onFrameMetricCollected(0L, staleFrameEnd, 100L, 0L, true, false, 60.0f) + onFrameMetricCollected(0L, staleFrameEnd, 800L, 0L, false, true, 60.0f) + onFrameMetricCollected(0L, futureFrameEndNanos(), 150L, 0L, true, false, 60.0f) + } + + val measurements = collector.stop() + + assertChunkCounts( + measurements, + slow = 1, + frozen = 0, + refreshRate = 1, + cpu = 0, + heap = 0, + native = 0, + ) + } + + /** + * A frameEndNanos far enough ahead of the collector's own `System.nanoTime()` reading that the + * chunk-relative timestamp stays positive regardless of test execution timing. + */ + private fun futureFrameEndNanos() = System.nanoTime() + TimeUnit.MINUTES.toNanos(1) + + /** A null measurement is left unset, as it would be by a collector that did not report it. */ + private fun perfData(nanos: Long, cpu: Double?, heap: Long?, native: Long?) = + PerformanceCollectionData(nanos).apply { + cpu?.let { cpuUsagePercentage = it } + heap?.let { usedHeapMemory = it } + native?.let { usedNativeMemory = it } + } + + private fun assertChunkCounts( + measurements: Map, + slow: Int, + frozen: Int, + refreshRate: Int, + cpu: Int, + heap: Int, + native: Int, + ) { + assertEquals(slow, measurements[ProfileMeasurement.ID_SLOW_FRAME_RENDERS]?.values?.size ?: 0) + assertEquals( + frozen, + measurements[ProfileMeasurement.ID_FROZEN_FRAME_RENDERS]?.values?.size ?: 0, + ) + assertEquals( + refreshRate, + measurements[ProfileMeasurement.ID_SCREEN_FRAME_RATES]?.values?.size ?: 0, + ) + assertEquals(cpu, measurements[ProfileMeasurement.ID_CPU_USAGE]?.values?.size ?: 0) + assertEquals(heap, measurements[ProfileMeasurement.ID_MEMORY_FOOTPRINT]?.values?.size ?: 0) + assertEquals( + native, + measurements[ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT]?.values?.size ?: 0, + ) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt new file mode 100644 index 00000000000..5e4e0504ddf --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt @@ -0,0 +1,194 @@ +package io.sentry.android.core + +import io.sentry.DataCategory +import io.sentry.IContinuousProfiler +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ProfileLifecycle +import io.sentry.SentryLevel +import io.sentry.TracesSampler +import io.sentry.protocol.SentryId +import io.sentry.test.DeferredExecutorService +import io.sentry.transport.RateLimiter +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * Shared dependencies for profiler test cases. Each test class creates one from its own fixture. + */ +class ProfilerMocks( + val executor: DeferredExecutorService, + val tracesSampler: TracesSampler, + val logger: ILogger, + val scopes: IScopes, +) + +// -- Shared test cases as extension functions on IContinuousProfiler -- + +fun IContinuousProfiler.testIsRunningReflectsStatus(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + stopProfiler(ProfileLifecycle.MANUAL) + mocks.executor.runAll() + assertFalse(isRunning) +} + +fun IContinuousProfiler.testStopProfilerStopsAfterChunkFinished(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + stopProfiler(ProfileLifecycle.MANUAL) + assertTrue(isRunning) + assertNotEquals(SentryId.EMPTY_ID, profilerId) + assertNotEquals(SentryId.EMPTY_ID, chunkId) + mocks.executor.runAll() + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) +} + +fun IContinuousProfiler.testMultipleStartsAcceptedInTraceMode(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + assertTrue(isRunning) + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + assertTrue(isRunning) + + stopProfiler(ProfileLifecycle.TRACE) + mocks.executor.runAll() + assertTrue(isRunning) + + stopProfiler(ProfileLifecycle.TRACE) + mocks.executor.runAll() + assertFalse(isRunning) +} + +fun IContinuousProfiler.testLogsWarningIfNotSampled(mocks: ProfilerMocks) { + whenever(mocks.tracesSampler.sampleSessionProfile(any())).thenReturn(false) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertFalse(isRunning) + verify(mocks.logger) + .log(eq(SentryLevel.DEBUG), eq("Profiler was not started due to sampling decision.")) +} + +fun IContinuousProfiler.testEvaluatesSessionSampleRateOnlyOnce(mocks: ProfilerMocks) { + verify(mocks.tracesSampler, never()).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) +} + +fun IContinuousProfiler.testReevaluateSamplingOnNextStart(mocks: ProfilerMocks) { + verify(mocks.tracesSampler, never()).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) + reevaluateSampling() + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(2)).sampleSessionProfile(any()) +} + +fun IContinuousProfiler.testStopsAndRestartsForEachChunk(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + val oldChunkId = chunkId + + mocks.executor.runAll() + verify(mocks.logger).log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) + assertTrue(isRunning) + + mocks.executor.runAll() + verify(mocks.logger, times(2)) + .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) + assertTrue(isRunning) + assertNotEquals(oldChunkId, chunkId) +} + +fun IContinuousProfiler.testSendsChunkOnRestart(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + mocks.executor.runAll() + verify(mocks.scopes, never()).captureProfileChunk(any()) + mocks.executor.runAll() + verify(mocks.scopes).captureProfileChunk(any()) +} + +fun IContinuousProfiler.testSendsChunkOnStop(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + mocks.executor.runAll() + verify(mocks.scopes, never()).captureProfileChunk(any()) + stopProfiler(ProfileLifecycle.MANUAL) + mocks.executor.runAll() + verify(mocks.scopes).captureProfileChunk(any()) +} + +fun IContinuousProfiler.testCloseWithoutTerminatingStopsAfterChunk(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + assertTrue(isRunning) + close(false) + assertTrue(isRunning) + mocks.executor.runAll() + assertFalse(isRunning) +} + +fun IContinuousProfiler.testDoesNotSendChunksAfterClose(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + close(true) + mocks.executor.runAll() + verify(mocks.scopes, never()).captureProfileChunk(any()) +} + +fun IContinuousProfiler.testStopsWhenRateLimited(mocks: ProfilerMocks) { + val rateLimiter = mock() + whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + (this as RateLimiter.IRateLimitObserver).onRateLimitChanged(rateLimiter) + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) + verify(mocks.logger).log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) +} + +fun IContinuousProfiler.testDoesNotStartWhenRateLimited(mocks: ProfilerMocks) { + val rateLimiter = mock() + whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) + whenever(mocks.scopes.rateLimiter).thenReturn(rateLimiter) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) + verify(mocks.logger).log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) +} + +fun IContinuousProfiler.testDoesNotStartWhenOffline(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) + verify(mocks.logger).log(eq(SentryLevel.WARNING), eq("Device is offline. Stopping profiler.")) +} + +fun IContinuousProfiler.testCanBeStartedAgainAfterStopCycle(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + stopProfiler(ProfileLifecycle.MANUAL) + mocks.executor.runAll() + assertFalse(isRunning) + + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + mocks.executor.runAll() + assertTrue(isRunning, "shouldStop must be reset on start") +} 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..091a75e1295 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 @@ -26,6 +31,7 @@ import io.sentry.test.getCtor import io.sentry.util.HintUtils import java.util.Locale import kotlin.test.BeforeTest +import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -82,6 +88,7 @@ class DefaultAndroidEventProcessorTest { context = ApplicationProvider.getApplicationContext() AppState.getInstance().resetInstance() DeviceInfoUtil.resetInstance() + CpuInfoUtils.getInstance().clear() } @Test @@ -482,8 +489,10 @@ class DefaultAndroidEventProcessorTest { } @Test + @Ignore("This test is flaky due to shared CpuInfoUtils instance") fun `Event sets no device cpu info when there is none provided`() { val sut = fixture.getSut(context) + sut.deviceInfoUtil?.get() CpuInfoUtils.getInstance().setCpuMaxFrequencies(emptyList()) assertNotNull(sut.process(SentryEvent(), Hint())) { val device = it.contexts.device!! @@ -495,6 +504,7 @@ class DefaultAndroidEventProcessorTest { @Test fun `Event sets rights device cpu info when there is one provided`() { val sut = fixture.getSut(context) + sut.deviceInfoUtil?.get() CpuInfoUtils.getInstance().setCpuMaxFrequencies(listOf(800, 900)) assertNotNull(sut.process(SentryEvent(), Hint())) { @@ -619,4 +629,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/DeviceInfoUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt index 6d90d6be538..faf993e1610 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt @@ -2,16 +2,22 @@ package io.sentry.android.core import android.content.Context import android.content.Intent +import android.content.res.Configuration import android.os.BatteryManager +import android.os.Build +import android.os.LocaleList import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.internal.util.CpuInfoUtils +import java.util.Locale +import java.util.TimeZone import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.runner.RunWith +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) class DeviceInfoUtilTest { @@ -47,6 +53,32 @@ class DeviceInfoUtilTest { assertNotNull(deviceInfo.memorySize) } + @Test + fun `sets default timezone`() { + val deviceInfoUtil = DeviceInfoUtil.getInstance(context, SentryAndroidOptions()) + val deviceInfo = deviceInfoUtil.collectDeviceInformation(false, false) + + assertEquals(TimeZone.getDefault(), deviceInfo.timezone) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `preserves timezone from locale unicode extension`() { + val defaultTimeZone = TimeZone.getDefault() + try { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + val configuration = Configuration(context.resources.configuration) + configuration.setLocales(LocaleList(Locale.forLanguageTag("en-US-u-tz-usnyc"))) + val localizedContext = context.createConfigurationContext(configuration) + val deviceInfoUtil = DeviceInfoUtil(localizedContext, SentryAndroidOptions()) + val deviceInfo = deviceInfoUtil.collectDeviceInformation(false, false) + + assertEquals("America/New_York", deviceInfo.timezone?.id) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + @Test fun `does include cpu data`() { CpuInfoUtils.getInstance().setCpuMaxFrequencies(listOf(1024)) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt index 97276d67566..0b13f4ca4d8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt @@ -14,6 +14,8 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -122,4 +124,19 @@ class EnvelopeFileObserverIntegrationTest { verify(fixture.logger) .log(eq(SentryLevel.DEBUG), eq("EnvelopeFileObserverIntegration installed.")) } + + @Test + fun `register creates the outbox dir when it does not exist yet`() { + val outboxDir = File(file, "outbox") + assertFalse(outboxDir.exists()) + + fixture.getSut { it.executorService = ImmediateExecutorService() } + val integration = + object : EnvelopeFileObserverIntegration() { + override fun getPath(options: SentryOptions): String = outboxDir.absolutePath + } + integration.register(fixture.scopes, fixture.scopes.options) + + assertTrue(outboxDir.isDirectory) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt new file mode 100644 index 00000000000..f2118fb76de --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt @@ -0,0 +1,492 @@ +package io.sentry.android.core + +import android.app.Activity +import android.app.Application +import android.app.Dialog +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Handler +import android.view.WindowManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.sentry.Scopes +import io.sentry.SentryFeedbackOptions +import io.sentry.test.DeferredExecutorService +import io.sentry.test.ImmediateExecutorService +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.isA +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class FeedbackShakeIntegrationTest { + + private class Fixture { + val application = mock() + val scopes = mock() + val options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + executorService = ImmediateExecutorService() + } + val activity = mock() + val formHandler = mock() + + init { + options.feedbackOptions.setFormHandler(formHandler) + } + + fun getSut(useShakeGesture: Boolean = true): FeedbackShakeIntegration { + options.feedbackOptions.isUseShakeGesture = useShakeGesture + return FeedbackShakeIntegration(application) + } + } + + private val fixture = Fixture() + + @BeforeTest + fun setup() { + CurrentActivityHolder.getInstance().clearActivity() + } + + @Test + fun `when useShakeGesture is enabled registers activity lifecycle callbacks`() { + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + verify(fixture.application).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `resolves the accelerometer sensor off the main thread`() { + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + // Callback registration stays synchronous, but the expensive SensorManager lookup is deferred. + verify(fixture.application).registerActivityLifecycleCallbacks(any()) + verify(fixture.application, never()).getSystemService(eq(Context.SENSOR_SERVICE)) + + deferredExecutor.runAll() + + verify(fixture.application).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `warm-up drained after close does not resolve the sensor`() { + // Integrations are closed before the executor drains, so a queued warm-up can run after + // close(). It must be a no-op rather than resolving the sensor and spinning up a HandlerThread. + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + sut.close() + + deferredExecutor.runAll() + + verify(fixture.application, never()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `re-registering after close re-arms shake detection`() { + // A second Sentry.init reusing the same integration must revive shake detection rather than + // stay off because of the closed latch. + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + sut.close() + sut.register(fixture.scopes, fixture.options) + + deferredExecutor.runAll() + + verify(fixture.application, atLeastOnce()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `when useShakeGesture is disabled does not register activity lifecycle callbacks`() { + val sut = fixture.getSut(useShakeGesture = false) + sut.register(fixture.scopes, fixture.options) + + verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `close unregisters activity lifecycle callbacks`() { + val sut = fixture.getSut() + sut.register(fixture.scopes, fixture.options) + + sut.close() + + verify(fixture.application).unregisterActivityLifecycleCallbacks(any()) + } + + @Test + fun `hooks into already-resumed activity on deferred init`() { + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut() + sut.register(fixture.scopes, fixture.options) + + // The integration should have attempted to start shake detection + // (it will fail gracefully because SensorManager is null in tests, + // but the important thing is it tried) + } + + @Test + fun `does not crash when no activity is available on deferred init`() { + val sut = fixture.getSut() + sut.register(fixture.scopes, fixture.options) + // Should not throw + } + + @Test + fun `onActivityPaused stops shake detection`() { + val sut = fixture.getSut() + sut.register(fixture.scopes, fixture.options) + + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + sut.onActivityResumed(fixture.activity) + sut.onActivityPaused(fixture.activity) + // Should not throw, shake detection stopped gracefully + } + + @Test + fun `close without register does not crash`() { + val sut = fixture.getSut() + sut.close() + } + + @Test + fun `register sets itself as shake controller even when useShakeGesture is disabled`() { + val sut = fixture.getSut(useShakeGesture = false) + sut.register(fixture.scopes, fixture.options) + + assertThat(fixture.options.feedbackOptions.shakeController).isSameInstanceAs(sut) + assertThat(sut.isOnShakeEnabled).isFalse() + } + + @Test + fun `enable after register starts shake detection at runtime`() { + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = false) + sut.register(fixture.scopes, fixture.options) + verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) + + sut.enableOnShake() + + assertThat(sut.isOnShakeEnabled).isTrue() + verify(fixture.application).registerActivityLifecycleCallbacks(any()) + // Hooks into the already-resumed activity + verify(fixture.activity).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `enable is idempotent`() { + val sut = fixture.getSut(useShakeGesture = false) + sut.register(fixture.scopes, fixture.options) + + sut.enableOnShake() + sut.enableOnShake() + + verify(fixture.application, times(1)).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `disable stops shake detection at runtime`() { + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + sut.disableOnShake() + + assertThat(sut.isOnShakeEnabled).isFalse() + verify(fixture.application).unregisterActivityLifecycleCallbacks(any()) + } + + @Test + fun `disable is idempotent`() { + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + sut.disableOnShake() + sut.disableOnShake() + + verify(fixture.application, times(1)).unregisterActivityLifecycleCallbacks(any()) + } + + @Test + fun `disable when never enabled does not unregister callbacks`() { + val sut = fixture.getSut(useShakeGesture = false) + sut.register(fixture.scopes, fixture.options) + + sut.disableOnShake() + + verify(fixture.application, never()).unregisterActivityLifecycleCallbacks(any()) + } + + @Test + fun `enable before register is a no-op`() { + val sut = fixture.getSut(useShakeGesture = false) + + sut.enableOnShake() + + assertThat(sut.isOnShakeEnabled).isFalse() + verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `re-enable after disable re-arms shake detection`() { + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + sut.disableOnShake() + sut.enableOnShake() + + deferredExecutor.runAll() + + assertThat(sut.isOnShakeEnabled).isTrue() + verify(fixture.application, atLeastOnce()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `close disables shake detection`() { + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + sut.close() + + assertThat(sut.isOnShakeEnabled).isFalse() + } + + @Test + fun `a visible dialog does not tear down the detection machinery`() { + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + val dialog = mock

    () + sut.onDialogVisible(fixture.activity, dialog) + sut.onDialogGone(dialog) + + verify(fixture.application, never()).unregisterActivityLifecycleCallbacks(any()) + assertThat(sut.isOnShakeEnabled).isTrue() + } + + @Test + fun `a dialog suppresses detection on the activity it belongs to`() { + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + sut.onDialogVisible(fixture.activity, mock()) + assertThat(sut.dialogActivity).isSameInstanceAs(fixture.activity) + + // Coming back to the activity the dialog is on (e.g. screen off/on) must not re-arm detection, + // otherwise a shake would stack a second dialog on top of the visible one. + sut.onActivityResumed(fixture.activity) + + verify(fixture.activity, never()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `a dialog on a backgrounded activity does not suppress detection on the next one`() { + // A dialog lives in the window of the activity that created it, so once that activity is no + // longer resumed the dialog cannot be seen - it must not keep detection off on the activity + // now in front. Android's order is A.onPause() -> B.onResume(), so exercise exactly that. + val otherActivity = mock() + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + whenever(otherActivity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + sut.onActivityResumed(fixture.activity) + sut.onDialogVisible(fixture.activity, mock()) + + sut.onActivityPaused(fixture.activity) + sut.onActivityResumed(otherActivity) + + verify(otherActivity).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `a dialog reports the activity it is showing on, not the current one`() { + // The dialog's host activity is what a stacked dialog would land on, so a mid-transition + // CurrentActivityHolder must not decide which activity detection is suppressed for. + val otherActivity = mock() + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + CurrentActivityHolder.getInstance().setActivity(otherActivity) + sut.onDialogVisible(fixture.activity, mock()) + + assertThat(sut.dialogActivity).isSameInstanceAs(fixture.activity) + + sut.onActivityResumed(fixture.activity) + + verify(fixture.activity, never()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `dismissing a dialog re-arms detection on the current activity`() { + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + sut.onActivityResumed(fixture.activity) + val dialog = mock() + sut.onDialogVisible(fixture.activity, dialog) + sut.onDialogGone(dialog) + + assertThat(sut.dialogActivity).isNull() + verify(fixture.activity, atLeastOnce()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `dismissing one of two visible dialogs keeps detection suppressed`() { + // Two dialogs can be visible at once, e.g. when the app calls showForm() while a dialog is + // already up. The first one going away must not re-arm detection under the second. + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + sut.onActivityResumed(fixture.activity) + val first = mock() + val second = mock() + sut.onDialogVisible(fixture.activity, first) + sut.onDialogVisible(fixture.activity, second) + + sut.onDialogGone(first) + assertThat(sut.dialogActivity).isSameInstanceAs(fixture.activity) + verify(fixture.activity, times(1)).getSystemService(eq(Context.SENSOR_SERVICE)) + + sut.onDialogGone(second) + assertThat(sut.dialogActivity).isNull() + verify(fixture.activity, times(2)).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `reporting the same dialog gone twice re-arms detection only once`() { + // A dismissed dialog reports back from both onStop() and onDetachedFromWindow(). + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + sut.onActivityResumed(fixture.activity) + val dialog = mock() + sut.onDialogVisible(fixture.activity, dialog) + sut.onDialogGone(dialog) + sut.onDialogGone(dialog) + + // Once for the resume, once for the single re-arm - the second report is a no-op. + verify(fixture.activity, times(2)).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `a dialog that fails to show does not leave detection suppressed`() { + // Dialog.show() runs onStart() - which reports the dialog as visible and stops detection - + // before the window is added, so an addView() failure hits with the dialog already tracked + // and no lifecycle callback left to report it gone. + val sensorManager = mock() + val accelerometer = mock() + whenever(fixture.activity.getSystemService(Context.SENSOR_SERVICE)).thenReturn(sensorManager) + whenever(sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER, false)) + .thenReturn(accelerometer) + whenever(fixture.activity.runOnUiThread(any())).thenAnswer { + (it.arguments[0] as Runnable).run() + null + } + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + sut.onActivityResumed(fixture.activity) + + val dialog = mock() + doAnswer { + sut.onDialogVisible(fixture.activity, dialog) + throw WindowManager.BadTokenException("Unable to add window") + } + .whenever(dialog) + .show() + sut.setDialogFactory { dialog } + + val listener = argumentCaptor() + verify(sensorManager) + .registerListener( + listener.capture(), + eq(accelerometer), + eq(SensorManager.SENSOR_DELAY_NORMAL), + isA(), + ) + shake(listener.lastValue) + + verify(dialog).show() + assertThat(sut.dialogActivity).isNull() + verify(sensorManager, times(2)) + .registerListener( + any(), + eq(accelerometer), + eq(SensorManager.SENSOR_DELAY_NORMAL), + isA(), + ) + } + + private fun shake(listener: SensorEventListener) { + val baseTimestamp = 1_000_000_000L + val intervalNs = 20_000_000L + for (i in 0 until 20) { + listener.onSensorChanged( + createSensorEvent(floatArrayOf(20f, 0f, 0f), baseTimestamp + i * intervalNs) + ) + } + } + + private fun createSensorEvent(values: FloatArray, timestamp: Long): SensorEvent { + val sensor = mock() + whenever(sensor.type).thenReturn(Sensor.TYPE_ACCELEROMETER) + + val constructor = SensorEvent::class.java.getDeclaredConstructor(Int::class.javaPrimitiveType) + constructor.isAccessible = true + val event = constructor.newInstance(values.size) + values.copyInto(event.values) + SensorEvent::class.java.getField("sensor").set(event, sensor) + SensorEvent::class.java.getField("timestamp").set(event, timestamp) + return event + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 5149f167129..ce518eabb05 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -7,6 +7,7 @@ import io.sentry.IScope import io.sentry.IScopes import io.sentry.ReplayController import io.sentry.ScopeCallback +import io.sentry.SentryExecutorService import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.Session @@ -32,7 +33,8 @@ class LifecycleWatcherTest { private class Fixture { val scopes = mock() val dateProvider = mock() - val options = SentryOptions() + // a real executor so scheduled end-session tasks actually run + val options = SentryOptions().apply { setTimerExecutorService(SentryExecutorService(this)) } val replayController = mock() val continuousProfiler = mock() @@ -115,10 +117,10 @@ class LifecycleWatcherTest { watcher.onForeground() watcher.onBackground() - assertNotNull(watcher.timerTask) + assertNotNull(watcher.endSessionFuture) watcher.onForeground() - assertNull(watcher.timerTask) + assertNull(watcher.endSessionFuture) verify(fixture.scopes, never()).endSession() verify(fixture.replayController, never()).stop() @@ -186,13 +188,6 @@ class LifecycleWatcherTest { verify(fixture.scopes, never()).addBreadcrumb(any()) } - @Test - fun `timer is created if session tracking is enabled`() { - val watcher = - fixture.getSUT(enableAutoSessionTracking = true, enableAppLifecycleBreadcrumbs = false) - assertNotNull(watcher.timer) - } - @Test fun `if the scopes has already a fresh session running, don't start new one`() { val watcher = @@ -282,7 +277,8 @@ class LifecycleWatcherTest { @Test fun `background-foreground replay`() { whenever(fixture.dateProvider.currentTimeMillis).thenReturn(1L) - val watcher = fixture.getSUT(sessionIntervalMillis = 2L, enableAppLifecycleBreadcrumbs = false) + val watcher = + fixture.getSUT(sessionIntervalMillis = 500L, enableAppLifecycleBreadcrumbs = false) watcher.onForeground() verify(fixture.replayController).start() 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 5ad197d829d..d67a869eff0 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 @@ -187,6 +187,31 @@ class ManifestMetadataReaderTest { assertNull(fixture.options.release) } + @Test + fun `applyMetadata reads dist to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.DIST to "test-dist") + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals("test-dist", fixture.options.dist) + } + + @Test + fun `applyMetadata reads dist and keep default value if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertNull(fixture.options.dist) + } + @Test fun `applyMetadata reads session tracking interval to options`() { // Arrange @@ -263,6 +288,156 @@ class ManifestMetadataReaderTest { assertEquals(false, fixture.options.isAttachAnrThreadDump) } + @Test + fun `applyMetadata reads app hang tracking enabled to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.NDK_APP_HANG_TRACKING_ENABLE to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(true, fixture.options.isEnableNdkAppHangTracking) + } + + @Test + fun `applyMetadata reads app hang tracking enabled to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(false, fixture.options.isEnableNdkAppHangTracking) + } + + @Test + fun `applyMetadata reads app hang timeout interval to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.NDK_APP_HANG_TIMEOUT_INTERVAL_MILLIS to 1000) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(1000.toLong(), fixture.options.ndkAppHangTimeoutIntervalMillis) + } + + @Test + fun `applyMetadata reads app hang timeout interval to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(5000.toLong(), fixture.options.ndkAppHangTimeoutIntervalMillis) + } + + @Test + fun `applyMetadata reads tombstone attach raw to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.TOMBSTONE_ATTACH_RAW to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(true, fixture.options.isAttachRawTombstone) + } + + @Test + fun `applyMetadata reads tombstone attach raw to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(false, fixture.options.isAttachRawTombstone) + } + + @Test + fun `applyMetadata reads tombstone enable to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.TOMBSTONE_ENABLE to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(true, fixture.options.isTombstoneEnabled) + } + + @Test + fun `applyMetadata reads tombstone enable to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(false, fixture.options.isTombstoneEnabled) + } + + @Test + fun `applyMetadata reads tombstone report historical to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.TOMBSTONE_REPORT_HISTORICAL to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(true, fixture.options.isReportHistoricalTombstones) + } + + @Test + fun `applyMetadata reads tombstone report historical to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(false, fixture.options.isReportHistoricalTombstones) + } + + @Test + fun `applyMetadata reads anr report historical to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ANR_REPORT_HISTORICAL to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(true, fixture.options.isReportHistoricalAnrs) + } + + @Test + fun `applyMetadata reads anr report historical to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(false, fixture.options.isReportHistoricalAnrs) + } + @Test fun `applyMetadata reads activity breadcrumbs to options`() { // Arrange @@ -646,6 +821,32 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isEnableScopeSync) } + @Test + fun `applyMetadata reads nativeSdkName to options`() { + // Arrange + val expectedValue = "sentry.native.android.unity" + val bundle = bundleOf(ManifestMetadataReader.NDK_SDK_NAME to expectedValue) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(expectedValue, fixture.options.nativeSdkName) + } + + @Test + fun `applyMetadata reads nativeSdkName and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertNull(fixture.options.nativeSdkName) + } + @Test fun `applyMetadata reads tracesSampleRate from metadata`() { // Arrange @@ -1158,6 +1359,31 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isCollectAdditionalContext) } + @Test + fun `applyMetadata reads collect external storage to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.COLLECT_EXTERNAL_STORAGE_CONTEXT to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isCollectExternalStorageContext) + } + + @Test + fun `applyMetadata reads collect external storage and keep default value if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.isCollectExternalStorageContext) + } + @Test fun `applyMetadata reads send default pii and keep default value if not found`() { // Arrange @@ -1366,6 +1592,36 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isEnablePerformanceV2) } + @Test + fun `applyMetadata reads standalone app start tracing flag to options`() { + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to true) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertTrue(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing false to options`() { + fixture.options.isEnableStandaloneAppStartTracing = true + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to false) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing flag to options and keeps default if not found`() { + val context = fixture.getContext() + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + @Test fun `applyMetadata reads startupProfiling flag to options`() { // Arrange @@ -1393,6 +1649,31 @@ class ManifestMetadataReaderTest { assertFalse(fixture.options.isEnableAppStartProfiling) } + @Test + fun `applyMetadata reads enableLegacyProfiling flag to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ENABLE_LEGACY_PROFILING to false) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.isEnableLegacyProfiling) + } + + @Test + fun `applyMetadata reads enableLegacyProfiling flag to options and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnableLegacyProfiling) + } + @Test fun `applyMetadata reads enableScopePersistence flag to options`() { // Arrange @@ -1686,6 +1967,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 @@ -1835,4 +2154,591 @@ class ManifestMetadataReaderTest { // Assert assertFalse(fixture.options.feedbackOptions.isShowBranding) } + + @Test + fun `applyMetadata reads feedback use shake gesture and keep default value if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.feedbackOptions.isUseShakeGesture) + } + + @Test + fun `applyMetadata reads feedback use shake gesture to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.FEEDBACK_USE_SHAKE_GESTURE to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.feedbackOptions.isUseShakeGesture) + } + + @Test + fun `applyMetadata reads screenshot strategy canvas to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_SCREENSHOT_STRATEGY to "canvas") + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals( + io.sentry.ScreenshotStrategyType.CANVAS, + fixture.options.sessionReplay.screenshotStrategy, + ) + } + + @Test + fun `applyMetadata reads screenshot strategy and defaults to PIXEL_COPY for unknown value`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_SCREENSHOT_STRATEGY to "unknown") + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals( + io.sentry.ScreenshotStrategyType.PIXEL_COPY, + fixture.options.sessionReplay.screenshotStrategy, + ) + } + + @Test + fun `applyMetadata reads screenshot strategy and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals( + io.sentry.ScreenshotStrategyType.PIXEL_COPY, + fixture.options.sessionReplay.screenshotStrategy, + ) + } + + @Test + fun `applyMetadata reads capture-surface-views to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_CAPTURE_SURFACE_VIEWS to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.sessionReplay.isCaptureSurfaceViews) + } + + @Test + fun `applyMetadata reads capture-surface-views and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.sessionReplay.isCaptureSurfaceViews) + } + + @Test + fun `applyMetadata reads anrProfilingSampleRate to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ANR_PROFILING_SAMPLE_RATE to 0.5f) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(0.5, fixture.options.anrProfilingSampleRate!!, 0.01) + } + + @Test + fun `applyMetadata keeps anrProfilingSampleRate default when not set in manifest`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertNull(fixture.options.anrProfilingSampleRate) + } + + @Test + fun `applyMetadata reads enableAnrFingerprinting to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ENABLE_ANR_FINGERPRINTING to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnableAnrFingerprinting) + } + + @Test + fun `applyMetadata keeps enableAnrFingerprinting default when not set in manifest`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnableAnrFingerprinting) + } + + // Network Detail Configuration Tests + + @Test + fun `applyMetadata reads comma-separated networkDetailAllowUrls from manifest`() { + // Arrange + val expectedUrls = "https://api.example.com/.*,https://cdn.example.com/.*" + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_NETWORK_DETAIL_ALLOW_URLS to expectedUrls) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val urls = fixture.options.sessionReplay.networkDetailAllowUrls + assertEquals(2, urls.size) + assertEquals("https://api.example.com/.*", urls[0]) + assertEquals("https://cdn.example.com/.*", urls[1]) + } + + @Test + fun `applyMetadata keeps empty networkDetailAllowUrls when not present`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(0, fixture.options.sessionReplay.networkDetailAllowUrls.size) + } + + @Test + fun `applyMetadata reads comma-separated networkDetailDenyUrls from manifest`() { + // Arrange + val expectedUrls = "https://private.example.com/.*,https://internal.example.com/.*" + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_NETWORK_DETAIL_DENY_URLS to expectedUrls) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val urls = fixture.options.sessionReplay.networkDetailDenyUrls + assertEquals(2, urls.size) + assertEquals("https://private.example.com/.*", urls[0]) + assertEquals("https://internal.example.com/.*", urls[1]) + } + + @Test + fun `applyMetadata keeps empty networkDetailDenyUrls when not present`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(0, fixture.options.sessionReplay.networkDetailDenyUrls.size) + } + + @Test + fun `applyMetadata reads networkCaptureBodies from manifest`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_NETWORK_CAPTURE_BODIES to false) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.sessionReplay.isNetworkCaptureBodies) + } + + @Test + fun `applyMetadata keeps default networkCaptureBodies as true when not present`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.sessionReplay.isNetworkCaptureBodies) + } + + @Test + fun `applyMetadata keeps the default networkRequestHeaders`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val headers = fixture.options.sessionReplay.networkRequestHeaders + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + + // Should have exactly the default headers + assertEquals(defaultHeaders.size, headers.size) + defaultHeaders.forEach { defaultHeader -> assertTrue(headers.contains(defaultHeader)) } + } + + @Test + fun `applyMetadata reads networkRequestHeaders from manifest`() { + // Arrange + val expectedHeaders = "Authorization,X-Custom-Header,X-Request-Id" + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_NETWORK_REQUEST_HEADERS to expectedHeaders) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val allHeaders = fixture.options.sessionReplay.networkRequestHeaders + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + + // Should include default headers + additional headers + defaultHeaders.forEach { defaultHeader -> + assertTrue(allHeaders.contains(defaultHeader)) // default + } + assertTrue(allHeaders.contains("Authorization")) // additional + assertTrue(allHeaders.contains("X-Custom-Header")) // additional + assertTrue(allHeaders.contains("X-Request-Id")) // additional + } + + @Test + fun `applyMetadata keeps the default networkResponseHeaders`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val headers = fixture.options.sessionReplay.networkResponseHeaders + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + + // Should have exactly the default headers + assertEquals(defaultHeaders.size, headers.size) + defaultHeaders.forEach { defaultHeader -> assertTrue(headers.contains(defaultHeader)) } + } + + @Test + fun `applyMetadata reads networkResponseHeaders from manifest`() { + // Arrange + val expectedHeaders = "X-Response-Time,X-Cache-Status,X-Server-Id" + val bundle = + bundleOf(ManifestMetadataReader.REPLAYS_NETWORK_RESPONSE_HEADERS to expectedHeaders) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val allHeaders = fixture.options.sessionReplay.networkResponseHeaders + // Should include default headers + additional headers + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + defaultHeaders.forEach { defaultHeader -> assertTrue(allHeaders.contains(defaultHeader)) } + assertTrue(allHeaders.contains("X-Response-Time")) // additional + assertTrue(allHeaders.contains("X-Cache-Status")) // additional + assertTrue(allHeaders.contains("X-Server-Id")) // additional + } + + @Test + fun `applyMetadata skips empty strings for networkDetailAllowUrls and networkDetailDenyUrls`() { + // Arrange + val bundle = + bundleOf( + ManifestMetadataReader.REPLAYS_NETWORK_DETAIL_ALLOW_URLS to ", ", + ManifestMetadataReader.REPLAYS_NETWORK_DETAIL_DENY_URLS to " ,, ", + ) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(0, fixture.options.sessionReplay.networkDetailAllowUrls.size) + assertEquals(0, fixture.options.sessionReplay.networkDetailDenyUrls.size) + } + + @Test + fun `applyMetadata skips empty strings for networkRequestHeaders and networkResponseHeaders`() { + // Arrange + val bundle = + bundleOf( + ManifestMetadataReader.REPLAYS_NETWORK_REQUEST_HEADERS to ",", + ManifestMetadataReader.REPLAYS_NETWORK_RESPONSE_HEADERS to " ,", + ) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + // Should still have default headers even with empty string + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + + val requestHeaders = fixture.options.sessionReplay.networkRequestHeaders + assertEquals(defaultHeaders.size, requestHeaders.size) + defaultHeaders.forEach { defaultHeader -> assertTrue(requestHeaders.contains(defaultHeader)) } + + val responseHeaders = fixture.options.sessionReplay.networkResponseHeaders + assertEquals(defaultHeaders.size, responseHeaders.size) + defaultHeaders.forEach { defaultHeader -> assertTrue(responseHeaders.contains(defaultHeader)) } + } + + @Test + fun `applyMetadata trims whitespace from network URLs`() { + // Arrange + val bundle = + bundleOf( + ManifestMetadataReader.REPLAYS_NETWORK_DETAIL_ALLOW_URLS to + " https://api.example.com/.* , https://cdn.example.com/.* " + ) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val urls = fixture.options.sessionReplay.networkDetailAllowUrls + assertEquals(2, urls.size) + assertEquals("https://api.example.com/.*", urls[0]) + assertEquals("https://cdn.example.com/.*", urls[1]) + } + + @Test + fun `applyMetadata trims whitespace from network headers`() { + // Arrange + val bundle = + bundleOf( + ManifestMetadataReader.REPLAYS_NETWORK_REQUEST_HEADERS to + " Authorization , X-Custom-Header " + ) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val headers = fixture.options.sessionReplay.networkRequestHeaders + assertTrue(headers.contains("Authorization")) + assertTrue(headers.contains("X-Custom-Header")) + } + + // Spotlight Configuration Tests + + @Test + fun `applyMetadata reads spotlight enabled and keeps default value if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.isEnableSpotlight) + } + + @Test + fun `applyMetadata reads spotlight enabled to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.SPOTLIGHT_ENABLE to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnableSpotlight) + } + + @Test + fun `applyMetadata reads spotlight url and keeps null if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertNull(fixture.options.spotlightConnectionUrl) + } + + @Test + fun `applyMetadata reads spotlight url to options`() { + // Arrange + val expectedUrl = "http://10.0.2.2:8969/stream" + val bundle = bundleOf(ManifestMetadataReader.SPOTLIGHT_CONNECTION_URL to expectedUrl) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(expectedUrl, fixture.options.spotlightConnectionUrl) + } + + @Test + fun `applyMetadata reads both spotlight enabled and url to options`() { + // Arrange + val expectedUrl = "http://localhost:8969/stream" + val bundle = + bundleOf( + ManifestMetadataReader.SPOTLIGHT_ENABLE to true, + ManifestMetadataReader.SPOTLIGHT_CONNECTION_URL to expectedUrl, + ) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnableSpotlight) + assertEquals(expectedUrl, fixture.options.spotlightConnectionUrl) + } + + // Screenshot masking tests + + @Test + fun `applyMetadata reads screenshot mask-all-text to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.SCREENSHOT_MASK_ALL_TEXT to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.screenshot.maskViewClasses.contains("android.widget.TextView")) + } + + @Test + fun `applyMetadata reads screenshot mask-all-images to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.SCREENSHOT_MASK_ALL_IMAGES to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.screenshot.maskViewClasses.contains("android.widget.ImageView")) + } + + @Test + fun `applyMetadata without specifying screenshot mask-all-text, stays false`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.screenshot.maskViewClasses.contains("android.widget.TextView")) + } + + @Test + fun `applyMetadata without specifying screenshot mask-all-images, stays false`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.screenshot.maskViewClasses.contains("android.widget.ImageView")) + } + + @Test + fun `applyMetadata reads both screenshot masking options`() { + // Arrange + val bundle = + bundleOf( + ManifestMetadataReader.SCREENSHOT_MASK_ALL_TEXT to true, + ManifestMetadataReader.SCREENSHOT_MASK_ALL_IMAGES to true, + ) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.screenshot.maskViewClasses.contains("android.widget.TextView")) + assertTrue(fixture.options.screenshot.maskViewClasses.contains("android.widget.ImageView")) + // maskAllImages should also add WebView + assertTrue(fixture.options.screenshot.maskViewClasses.contains("android.webkit.WebView")) + } + + @Test + fun `applyMetadata reads strictTraceContinuation and keeps default value if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.isStrictTraceContinuation) + } + + @Test + fun `applyMetadata reads strictTraceContinuation to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.STRICT_TRACE_CONTINUATION to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isStrictTraceContinuation) + } + + @Test + fun `applyMetadata reads orgId and keeps null if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertNull(fixture.options.orgId) + } + + @Test + fun `applyMetadata reads orgId to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ORG_ID to "12345") + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals("12345", fixture.options.orgId) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt new file mode 100644 index 00000000000..243c20a2069 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt @@ -0,0 +1,191 @@ +package io.sentry.android.core + +import io.sentry.DateUtils +import java.io.File +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.mock + +class NativeEventCollectorTest { + + @get:Rule val tmpDir = TemporaryFolder() + + class Fixture { + lateinit var outboxDir: File + + val options = + SentryAndroidOptions().apply { + setLogger(mock()) + isDebug = true + } + + fun getSut(tmpDir: TemporaryFolder): NativeEventCollector { + outboxDir = File(tmpDir.root, "outbox") + outboxDir.mkdirs() + options.cacheDirPath = tmpDir.root.absolutePath + return NativeEventCollector(options) + } + } + + private val fixture = Fixture() + + @Test + fun `collects native event from outbox`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("native-event.txt") + + val timestamp = DateUtils.getDateTime("2023-07-15T10:30:00.000Z").time + val match = sut.findAndRemoveMatchingNativeEvent(timestamp) + assertNotNull(match) + } + + @Test + fun `does not collect java platform event`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("java-event.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `does not collect session-only envelope`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("session-only.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `collects native event after skipping attachment`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("native-with-attachment.txt") + + val timestamp = DateUtils.getDateTime("2023-07-15T11:45:30.500Z").time + val match = sut.findAndRemoveMatchingNativeEvent(timestamp) + assertNotNull(match) + } + + @Test + fun `handles empty file without throwing`() { + val sut = fixture.getSut(tmpDir) + File(fixture.outboxDir, "empty.envelope").writeText("") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `handles malformed envelope without throwing`() { + val sut = fixture.getSut(tmpDir) + File(fixture.outboxDir, "malformed.envelope").writeText("this is not a valid envelope") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `handles envelope with event and attachments without throwing`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("event-attachment.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `handles transaction envelope without throwing`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("transaction.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `handles session envelope without throwing`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("session.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `handles feedback envelope without throwing`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("feedback.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `handles attachment-only envelope without throwing`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("attachment.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `collects multiple native events`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("native-event.txt") + copyEnvelopeToOutbox("native-with-attachment.txt") + + val timestamp1 = DateUtils.getDateTime("2023-07-15T10:30:00.000Z").time + val timestamp2 = DateUtils.getDateTime("2023-07-15T11:45:30.500Z").time + val match1 = sut.findAndRemoveMatchingNativeEvent(timestamp1) + val match2 = sut.findAndRemoveMatchingNativeEvent(timestamp2) + assertNotNull(match1) + assertNotNull(match2) + } + + @Test + fun `collects native event that follows large java event in same envelope`() { + // This test verifies that BoundedInputStream.close() correctly handles being + // called multiple times (once by InputStreamReader.close() and once by + // try-with-resources). With a large payload (>8KB buffer), there will be + // remaining bytes after early JSON parsing exit, and double-close would + // corrupt the stream position if remaining isn't reset. + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("java-then-native-large.txt") + + val timestamp = DateUtils.getDateTime("2023-07-15T10:31:00.000Z").time + val match = sut.findAndRemoveMatchingNativeEvent(timestamp) + assertNotNull(match) + } + + @Test + fun `ignores non-native events when collecting multiple envelopes`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("native-event.txt") + copyEnvelopeToOutbox("java-event.txt") + copyEnvelopeToOutbox("transaction.txt") + copyEnvelopeToOutbox("session.txt") + + val timestamp = DateUtils.getDateTime("2023-07-15T10:30:00.000Z").time + val nativeMatch = sut.findAndRemoveMatchingNativeEvent(timestamp) + assertNotNull(nativeMatch) + + // No other matches (already removed) + val noMatch = sut.findAndRemoveMatchingNativeEvent(timestamp) + assertNull(noMatch) + } + + private fun copyEnvelopeToOutbox(name: String): File { + val resourcePath = "envelopes/$name" + val inputStream = + javaClass.classLoader?.getResourceAsStream(resourcePath) + ?: throw IllegalArgumentException("Resource not found: $resourcePath") + val outFile = File(fixture.outboxDir, name) + inputStream.use { input -> outFile.outputStream().use { output -> input.copyTo(output) } } + return outFile + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt index 4f6ba7fc5f0..711f5f7fe0b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt @@ -5,7 +5,6 @@ import android.net.Network import android.net.NetworkCapabilities import android.os.Build import io.sentry.Breadcrumb -import io.sentry.DateUtils import io.sentry.IScopes import io.sentry.ISentryExecutorService import io.sentry.SentryDateProvider @@ -54,8 +53,9 @@ class NetworkBreadcrumbsIntegrationTest { executorService = executor isEnableNetworkEventBreadcrumbs = enableNetworkEventBreadcrumbs dateProvider = SentryDateProvider { - val nowNanos = TimeUnit.MILLISECONDS.toNanos(nowMs ?: System.currentTimeMillis()) - SentryNanotimeDate(DateUtils.nanosToDate(nowNanos), nowNanos) + val nowMillis = nowMs ?: System.currentTimeMillis() + val nowNanos = TimeUnit.MILLISECONDS.toNanos(nowMillis) + SentryNanotimeDate(nowMillis, nowNanos) } } return NetworkBreadcrumbsIntegration(context, buildInfo) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt new file mode 100644 index 00000000000..2f76e73108f --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt @@ -0,0 +1,223 @@ +package io.sentry.android.core + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IConnectionStatusProvider +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ProfileLifecycle +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.TracesSampler +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector +import io.sentry.test.DeferredExecutorService +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class PerfettoContinuousProfilerTest { + private lateinit var context: Context + private val fixture = Fixture() + private lateinit var mocks: ProfilerMocks + + private class Fixture { + private val mockDsn = "http://key@localhost/proj" + val executor = DeferredExecutorService() + val mockedSentry = mockStatic(Sentry::class.java) + val mockLogger = mock() + val mockTracesSampler = mock() + val mockPerfettoProfiler = mock() + val frameMetricsCollector: SentryFrameMetricsCollector = mock() + + val scopes: IScopes = mock() + + val options = + spy(SentryAndroidOptions()).apply { + dsn = mockDsn + profilesSampleRate = 1.0 + isDebug = true + setLogger(mockLogger) + } + + val mockTraceFile = + java.io.File.createTempFile("test-trace", ".pftrace").apply { + writeBytes(byteArrayOf(0x50, 0x65, 0x72, 0x66)) + deleteOnExit() + } + + init { + whenever(mockTracesSampler.sampleSessionProfile(any())).thenReturn(true) + whenever(mockPerfettoProfiler.start(any())).thenReturn(true) + doAnswer { invocation -> + val listener = invocation.getArgument>(0) + listener.accept(mockTraceFile) + null + } + .whenever(mockPerfettoProfiler) + .endAndCollect(any()) + } + + fun getSut( + optionConfig: ((options: SentryAndroidOptions) -> Unit) = {} + ): PerfettoContinuousProfiler { + options.executorService = executor + optionConfig(options) + whenever(scopes.options).thenReturn(options) + return PerfettoContinuousProfiler( + mockLogger, + frameMetricsCollector, + { options.executorService }, + { mockPerfettoProfiler }, + ) + } + } + + @BeforeTest + fun `set up`() { + context = ApplicationProvider.getApplicationContext() + Sentry.setCurrentScopes(fixture.scopes) + fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + mocks = + ProfilerMocks(fixture.executor, fixture.mockTracesSampler, fixture.mockLogger, fixture.scopes) + } + + @AfterTest + fun clear() { + fixture.mockedSentry.close() + } + + // -- Shared tests (see ContinuousProfilerTestCases.kt) -- + + @Test + fun `isRunning reflects profiler status`() = fixture.getSut().testIsRunningReflectsStatus(mocks) + + @Test + fun `stopProfiler stops the profiler after chunk is finished`() = + fixture.getSut().testStopProfilerStopsAfterChunkFinished(mocks) + + @Test + fun `profiler multiple starts are accepted in trace mode`() = + fixture.getSut().testMultipleStartsAcceptedInTraceMode(mocks) + + @Test + fun `profiler logs a warning on start if not sampled`() = + fixture.getSut().testLogsWarningIfNotSampled(mocks) + + @Test + fun `profiler evaluates sessionSampleRate only the first time`() = + fixture.getSut().testEvaluatesSessionSampleRateOnlyOnce(mocks) + + @Test + fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() = + fixture.getSut().testReevaluateSamplingOnNextStart(mocks) + + @Test + fun `profiler ignores profilesSampleRate`() { + val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + } + + @Test + fun `profiler stops and restart for each chunk`() = + fixture.getSut().testStopsAndRestartsForEachChunk(mocks) + + @Test + fun `profiler sends chunk on each restart`() = fixture.getSut().testSendsChunkOnRestart(mocks) + + @Test fun `profiler sends another chunk on stop`() = fixture.getSut().testSendsChunkOnStop(mocks) + + @Test + fun `close without terminating stops all profiles after chunk is finished`() = + fixture.getSut().testCloseWithoutTerminatingStopsAfterChunk(mocks) + + @Test + fun `profiler does not send chunks after close`() = + fixture.getSut().testDoesNotSendChunksAfterClose(mocks) + + @Test fun `profiler stops when rate limited`() = fixture.getSut().testStopsWhenRateLimited(mocks) + + @Test + fun `profiler does not start when rate limited`() = + fixture.getSut().testDoesNotStartWhenRateLimited(mocks) + + @Test + fun `profiler does not start when offline`() = + fixture + .getSut { + it.connectionStatusProvider = mock { provider -> + whenever(provider.connectionStatus) + .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) + } + } + .testDoesNotStartWhenOffline(mocks) + + @Test + fun `manual profiler can be started again after a full start-stop cycle`() = + fixture.getSut().testCanBeStartedAgainAfterStopCycle(mocks) + + // -- Perfetto-specific tests -- + + @Test + fun `async chunk callback does not restart when stop requested while pending`() { + val profiler = fixture.getSut() + + // Defer the endAndCollect listener to simulate the OS delivering the trace asynchronously, + // after the chunk timer already captured the (then-true) restart decision. + var pendingListener: java.util.function.Consumer? = null + doAnswer { invocation -> + pendingListener = invocation.getArgument(0) + null + } + .whenever(fixture.mockPerfettoProfiler) + .endAndCollect(any()) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + + // Chunk timer fires: stopInternal(true) captures shouldRestart=true and calls endAndCollect, + // but the listener is held pending instead of firing inline. + fixture.executor.runAll() + assertFalse(profiler.isRunning) + assertNotNull(pendingListener) + + // A stop is requested while the async callback is still pending. + profiler.stopProfiler(ProfileLifecycle.MANUAL) + + // The OS now delivers the trace. The callback must honor the late stop and not restart. + pendingListener!!.accept(fixture.mockTraceFile) + fixture.executor.runAll() + assertFalse( + profiler.isRunning, + "profiler must not restart when a stop was requested while the callback was pending", + ) + } + + @Test + fun `profiler multiple starts are ignored in manual mode`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + verify(fixture.mockLogger) + .log( + eq(SentryLevel.WARNING), + eq("Unexpected call to startProfiler(MANUAL) while profiler already running. Skipping."), + ) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt new file mode 100644 index 00000000000..0746d36dfff --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt @@ -0,0 +1,268 @@ +package io.sentry.android.core + +import android.content.Context +import android.os.ProfilingManager +import android.os.ProfilingResult +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ILogger +import io.sentry.test.DeferredExecutorService +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import java.util.function.Consumer +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class PerfettoProfilerTest { + + private lateinit var context: Context + private val mockLogger = mock() + private val executor = DeferredExecutorService() + + private lateinit var capturedCallback: Consumer + + private val mockProfilingManager = + mock().also { manager -> + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + capturedCallback = invocation.getArgument(5) as Consumer + null + } + .whenever(manager) + .requestProfiling(any(), any(), any(), any(), any(), any()) + } + + @BeforeTest + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + private fun getSut(profilingManager: ProfilingManager? = mockProfilingManager): PerfettoProfiler { + return PerfettoProfiler(mockLogger, executor, profilingManager) + } + + private fun createTraceFile(): File { + return File.createTempFile("test-trace", ".pftrace").apply { + writeBytes(byteArrayOf(0x50, 0x65, 0x72, 0x66)) + deleteOnExit() + } + } + + private fun mockResult( + errorCode: Int = ProfilingResult.ERROR_NONE, + filePath: String? = null, + errorMessage: String? = null, + ): ProfilingResult { + return mock().also { + whenever(it.errorCode).thenReturn(errorCode) + whenever(it.resultFilePath).thenReturn(filePath) + whenever(it.errorMessage).thenReturn(errorMessage) + } + } + + @Test + fun `start returns true on first call`() { + val profiler = getSut() + assertTrue(profiler.start(60000)) + } + + @Test + fun `start returns false when already started`() { + val profiler = getSut() + assertTrue(profiler.start(60000)) + assertFalse(profiler.start(60000)) + } + + @Test + fun `start returns false when ProfilingManager is null`() { + val profiler = getSut(profilingManager = null) + assertFalse(profiler.start(60000)) + } + + @Test + fun `endAndCollect calls listener with null when never started`() { + val profiler = getSut() + val result = AtomicReference(File("sentinel")) + profiler.endAndCollect { result.set(it) } + assertNull(result.get()) + } + + @Test + fun `endAndCollect calls listener synchronously when result already available`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + val result = AtomicReference() + profiler.endAndCollect { result.set(it) } + + assertEquals(traceFile.absolutePath, result.get()?.absolutePath) + } + + @Test + fun `endAndCollect calls listener when result arrives later`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference() + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + assertEquals(traceFile.absolutePath, result.get()?.absolutePath) + } + + @Test + fun `endAndCollect calls listener with null on error result`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept( + mockResult(errorCode = ProfilingResult.ERROR_UNKNOWN, errorMessage = "unknown error") + ) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } + + @Test + fun `endAndCollect calls listener with null on rate limit error`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept(mockResult(errorCode = ProfilingResult.ERROR_FAILED_RATE_LIMIT_PROCESS)) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } + + @Test + fun `timeout fires listener with null when OS never responds`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + profiler.endAndCollect { result.set(it) } + + assertEquals("sentinel", result.get()?.name) + + executor.runAll() + + assertNull(result.get()) + } + + @Test + fun `timeout is no-op when result already arrived`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val callCount = AtomicInteger(0) + val result = AtomicReference() + profiler.endAndCollect { + callCount.incrementAndGet() + result.set(it) + } + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + assertEquals(1, callCount.get()) + assertEquals(traceFile.absolutePath, result.get()?.absolutePath) + + executor.runAll() + + assertEquals(1, callCount.get()) + } + + @Test + fun `listener is called exactly once when result and endAndCollect race`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val callCount = AtomicInteger(0) + val latch = CountDownLatch(1) + + val resultThread = Thread { + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + latch.countDown() + } + + profiler.endAndCollect { callCount.incrementAndGet() } + resultThread.start() + + assertTrue(latch.await(5, TimeUnit.SECONDS)) + + executor.runAll() + + assertEquals(1, callCount.get()) + } + + @Test + fun `trace file is deleted when result arrives after the timeout`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val callCount = AtomicInteger(0) + profiler.endAndCollect { callCount.incrementAndGet() } + + executor.runAll() + assertEquals(1, callCount.get()) + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + assertEquals(1, callCount.get()) + assertFalse(traceFile.exists()) + } + + @Test + fun `endAndCollect calls listener with null when result file path is null`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept(mockResult(filePath = null)) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } + + @Test + fun `endAndCollect calls listener with null when trace file does not exist`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept(mockResult(filePath = "/non/existent/path.pftrace")) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index e2fed5bb003..1dc00f09f95 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -4,7 +4,10 @@ import android.content.ContentProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Hint import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.ITransaction import io.sentry.MeasurementUnit +import io.sentry.SentryLongDate import io.sentry.SentryTracer import io.sentry.SpanContext import io.sentry.SpanDataConvention @@ -13,7 +16,9 @@ import io.sentry.SpanStatus import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD +import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM +import io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP import io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP import io.sentry.android.core.performance.ActivityLifecycleTimeSpan import io.sentry.android.core.performance.AppStartMetrics @@ -87,7 +92,7 @@ class PerformanceAndroidEventProcessorTest { fun `add cold start measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -95,11 +100,174 @@ class PerformanceAndroidEventProcessorTest { assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) } + @Test + fun `add cold start measurement for standalone app start transaction launched from background`() { + val sut = fixture.getSut() + + var tr = createStandaloneAppStartTransaction() + setStandaloneColdAppStartMetrics() + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + } + + @Test + fun `standalone app start with instrumented application onCreate attaches process and application spans`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load", "application.load"), tr.spans.map { it.op }) + assertTrue(tr.spans.all { it.parentSpanId == rootSpanId }) + } + + @Test + fun `standalone app start without instrumented application onCreate attaches only process span`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = false) + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load"), tr.spans.map { it.op }) + assertEquals(rootSpanId, tr.spans.single().parentSpanId) + } + + @Test + fun `standalone app start uses the transaction root span id as parent`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics() + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + val processLoadSpan = tr.spans.first { it.op == "process.load" } + assertEquals(rootSpanId, processLoadSpan.parentSpanId) + } + + @Test + fun `standalone app start spans do not carry TTID or TTFD contributing flags`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = createStandaloneAppStartTransaction() + + tr = sut.process(tr, Hint()) + + assertTrue(tr.spans.isNotEmpty()) + for (span in tr.spans) { + assertNull(span.data?.get(SpanDataConvention.CONTRIBUTES_TTID)) + assertNull(span.data?.get(SpanDataConvention.CONTRIBUTES_TTFD)) + } + } + + @Test + fun `foreground standalone app start measurement uses foreground fallback time span`() { + val sut = fixture.getSut(enablePerformanceV2 = false) + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = true + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(101) + } + sdkInitTimeSpan.apply { + setStartedAt(10) + setStoppedAt(30) + } + } + + var tr = createStandaloneAppStartTransaction(appStartScreen = "MainActivity") + + tr = sut.process(tr, Hint()) + + assertEquals(20f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) + } + + private fun extendAppStartFinishedWith(status: SpanStatus, endMs: Long) { + val span = mock() + whenever(span.isFinished).thenReturn(true) + whenever(span.status).thenReturn(status) + whenever(span.finishDate).thenReturn(SentryLongDate(endMs * 1_000_000L)) + val ext = AppStartMetrics.getInstance().appStartExtension + ext.setExtendAppStartListener { AppStartExtension.ExtendedAppStart(mock(), span) } + ext.extendAppStart() + } + + @Test + fun `extended app start uses the extended end for the cold start measurement`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartType.COLD + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + val startMs = metrics.appStartTimeSpan.startTimestampMs + extendAppStartFinishedWith(SpanStatus.OK, startMs + 500) + + var tr = createUiLoadTransactionWithAppStartChildSpan() + tr = sut.process(tr, Hint()) + + assertEquals(500f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) + } + + @Test + fun `extended app start never reports shorter than the natural first frame duration`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartType.COLD + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(1000) + } + val startMs = metrics.appStartTimeSpan.startTimestampMs + extendAppStartFinishedWith(SpanStatus.OK, startMs + 100) + + var tr = createUiLoadTransactionWithAppStartChildSpan() + tr = sut.process(tr, Hint()) + + assertEquals(999f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) + } + + @Test + fun `extended app start that hit the deadline suppresses the measurement`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartType.COLD + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + val startMs = metrics.appStartTimeSpan.startTimestampMs + extendAppStartFinishedWith(SpanStatus.DEADLINE_EXCEEDED, startMs + 30_000) + + var tr = createUiLoadTransactionWithAppStartChildSpan() + tr = sut.process(tr, Hint()) + + assertFalse(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) + } + @Test fun `add cold start measurement for performance-v2`() { val sut = fixture.getSut(enablePerformanceV2 = true) - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -111,7 +279,7 @@ class PerformanceAndroidEventProcessorTest { fun `add warm start measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.WARM) + var tr = createUiLoadTransactionWithAppStartChildSpan(coldStart = false) setAppStart(fixture.options, false) tr = sut.process(tr, Hint()) @@ -123,7 +291,7 @@ class PerformanceAndroidEventProcessorTest { fun `set app cold start unit measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -136,23 +304,40 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric twice`() { val sut = fixture.getSut() - var tr1 = getTransaction(AppStartType.COLD) + var tr1 = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options, false) tr1 = sut.process(tr1, Hint()) - var tr2 = getTransaction(AppStartType.UNKNOWN) + var tr2 = createUiLoadTransaction() tr2 = sut.process(tr2, Hint()) assertTrue(tr1.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) assertTrue(tr2.measurements.isEmpty()) } + @Test + fun `do not add standalone app start metric twice`() { + val sut = fixture.getSut() + + setStandaloneColdAppStartMetrics() + + var tr1 = createStandaloneAppStartTransaction() + tr1 = sut.process(tr1, Hint()) + + var tr2 = createStandaloneAppStartTransaction() + tr2 = sut.process(tr2, Hint()) + + assertTrue(tr1.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr2.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr2.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) + } + @Test fun `do not add app start metric if its not ready`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransactionWithAppStartChildSpan() tr = sut.process(tr, Hint()) @@ -163,7 +348,7 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric if performance is disabled`() { val sut = fixture.getSut(tracesSampleRate = null) - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() tr = sut.process(tr, Hint()) @@ -174,7 +359,7 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric if no app_start span`() { val sut = fixture.getSut(tracesSampleRate = null) - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransaction() tr = sut.process(tr, Hint()) @@ -184,7 +369,7 @@ class PerformanceAndroidEventProcessorTest { @Test fun `do not add slow and frozen frames if not auto transaction`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createTransaction("custom.op") tr = sut.process(tr, Hint()) @@ -194,7 +379,7 @@ class PerformanceAndroidEventProcessorTest { @Test fun `do not add slow and frozen frames if tracing is disabled`() { val sut = fixture.getSut(null) - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransaction() tr = sut.process(tr, Hint()) @@ -464,10 +649,10 @@ class PerformanceAndroidEventProcessorTest { val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) tr.spans.add(appStartSpan) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) // then the app start metrics should be attached tr = sut.process(tr, Hint()) - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) assertTrue(tr.spans.any { "application.load" == it.op }) @@ -867,13 +1052,44 @@ class PerformanceAndroidEventProcessorTest { } } - private fun getTransaction(type: AppStartType): SentryTransaction { - val op = - when (type) { - AppStartType.COLD -> "app.start.cold" - AppStartType.WARM -> "app.start.warm" - AppStartType.UNKNOWN -> "ui.load" + private fun setStandaloneColdAppStartMetrics(withApplicationOnCreate: Boolean = false) { + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = false + classLoadedUptimeMs = 50 + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + if (withApplicationOnCreate) { + applicationOnCreateTimeSpan.apply { + setStartedAt(10) + description = "com.example.App.onCreate" + setStoppedAt(42) + } } + } + } + + private fun createUiLoadTransactionWithAppStartChildSpan( + coldStart: Boolean = true + ): SentryTransaction = + createUiLoadTransaction().also { txn -> + txn.spans.add(createAppStartSpan(txn.contexts.trace!!.traceId, coldStart)) + } + + private fun createUiLoadTransaction(): SentryTransaction = createTransaction(UI_LOAD_OP) + + private fun createStandaloneAppStartTransaction( + appStartScreen: String? = null + ): SentryTransaction = + createTransaction(STANDALONE_APP_START_OP).also { txn -> + if (appStartScreen != null) { + txn.contexts.trace!!.setData(APP_START_SCREEN_DATA, appStartScreen) + } + } + + private fun createTransaction(op: String): SentryTransaction { val txn = SentryTransaction(fixture.tracer) txn.contexts.setTrace(SpanContext(op, TracesSamplingDecision(false))) return txn diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt index fac4fdc1891..b8e223f08e9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt @@ -1,8 +1,33 @@ package io.sentry.android.core import android.app.Activity +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.os.Looper +import android.text.TextUtils import android.view.View -import android.view.Window +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.RadioButton +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Attachment import io.sentry.Hint @@ -12,6 +37,7 @@ import io.sentry.SentryIntegrationPackageStorage import io.sentry.TypeCheckHint.ANDROID_ACTIVITY import io.sentry.protocol.SentryException import io.sentry.util.thread.IThreadChecker +import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -21,40 +47,42 @@ import kotlin.test.assertNull import kotlin.test.assertSame 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 +import org.robolectric.Robolectric.buildActivity +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadows.ShadowPixelCopy @RunWith(AndroidJUnit4::class) +@Config(shadows = [ShadowPixelCopy::class], sdk = [30]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) class ScreenshotEventProcessorTest { + + companion object { + private val SNAPSHOTS_DIR = + File("build/test-snapshots/ScreenshotEventProcessorTest").also { it.mkdirs() } + } + private class Fixture { - val buildInfo = mock() - val activity = mock() - val window = mock() - val view = mock() - val rootView = mock() + lateinit var activity: Activity val threadChecker = mock() val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } val mainProcessor = MainEventProcessor(options) init { - whenever(rootView.width).thenReturn(1) - whenever(rootView.height).thenReturn(1) - whenever(view.rootView).thenReturn(rootView) - whenever(window.decorView).thenReturn(view) - whenever(window.peekDecorView()).thenReturn(view) - whenever(activity.window).thenReturn(window) - whenever(activity.runOnUiThread(any())).then { it.getArgument(0).run() } - whenever(threadChecker.isMainThread).thenReturn(true) } - fun getSut(attachScreenshot: Boolean = false): ScreenshotEventProcessor { + fun getSut( + attachScreenshot: Boolean = false, + isReplayAvailable: Boolean = false, + ): ScreenshotEventProcessor { options.isAttachScreenshot = attachScreenshot options.threadChecker = threadChecker - return ScreenshotEventProcessor(options, buildInfo) + return ScreenshotEventProcessor(options, BuildInfoProvider(options.logger), isReplayAvailable) } } @@ -62,8 +90,12 @@ class ScreenshotEventProcessorTest { @BeforeTest fun `set up`() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + System.setProperty("robolectric.pixelCopyRenderMode", "hardware") + fixture = Fixture() CurrentActivityHolder.getInstance().clearActivity() + fixture.activity = buildActivity(MaskingActivity::class.java, null).setup().get() } @Test @@ -108,7 +140,7 @@ class ScreenshotEventProcessorTest { val sut = fixture.getSut(true) val hint = Hint() - whenever(fixture.activity.isFinishing).thenReturn(true) + fixture.activity.finish() CurrentActivityHolder.getInstance().setActivity(fixture.activity) val event = fixture.mainProcessor.process(getEvent(), hint) @@ -122,8 +154,8 @@ class ScreenshotEventProcessorTest { val sut = fixture.getSut(true) val hint = Hint() - whenever(fixture.rootView.width).thenReturn(0) - whenever(fixture.rootView.height).thenReturn(0) + val root = fixture.activity.window.decorView + root.layout(0, 0, 0, 0) CurrentActivityHolder.getInstance().setActivity(fixture.activity) val event = fixture.mainProcessor.process(getEvent(), hint) @@ -165,6 +197,7 @@ class ScreenshotEventProcessorTest { } @Test + @Config(sdk = [23]) fun `when screenshot event processor is called from background thread it executes on main thread`() { val sut = fixture.getSut(true) whenever(fixture.threadChecker.isMainThread).thenReturn(false) @@ -175,7 +208,7 @@ class ScreenshotEventProcessorTest { val event = fixture.mainProcessor.process(getEvent(), hint) sut.process(event, hint) - verify(fixture.activity).runOnUiThread(any()) + shadowOf(Looper.getMainLooper()).idle() assertNotNull(hint.screenshot) } @@ -291,5 +324,547 @@ class ScreenshotEventProcessorTest { assertNotNull(hint.screenshot) } + @Test + fun `when masking is configured and VH capture fails, no screenshot is attached`() { + val sut = fixture.getSut(attachScreenshot = true, isReplayAvailable = true) + fixture.options.screenshot.setMaskAllText(true) + val hint = Hint() + + // No activity set, so VH capture will return null (no rootView) + CurrentActivityHolder.getInstance().clearActivity() + + val event = fixture.mainProcessor.process(getEvent(), hint) + sut.process(event, hint) + + assertNull(hint.screenshot) + } + + @Test + fun `when masking is configured but replay is not available, screenshot is not captured`() { + val sut = fixture.getSut(attachScreenshot = true, isReplayAvailable = false) + fixture.options.screenshot.setMaskAllText(true) + val hint = Hint() + + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + + val event = fixture.mainProcessor.process(getEvent(), hint) + sut.process(event, hint) + + assertNull(hint.screenshot) + } + + @Test + fun `when masking is configured from background thread, VH is captured on main thread`() { + fixture.options.screenshot.setMaskAllText(true) + val sut = fixture.getSut(attachScreenshot = true, isReplayAvailable = true) + whenever(fixture.threadChecker.isMainThread).thenReturn(false) + + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + + val hint = Hint() + val event = fixture.mainProcessor.process(getEvent(), hint) + sut.process(event, hint) + + shadowOf(Looper.getMainLooper()).idle() + assertNotNull(hint.screenshot) + } + + // region Snapshot Tests + + @Test + fun `snapshot - screenshot without masking`() { + val bytes = processEventForSnapshots("screenshot_no_masking", isReplayAvailable = false) + assertNotNull(bytes) + } + + @Test + fun `snapshot - screenshot with text masking enabled`() { + val bytes = + processEventForSnapshots("screenshot_mask_text") { it.screenshot.setMaskAllText(true) } + assertNotNull(bytes) + } + + @Test + fun `snapshot - screenshot with image masking enabled`() { + val bytes = + processEventForSnapshots("screenshot_mask_images") { it.screenshot.setMaskAllImages(true) } + assertNotNull(bytes) + } + + @Test + fun `snapshot - screenshot with all masking enabled`() { + val bytes = + processEventForSnapshots("screenshot_mask_all") { + it.screenshot.setMaskAllText(true) + it.screenshot.setMaskAllImages(true) + } + assertNotNull(bytes) + } + + @Test + fun `snapshot - screenshot with custom view masking`() { + val bytes = + processEventForSnapshots("screenshot_mask_custom_view") { + // CustomView draws white, so masking it should draw black on top + it.screenshot.addMaskViewClass(CustomView::class.java.name) + } + assertNotNull(bytes) + } + + @Test + fun `snapshot - screenshot with ellipsized text no masking`() { + fixture.activity = buildActivity(EllipsizedTextActivity::class.java, null).setup().get() + val bytes = + processEventForSnapshots( + "screenshot_mask_ellipsized_view_unmasked", + isReplayAvailable = false, + ) + assertNotNull(bytes) + } + + @Test + fun `snapshot - screenshot with ellipsized text masking`() { + fixture.activity = buildActivity(EllipsizedTextActivity::class.java, null).setup().get() + val bytes = + processEventForSnapshots("screenshot_mask_ellipsized_view_masked") { + it.screenshot.setMaskAllText(true) + } + assertNotNull(bytes) + } + + @Test + fun `snapshot - compose text no masking`() { + fixture.activity = buildActivity(ComposeTextActivity::class.java, null).setup().get() + val bytes = + processEventForSnapshots( + "screenshot_mask_ellipsized_compose_unmasked", + isReplayAvailable = false, + ) + assertNotNull(bytes) + } + + @Test + fun `snapshot - compose text with masking`() { + fixture.activity = buildActivity(ComposeTextActivity::class.java, null).setup().get() + val bytes = + processEventForSnapshots("screenshot_mask_ellipsized_compose_masked") { + it.screenshot.setMaskAllText(true) + } + assertNotNull(bytes) + } + + @Test + fun `snapshot - multiline view text no masking`() { + fixture.activity = buildActivity(MultiLineTextActivity::class.java, null).setup().get() + val bytes = + processEventForSnapshots("screenshot_multiline_view_unmasked", isReplayAvailable = false) + assertNotNull(bytes) + } + + @Test + fun `snapshot - multiline view text with masking`() { + fixture.activity = buildActivity(MultiLineTextActivity::class.java, null).setup().get() + val bytes = + processEventForSnapshots("screenshot_multiline_view_masked") { + it.screenshot.setMaskAllText(true) + } + assertNotNull(bytes) + } + + @Test + fun `snapshot - multiline compose text no masking`() { + fixture.activity = buildActivity(ComposeMultiLineTextActivity::class.java, null).setup().get() + val bytes = + processEventForSnapshots("screenshot_multiline_compose_unmasked", isReplayAvailable = false) + assertNotNull(bytes) + } + + @Test + fun `snapshot - multiline compose text with masking`() { + fixture.activity = buildActivity(ComposeMultiLineTextActivity::class.java, null).setup().get() + val bytes = + processEventForSnapshots("screenshot_multiline_compose_masked") { + it.screenshot.setMaskAllText(true) + } + assertNotNull(bytes) + } + + // endregion + private fun getEvent(): SentryEvent = SentryEvent(Throwable("Throwable")) + + private fun processEventForSnapshots( + testName: String, + attachScreenshot: Boolean = true, + isReplayAvailable: Boolean = true, + configureOptions: (SentryAndroidOptions) -> Unit = {}, + ): ByteArray? { + configureOptions(fixture.options) + val sut = fixture.getSut(attachScreenshot, isReplayAvailable) + val hint = Hint() + + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + + val event = fixture.mainProcessor.process(getEvent(), hint) + sut.process(event, hint) + + val screenshot = hint.screenshot ?: return null + val bytes = screenshot.bytes ?: screenshot.byteProvider?.call() ?: return null + + File(SNAPSHOTS_DIR, "$testName.png").writeBytes(bytes) + + return bytes + } +} + +private class CustomView(context: Context) : View(context) { + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawColor(Color.WHITE) + } +} + +private class EllipsizedTextActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val longText = "This is a very long text that should be ellipsized when it does not fit" + + val linearLayout = + LinearLayout(this).apply { + setBackgroundColor(Color.WHITE) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + setPadding(10, 10, 10, 10) + } + + // Ellipsize end + linearLayout.addView( + TextView(this).apply { + text = longText + setTextColor(Color.BLACK) + textSize = 16f + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + setBackgroundColor(Color.LTGRAY) + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 8, 0, 0) + } + } + ) + + // Ellipsize middle + linearLayout.addView( + TextView(this).apply { + text = longText + setTextColor(Color.BLACK) + textSize = 16f + maxLines = 1 + ellipsize = TextUtils.TruncateAt.MIDDLE + setBackgroundColor(Color.LTGRAY) + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 8, 0, 0) + } + } + ) + + // Ellipsize start + linearLayout.addView( + TextView(this).apply { + text = longText + setTextColor(Color.BLACK) + textSize = 16f + maxLines = 1 + ellipsize = TextUtils.TruncateAt.START + setBackgroundColor(Color.LTGRAY) + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 8, 0, 0) + } + } + ) + + // Non-ellipsized text for comparison + linearLayout.addView( + TextView(this).apply { + text = "Short text" + setTextColor(Color.BLACK) + textSize = 16f + setBackgroundColor(Color.LTGRAY) + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 8, 0, 0) + } + } + ) + + setContentView(linearLayout) + } +} + +private class ComposeTextActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val longText = "This is a very long text that should be ellipsized when it does not fit in view" + + setContent { + Column( + modifier = + Modifier.fillMaxWidth() + .background(androidx.compose.ui.graphics.Color.White) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Ellipsis overflow + Text( + longText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 16.sp, + modifier = + Modifier.fillMaxWidth().background(androidx.compose.ui.graphics.Color.LightGray), + ) + + // Text with textAlign center + Text( + "Centered text", + textAlign = TextAlign.Center, + fontSize = 16.sp, + modifier = + Modifier.fillMaxWidth().background(androidx.compose.ui.graphics.Color.LightGray), + ) + + // Text with textAlign end + Text( + "End-aligned text", + textAlign = TextAlign.End, + fontSize = 16.sp, + modifier = + Modifier.fillMaxWidth().background(androidx.compose.ui.graphics.Color.LightGray), + ) + + // Ellipsis with textAlign center + Text( + longText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + fontSize = 16.sp, + modifier = + Modifier.fillMaxWidth().background(androidx.compose.ui.graphics.Color.LightGray), + ) + + // Weighted row with text + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Weight 1", + fontSize = 16.sp, + modifier = Modifier.weight(1f).background(androidx.compose.ui.graphics.Color.LightGray), + ) + Text( + "Weight 1", + fontSize = 16.sp, + modifier = Modifier.weight(1f).background(androidx.compose.ui.graphics.Color.LightGray), + ) + } + + // Weighted row with ellipsized text + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + longText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 16.sp, + modifier = Modifier.weight(1f).background(androidx.compose.ui.graphics.Color.LightGray), + ) + Text( + longText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + fontSize = 16.sp, + modifier = Modifier.weight(1f).background(androidx.compose.ui.graphics.Color.LightGray), + ) + } + + // Short text (for comparison) + Text( + "Short text", + fontSize = 16.sp, + modifier = Modifier.background(androidx.compose.ui.graphics.Color.LightGray), + ) + } + } + } +} + +private class MultiLineTextActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val multiLineText = + "This is a long text that will wrap across multiple lines without being ellipsized. " + + "It should continue to flow naturally within the available width of the view." + + val linearLayout = + LinearLayout(this).apply { + setBackgroundColor(Color.WHITE) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + setPadding(10, 10, 10, 10) + } + + // Multi-line wrapping text (no maxLines, no ellipsize) + linearLayout.addView( + TextView(this).apply { + text = multiLineText + setTextColor(Color.BLACK) + textSize = 16f + setBackgroundColor(Color.LTGRAY) + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 8, 0, 0) + } + } + ) + + // Multi-line text with maxLines = 3 (wraps but capped) + linearLayout.addView( + TextView(this).apply { + text = multiLineText + setTextColor(Color.BLACK) + textSize = 16f + maxLines = 3 + setBackgroundColor(Color.LTGRAY) + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 8, 0, 0) + } + } + ) + + // Short single-line text for comparison + linearLayout.addView( + TextView(this).apply { + text = "Short text" + setTextColor(Color.BLACK) + textSize = 16f + setBackgroundColor(Color.LTGRAY) + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 8, 0, 0) + } + } + ) + + setContentView(linearLayout) + } +} + +private class ComposeMultiLineTextActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val multiLineText = + "This is a long text that will wrap across multiple lines without being ellipsized. " + + "It should continue to flow naturally within the available width of the view." + + setContent { + Column( + modifier = + Modifier.fillMaxWidth() + .background(androidx.compose.ui.graphics.Color.White) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Multi-line wrapping text (no maxLines, no overflow) + Text( + multiLineText, + fontSize = 16.sp, + modifier = + Modifier.fillMaxWidth().background(androidx.compose.ui.graphics.Color.LightGray), + ) + + // Multi-line text with maxLines = 3 + Text( + multiLineText, + maxLines = 3, + fontSize = 16.sp, + modifier = + Modifier.fillMaxWidth().background(androidx.compose.ui.graphics.Color.LightGray), + ) + + // Multi-line centered text + Text( + multiLineText, + textAlign = TextAlign.Center, + fontSize = 16.sp, + modifier = + Modifier.fillMaxWidth().background(androidx.compose.ui.graphics.Color.LightGray), + ) + + // Short text for comparison + Text( + "Short text", + fontSize = 16.sp, + modifier = Modifier.background(androidx.compose.ui.graphics.Color.LightGray), + ) + } + } + } +} + +private class MaskingActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = + LinearLayout(this).apply { + setBackgroundColor(android.R.color.white) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + + val textView = + TextView(this).apply { + text = "Hello, World!" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + linearLayout.addView(textView) + + val image = this::class.java.classLoader?.getResource("Tongariro.jpg")!! + val imageView = + ImageView(this).apply { + setImageDrawable(Drawable.createFromPath(image.path)) + layoutParams = LayoutParams(50, 50).apply { setMargins(0, 16, 0, 0) } + } + linearLayout.addView(imageView) + + val radioButton = + RadioButton(this).apply { + text = "Radio Button" + layoutParams = + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(radioButton) + + val customView = + CustomView(this).apply { + layoutParams = LayoutParams(50, 50).apply { setMargins(0, 16, 0, 0) } + } + linearLayout.addView(customView) + + setContentView(linearLayout) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SendCachedEnvelopeIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SendCachedEnvelopeIntegrationTest.kt index b31c5c1fbf1..df9ce60883e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SendCachedEnvelopeIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SendCachedEnvelopeIntegrationTest.kt @@ -7,8 +7,8 @@ import io.sentry.IScopes import io.sentry.ISentryExecutorService import io.sentry.SendCachedEnvelopeFireAndForgetIntegration.SendFireAndForget import io.sentry.SendCachedEnvelopeFireAndForgetIntegration.SendFireAndForgetFactory +import io.sentry.SentryExecutorService import io.sentry.SentryLevel.DEBUG -import io.sentry.SentryOptions import io.sentry.test.DeferredExecutorService import io.sentry.test.ImmediateExecutorService import io.sentry.transport.RateLimiter @@ -46,7 +46,7 @@ class SendCachedEnvelopeIntegrationTest { options.cacheDirPath = cacheDirPath options.setLogger(logger) options.isDebug = true - options.executorService = mockExecutorService ?: SentryOptions().executorService + options.executorService = mockExecutorService ?: SentryExecutorService() whenever(sender.send()).then { Thread.sleep(delaySend) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 8cb79b0bb5b..94857b91058 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -156,6 +156,12 @@ class SentryAndroidOptionsTest { assertFalse(sentryOptions.isEnablePerformanceV2) } + @Test + fun `standalone app start tracing is disabled by default`() { + val sentryOptions = SentryAndroidOptions() + assertFalse(sentryOptions.isEnableStandaloneAppStartTracing) + } + fun `when options is initialized, enableScopeSync is enabled by default`() { assertTrue(SentryAndroidOptions().isEnableScopeSync) } @@ -195,6 +201,51 @@ class SentryAndroidOptionsTest { assertTrue(sentryOptions.isEnableSystemEventBreadcrumbsExtras) } + @Test + fun `anr profiling sample rate is null by default`() { + val sentryOptions = SentryAndroidOptions() + + assertNull(sentryOptions.anrProfilingSampleRate) + assertFalse(sentryOptions.isAnrProfilingEnabled) + } + + @Test + fun `anr profiling can be enabled via sample rate`() { + val sentryOptions = SentryAndroidOptions() + sentryOptions.anrProfilingSampleRate = 1.0 + assertEquals(1.0, sentryOptions.anrProfilingSampleRate) + assertTrue(sentryOptions.isAnrProfilingEnabled) + } + + @Test + fun `anr profiling can be disabled via null sample rate`() { + val sentryOptions = SentryAndroidOptions() + sentryOptions.anrProfilingSampleRate = 1.0 + sentryOptions.anrProfilingSampleRate = null + assertNull(sentryOptions.anrProfilingSampleRate) + assertFalse(sentryOptions.isAnrProfilingEnabled) + } + + @Test + fun `anr profiling is disabled when sample rate is zero`() { + val sentryOptions = SentryAndroidOptions() + sentryOptions.anrProfilingSampleRate = 0.0 + assertFalse(sentryOptions.isAnrProfilingEnabled) + } + + @Test(expected = IllegalArgumentException::class) + fun `anr profiling rejects invalid sample rate`() { + val sentryOptions = SentryAndroidOptions() + sentryOptions.anrProfilingSampleRate = 2.0 + } + + @Test + fun `app hang tracking is disabled by default with a 5s timeout`() { + val sentryOptions = SentryAndroidOptions() + assertFalse(sentryOptions.isEnableNdkAppHangTracking) + assertEquals(5000L, sentryOptions.ndkAppHangTimeoutIntervalMillis) + } + private class CustomDebugImagesLoader : IDebugImagesLoader { override fun loadDebugImages(): List? = null diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 504f1ca7fb0..2bd26051c07 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -6,6 +6,7 @@ import android.app.ApplicationExitInfo import android.content.Context import android.os.Build import android.os.Bundle +import android.os.Handler import android.os.Looper import android.os.SystemClock import androidx.test.core.app.ApplicationProvider @@ -15,6 +16,7 @@ import io.sentry.DateUtils import io.sentry.Hint import io.sentry.ILogger import io.sentry.ISentryClient +import io.sentry.NoOpSentryExecutorService import io.sentry.Sentry import io.sentry.Sentry.OptionsConfiguration import io.sentry.SentryEnvelope @@ -25,9 +27,9 @@ import io.sentry.SentryOptions import io.sentry.SentryOptions.BeforeSendCallback import io.sentry.Session import io.sentry.ShutdownHookIntegration -import io.sentry.SpotlightIntegration import io.sentry.SystemOutLogger import io.sentry.UncaughtExceptionHandlerIntegration +import io.sentry.android.core.anr.AnrProfilingIntegration import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.fragment.FragmentLifecycleIntegration @@ -45,8 +47,8 @@ import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME import io.sentry.cache.tape.QueueFile import io.sentry.protocol.Contexts import io.sentry.protocol.SentryId +import io.sentry.spotlight.SpotlightIntegration import io.sentry.test.applyTestOptions -import io.sentry.test.initForTest import io.sentry.transport.NoOpEnvelopeCache import io.sentry.util.StringUtils import java.io.ByteArrayOutputStream @@ -130,35 +132,35 @@ class SentryAndroidTest { whenever(mock.traceInputStream) .thenReturn( """ -"main" prio=5 tid=1 Blocked - | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 - | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 - | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 - | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB - | held mutexes= - at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) - - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 - at android.os.Handler.handleCallback(Handler.java:942) - at android.os.Handler.dispatchMessage(Handler.java:99) - at android.os.Looper.loopOnce(Looper.java:201) - at android.os.Looper.loop(Looper.java:288) - at android.app.ActivityThread.main(ActivityThread.java:7872) - at java.lang.reflect.Method.invoke(Native method) - at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) - at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) - -"perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) - | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 - | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 - | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 - | stack=0x7b20124000-0x7b20126000 stackSize=991KB - | held mutexes= - native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) - native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - (no managed stack frames) - """ + "main" prio=5 tid=1 Blocked + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) + - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 + at android.os.Handler.handleCallback(Handler.java:942) + at android.os.Handler.dispatchMessage(Handler.java:99) + at android.os.Looper.loopOnce(Looper.java:201) + at android.os.Looper.loop(Looper.java:288) + at android.app.ActivityThread.main(ActivityThread.java:7872) + at java.lang.reflect.Method.invoke(Native method) + at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) + at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) + + "perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) + | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 + | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 + | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 + | stack=0x7b20124000-0x7b20126000 stackSize=991KB + | held mutexes= + native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) + native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + (no managed stack frames) + """ .trimIndent() .byteInputStream() ) @@ -237,13 +239,19 @@ class SentryAndroidTest { } @Test - fun `deduplicates fragment and timber integrations`() { + fun `deduplicates fragment, timber and system events integrations`() { var refOptions: SentryAndroidOptions? = null - fixture.initSut(autoInit = true) { it.addIntegration(FragmentLifecycleIntegration(ApplicationProvider.getApplicationContext())) it.addIntegration(SentryTimberIntegration(minEventLevel = FATAL, minBreadcrumbLevel = DEBUG)) + + it.addIntegration( + SystemEventsBreadcrumbsIntegration( + ApplicationProvider.getApplicationContext(), + CustomHandler(Looper.getMainLooper()), + ) + ) refOptions = it } @@ -256,6 +264,11 @@ class SentryAndroidTest { // fragment integration is not auto-installed in the test, since the context is not Application // but we just verify here that the single integration is preserved assertEquals(refOptions!!.integrations.filterIsInstance().size, 1) + + val systemEventsIntegrations = + refOptions!!.integrations.filterIsInstance() + assertEquals(systemEventsIntegrations.size, 1) + assertTrue(systemEventsIntegrations.first().customHandler is CustomHandler) } @Test @@ -427,7 +440,9 @@ class SentryAndroidTest { // clean state for a new process. assertEquals( emptyList(), - options.findPersistingScopeObserver()?.read(options, BREADCRUMBS_FILENAME, List::class.java), + options + .findPersistingScopeObserver() + ?.read(options, BREADCRUMBS_FILENAME, List::class.java), ) assertEquals( SentryId.EMPTY_ID.toString(), @@ -450,7 +465,9 @@ class SentryAndroidTest { // assert that persisted values have changed assertEquals( "TestActivity", - options.findPersistingScopeObserver()?.read(options, TRANSACTION_FILENAME, String::class.java), + options + .findPersistingScopeObserver() + ?.read(options, TRANSACTION_FILENAME, String::class.java), ) assertEquals( "io.sentry.sample@1.1.0+220", @@ -464,7 +481,7 @@ class SentryAndroidTest { fixture.initSut(context = mock()) { options -> optionsRef = options options.dsn = "https://key@sentry.io/123" - assertEquals(18, options.integrations.size) + assertEquals(20, options.integrations.size) options.integrations.removeAll { it is UncaughtExceptionHandlerIntegration || it is ShutdownHookIntegration || @@ -473,9 +490,11 @@ class SentryAndroidTest { it is EnvelopeFileObserverIntegration || it is AppLifecycleIntegration || it is AnrIntegration || + it is AnrProfilingIntegration || it is ActivityLifecycleIntegration || it is ActivityBreadcrumbsIntegration || it is UserInteractionIntegration || + it is FeedbackShakeIntegration || it is FragmentLifecycleIntegration || it is SentryTimberIntegration || it is AppComponentsBreadcrumbsIntegration || @@ -517,6 +536,19 @@ class SentryAndroidTest { assertEquals(99, AppStartMetrics.getInstance().appStartTimeSpan.startUptimeMs) } + @Test + fun `executor service is not NoOp when AndroidConnectionStatusProvider is initialized`() { + var executorServiceIsNoOp = true + fixture.initSut(context = context) { options -> + options.dsn = "https://key@sentry.io/123" + // the config callback runs before initializeIntegrationsAndProcessors, which creates + // AndroidConnectionStatusProvider - so if the executor is already real here, + // it's guaranteed to be real when the provider calls submitSafe() + executorServiceIsNoOp = options.executorService is NoOpSentryExecutorService + } + assertFalse(executorServiceIsNoOp) + } + @Test fun `if the config options block throws still intializes android event processors`() { lateinit var optionsRef: SentryOptions @@ -527,7 +559,7 @@ class SentryAndroidTest { } assertTrue(optionsRef.eventProcessors.any { it is DefaultAndroidEventProcessor }) - assertTrue(optionsRef.eventProcessors.any { it is AnrV2EventProcessor }) + assertTrue(optionsRef.eventProcessors.any { it is ApplicationExitInfoEventProcessor }) } private fun prefillScopeCache(options: SentryOptions, cacheDir: String) { @@ -580,3 +612,5 @@ fun initForTest(context: Context, logger: ILogger) { fun initForTest(context: Context) { SentryAndroid.init(context) } + +class CustomHandler(looper: Looper) : Handler(looper) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt index 63a6ff8cb60..114f6850091 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt @@ -168,6 +168,7 @@ class SentryInitProviderTest { mockContext, loadClass, activityFramesTracker, + false, ) assertFalse(sentryOptions.isEnableNdk) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt index 1a84a1282da..0c0c03d71d2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt @@ -31,11 +31,10 @@ class SentryLogcatAdapterTest { Bundle().apply { putString(ManifestMetadataReader.DSN, "https://key@sentry.io/123") } val mockContext = ContextUtilsTestHelper.mockMetaData(metaData = metadata) initForTest(mockContext) { - it.beforeBreadcrumb = - SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> - breadcrumbs.add(breadcrumb) - breadcrumb - } + it.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + breadcrumbs.add(breadcrumb) + breadcrumb + } it.logs.isEnabled = true it.logs.beforeSend = SentryOptions.Logs.BeforeSendLogCallback { logEvent -> diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt index 58dc56d1493..bff6cdfad37 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt @@ -153,6 +153,21 @@ class SentryPerformanceProviderTest { ) } + @Test + fun `when config file is malformed, profiler is not started`() { + fixture.getSut { config -> config.writeText("{\"profile_sampled\": tru") } + assertNull(AppStartMetrics.getInstance().appStartProfiler) + assertNull(AppStartMetrics.getInstance().appStartContinuousProfiler) + verify(fixture.logger).log(eq(SentryLevel.ERROR), eq("Error when deserializing"), any()) + verify(fixture.logger) + .log( + eq(SentryLevel.WARNING), + eq( + "Unable to deserialize the SentryAppStartProfilingOptions. App start profiling will not start." + ), + ) + } + @Test fun `when profiling is disabled, profiler is not started`() { fixture.getSut { config -> @@ -257,8 +272,9 @@ class SentryPerformanceProviderTest { @Test fun `when provider is closed, profiler is stopped`() { - val provider = - fixture.getSut { config -> writeConfig(config, continuousProfilingEnabled = false) } + val provider = fixture.getSut { config -> + writeConfig(config, continuousProfilingEnabled = false) + } provider.shutdown() assertNotNull(AppStartMetrics.getInstance().appStartProfiler) assertFalse(AppStartMetrics.getInstance().appStartProfiler!!.isRunning) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryScreenshotOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryScreenshotOptionsTest.kt new file mode 100644 index 00000000000..ad6d29b5614 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryScreenshotOptionsTest.kt @@ -0,0 +1,113 @@ +package io.sentry.android.core + +import io.sentry.SentryMaskingOptions +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SentryScreenshotOptionsTest { + + @Test + fun `maskViewClasses is empty by default`() { + val options = SentryScreenshotOptions() + assertTrue(options.maskViewClasses.isEmpty()) + } + + @Test + fun `unmaskViewClasses is empty by default`() { + val options = SentryScreenshotOptions() + assertTrue(options.unmaskViewClasses.isEmpty()) + } + + @Test + fun `setMaskAllText true only adds TextView`() { + val options = SentryScreenshotOptions() + options.setMaskAllText(true) + + assertTrue(options.maskViewClasses.contains(SentryMaskingOptions.TEXT_VIEW_CLASS_NAME)) + // Should NOT add sensitive view classes (only setMaskAllImages does that) + assertFalse(options.maskViewClasses.contains(SentryMaskingOptions.WEB_VIEW_CLASS_NAME)) + assertFalse(options.maskViewClasses.contains(SentryMaskingOptions.VIDEO_VIEW_CLASS_NAME)) + assertEquals(1, options.maskViewClasses.size) + } + + @Test + fun `setMaskAllImages true adds ImageView and sensitive view classes`() { + val options = SentryScreenshotOptions() + options.setMaskAllImages(true) + + assertTrue(options.maskViewClasses.contains(SentryMaskingOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(options.maskViewClasses.contains(SentryMaskingOptions.WEB_VIEW_CLASS_NAME)) + assertTrue(options.maskViewClasses.contains(SentryMaskingOptions.VIDEO_VIEW_CLASS_NAME)) + assertTrue( + options.maskViewClasses.contains(SentryMaskingOptions.CAMERAX_PREVIEW_VIEW_CLASS_NAME) + ) + assertTrue( + options.maskViewClasses.contains(SentryMaskingOptions.ANDROIDX_MEDIA_VIEW_CLASS_NAME) + ) + assertTrue(options.maskViewClasses.contains(SentryMaskingOptions.EXOPLAYER_CLASS_NAME)) + assertTrue(options.maskViewClasses.contains(SentryMaskingOptions.EXOPLAYER_STYLED_CLASS_NAME)) + } + + @Test + fun `setMaskAllImages false does not add sensitive view classes`() { + val options = SentryScreenshotOptions() + options.setMaskAllImages(false) + + assertFalse(options.maskViewClasses.contains(SentryMaskingOptions.WEB_VIEW_CLASS_NAME)) + assertFalse(options.maskViewClasses.contains(SentryMaskingOptions.VIDEO_VIEW_CLASS_NAME)) + assertTrue(options.unmaskViewClasses.contains(SentryMaskingOptions.IMAGE_VIEW_CLASS_NAME)) + } + + @Test + fun `calling setMaskAllImages true multiple times does not duplicate classes`() { + val options = SentryScreenshotOptions() + options.setMaskAllImages(true) + options.setMaskAllImages(true) + options.setMaskAllImages(true) + + // CopyOnWriteArraySet should prevent duplicates + assertEquals(7, options.maskViewClasses.size) + } + + @Test + fun `inherits addMaskViewClass from base class`() { + val options = SentryScreenshotOptions() + options.addMaskViewClass("com.example.CustomView") + + assertTrue(options.maskViewClasses.contains("com.example.CustomView")) + } + + @Test + fun `inherits addUnmaskViewClass from base class`() { + val options = SentryScreenshotOptions() + options.addUnmaskViewClass("com.example.SafeView") + + assertTrue(options.unmaskViewClasses.contains("com.example.SafeView")) + } + + @Test + fun `inherits container class methods from base class`() { + val options = SentryScreenshotOptions() + options.setMaskViewContainerClass("com.example.MaskContainer") + options.setUnmaskViewContainerClass("com.example.UnmaskContainer") + + assertEquals("com.example.MaskContainer", options.maskViewContainerClass) + assertEquals("com.example.UnmaskContainer", options.unmaskViewContainerClass) + } + + @Test + fun `setMaskAllImages false removes sensitive view classes added by true`() { + val options = SentryScreenshotOptions() + options.setMaskAllImages(true) + + // Verify classes were added + assertTrue(options.maskViewClasses.contains(SentryMaskingOptions.WEB_VIEW_CLASS_NAME)) + assertTrue(options.maskViewClasses.contains(SentryMaskingOptions.VIDEO_VIEW_CLASS_NAME)) + + options.setMaskAllImages(false) + + assertTrue(options.maskViewClasses.isEmpty()) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt new file mode 100644 index 00000000000..93cb4759e99 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt @@ -0,0 +1,47 @@ +package io.sentry.android.core + +import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo +import android.app.ApplicationStartInfo +import android.os.Build +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@Implements(ActivityManager::class, minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM) +class SentryShadowActivityManager { + companion object { + private var historicalProcessStartReasons: List = emptyList() + private var importance: Int = RunningAppProcessInfo.IMPORTANCE_FOREGROUND + private var historicalProcessStartReasonsException: RuntimeException? = null + + fun setHistoricalProcessStartReasons(startReasons: List) { + historicalProcessStartReasons = startReasons + } + + fun setHistoricalProcessStartReasonsException(exception: RuntimeException) { + historicalProcessStartReasonsException = exception + } + + fun setImportance(importance: Int) { + this.importance = importance + } + + fun reset() { + historicalProcessStartReasons = emptyList() + importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND + historicalProcessStartReasonsException = null + } + + @Implementation + @JvmStatic + fun getMyMemoryState(outState: RunningAppProcessInfo) { + outState.importance = importance + } + } + + @Implementation + fun getHistoricalProcessStartReasons(maxNum: Int): List { + historicalProcessStartReasonsException?.let { throw it } + return historicalProcessStartReasons.take(maxNum) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt index c3ff6653673..e36388fb185 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt @@ -6,15 +6,25 @@ import org.robolectric.annotation.Implements @Implements(android.os.Process::class) class SentryShadowProcess { companion object { - private var startupTimeMillis: Long = 0 + private var startUptimeMillis: Long = 0 + private var startElapsedRealtime: Long = 0 fun setStartUptimeMillis(value: Long) { - startupTimeMillis = value + startUptimeMillis = value } + fun setStartElapsedRealtime(value: Long) { + startElapsedRealtime = value + } + + @Suppress("unused") + @Implementation + @JvmStatic + fun getStartUptimeMillis(): Long = startUptimeMillis + @Suppress("unused") @Implementation @JvmStatic - fun getStartUptimeMillis(): Long = startupTimeMillis + fun getStartElapsedRealtime(): Long = startElapsedRealtime } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt new file mode 100644 index 00000000000..24ccfceaa86 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt @@ -0,0 +1,219 @@ +package io.sentry.android.core + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorManager +import android.os.Handler +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ILogger +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.isA +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class SentryShakeDetectorTest { + + private class Fixture { + val logger = mock() + val context = mock() + val sensorManager = mock() + val accelerometer = mock() + val listener = mock() + + init { + whenever(context.getSystemService(Context.SENSOR_SERVICE)).thenReturn(sensorManager) + whenever(sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER, false)) + .thenReturn(accelerometer) + } + + fun getSut(): SentryShakeDetector { + return SentryShakeDetector(logger) + } + } + + private val fixture = Fixture() + + @Test + fun `registers sensor listener on start`() { + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + verify(fixture.sensorManager) + .registerListener( + eq(sut), + eq(fixture.accelerometer), + eq(SensorManager.SENSOR_DELAY_NORMAL), + isA(), + ) + } + + @Test + fun `unregisters sensor listener on stop`() { + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + sut.stop() + + verify(fixture.sensorManager).unregisterListener(sut) + } + + @Test + fun `does not crash when SensorManager is null`() { + whenever(fixture.context.getSystemService(Context.SENSOR_SERVICE)).thenReturn(null) + + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + verify(fixture.sensorManager, never()) + .registerListener(any(), any(), any(), any()) + } + + @Test + fun `does not crash when accelerometer is null`() { + whenever(fixture.sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER, false)) + .thenReturn(null) + + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + verify(fixture.sensorManager, never()) + .registerListener(any(), any(), any(), any()) + } + + @Test + fun `triggers listener when sustained shake is detected`() { + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + // Send enough accelerating samples over 0.25s+ to trigger (>75% accelerating) + val baseTimestamp = 1_000_000_000L // 1s in nanos + val intervalNs = 20_000_000L // 20ms between samples (~50Hz) + for (i in 0 until 20) { + val event = createSensorEvent(floatArrayOf(20f, 0f, 0f), baseTimestamp + i * intervalNs) + sut.onSensorChanged(event) + } + + verify(fixture.listener).onShake() + } + + @Test + fun `does not trigger listener on single spike`() { + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + val event = createSensorEvent(floatArrayOf(30f, 0f, 0f), 1_000_000_000L) + sut.onSensorChanged(event) + + verify(fixture.listener, never()).onShake() + } + + @Test + fun `does not trigger listener below threshold`() { + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + val baseTimestamp = 1_000_000_000L + val intervalNs = 20_000_000L + for (i in 0 until 20) { + val event = + createSensorEvent( + floatArrayOf(0f, 0f, SensorManager.GRAVITY_EARTH), + baseTimestamp + i * intervalNs, + ) + sut.onSensorChanged(event) + } + + verify(fixture.listener, never()).onShake() + } + + @Test + fun `does not trigger listener for non-accelerometer events`() { + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + val event = createSensorEvent(floatArrayOf(30f, 0f, 0f), 1_000_000_000L, Sensor.TYPE_GYROSCOPE) + sut.onSensorChanged(event) + + verify(fixture.listener, never()).onShake() + } + + @Test + fun `stop without start does not crash`() { + val sut = fixture.getSut() + sut.stop() + } + + @Test + fun `sample queue triggers when 75 percent of samples are accelerating`() { + val queue = SentryShakeDetector.SampleQueue() + val intervalNs = 20_000_000L + + // 15 accelerating + 5 not = 75% in a 0.4s window (> 0.25s minimum) + for (i in 0 until 15) { + queue.add(i * intervalNs, true) + } + for (i in 15 until 20) { + queue.add(i * intervalNs, false) + } + + assertTrue(queue.isShaking()) + } + + @Test + fun `sample queue does not trigger below 75 percent`() { + val queue = SentryShakeDetector.SampleQueue() + val intervalNs = 20_000_000L + + // 10 accelerating + 10 not = 50% + for (i in 0 until 10) { + queue.add(i * intervalNs, true) + } + for (i in 10 until 20) { + queue.add(i * intervalNs, false) + } + + assertFalse(queue.isShaking()) + } + + @Test + fun `sample queue does not trigger below minimum window`() { + val queue = SentryShakeDetector.SampleQueue() + + // All accelerating but only 0.06s apart (below 0.25s minimum) + for (i in 0 until 4) { + queue.add(i * 20_000_000L, true) + } + + assertFalse(queue.isShaking()) + } + + private fun createSensorEvent( + values: FloatArray, + timestamp: Long = 0L, + sensorType: Int = Sensor.TYPE_ACCELEROMETER, + ): SensorEvent { + val sensor = mock() + whenever(sensor.type).thenReturn(sensorType) + + val constructor = SensorEvent::class.java.getDeclaredConstructor(Int::class.javaPrimitiveType) + constructor.isAccessible = true + val event = constructor.newInstance(values.size) + values.copyInto(event.values) + + val sensorField = SensorEvent::class.java.getField("sensor") + sensorField.set(event, sensor) + + val timestampField = SensorEvent::class.java.getField("timestamp") + timestampField.set(event, timestamp) + + return event + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackDialogTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt similarity index 57% rename from sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackDialogTest.kt rename to sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt index bd60859c608..852b4e7e8f0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackDialogTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt @@ -1,6 +1,10 @@ package io.sentry.android.core +import android.app.Activity +import android.app.Application import android.content.Context +import android.os.Looper +import android.view.WindowManager import android.widget.TextView import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -17,16 +21,22 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf @RunWith(AndroidJUnit4::class) -class SentryUserFeedbackDialogTest { +class SentryUserFeedbackFormTest { class Fixture { val application: Context = ApplicationProvider.getApplicationContext() private val mockDsn = "http://key@localhost/proj" @@ -55,10 +65,10 @@ class SentryUserFeedbackDialogTest { fun getSut( associatedEventId: SentryId? = null, - configuration: SentryUserFeedbackDialog.OptionsConfiguration? = null, + configuration: SentryUserFeedbackForm.OptionsConfiguration? = null, configurator: SentryFeedbackOptions.OptionsConfigurator? = null, - ): SentryUserFeedbackDialog = - SentryUserFeedbackDialog(application, 0, associatedEventId, configuration, configurator) + ): SentryUserFeedbackForm = + SentryUserFeedbackForm(application, 0, associatedEventId, configuration, configurator) } private val fixture = Fixture() @@ -130,4 +140,77 @@ class SentryUserFeedbackDialogTest { // And the original options should not be modified assertNotEquals("custom title", fixture.options.feedbackOptions.formTitle) } + + @Test + fun `dialog window does not have FLAG_ALT_FOCUSABLE_IM so soft keyboard can appear`() { + fixture.options.isEnabled = true + val sut = fixture.getSut() + sut.show() + val window = sut.window + assertNotNull(window) + val flags = window.attributes.flags + assertEquals(0, flags and WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + } + + @Test + fun `a crashing onFormClose callback does not crash the app when the dialog is closed`() { + fixture.options.isEnabled = true + fixture.options.feedbackOptions.onFormClose = Runnable { throw RuntimeException("user bug") } + val sut = fixture.getSut() + sut.show() + + sut.dismiss() + // The dismiss listener is dispatched via a Handler message + shadowOf(Looper.getMainLooper()).idle() + + verify(fixture.mockLogger) + .log(eq(SentryLevel.ERROR), eq("onFormClose callback threw an exception."), any()) + } + + @Test + fun `a crashing onFormClose callback still runs the user's dismiss listener`() { + fixture.options.isEnabled = true + fixture.options.feedbackOptions.onFormClose = Runnable { throw RuntimeException("user bug") } + val sut = fixture.getSut() + var dismissed = false + sut.setOnDismissListener { dismissed = true } + sut.show() + + sut.dismiss() + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(dismissed) + } + + @Test + fun `a crashing onFormOpen callback does not crash the app when the dialog is shown`() { + fixture.options.isEnabled = true + fixture.options.feedbackOptions.onFormOpen = Runnable { throw RuntimeException("user bug") } + val sut = fixture.getSut() + + sut.show() + + verify(fixture.mockLogger) + .log(eq(SentryLevel.ERROR), eq("onFormOpen callback threw an exception."), any()) + // The form open must still complete its own work after the callback crash + verify(fixture.mockReplayController).captureReplay(eq(false)) + } + + @Test + fun `dialog reports its own host activity to the shake integration while visible`() { + fixture.options.isEnabled = true + val integration = FeedbackShakeIntegration(fixture.application as Application) + fixture.options.feedbackOptions.setShakeController(integration) + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + + val sut = SentryUserFeedbackForm(activity, 0, null, null, null) + sut.show() + + assertEquals(activity, integration.dialogActivity) + + sut.dismiss() + shadowOf(Looper.getMainLooper()).idle() + + assertNull(integration.dialogActivity) + } } 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-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt index e5d7349d37c..2b6f19a8d31 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt @@ -8,7 +8,6 @@ import io.sentry.SentryNanotimeDate import io.sentry.SpanContext import io.sentry.android.core.internal.util.SentryFrameMetricsCollector import io.sentry.protocol.MeasurementValue -import java.util.Date import java.util.UUID import java.util.concurrent.TimeUnit import kotlin.test.Test @@ -50,11 +49,12 @@ class SpanFrameMetricsCollectorTest { val span = mock() val spanContext = SpanContext("op.fake") whenever(span.spanContext).thenReturn(spanContext) - whenever(span.startDate).thenReturn(SentryNanotimeDate(Date(), startTimeStampNanos)) + whenever(span.startDate) + .thenReturn(SentryNanotimeDate(System.currentTimeMillis(), startTimeStampNanos)) whenever(span.finishDate) .thenReturn( if (endTimeStampNanos != null) { - SentryNanotimeDate(Date(), endTimeStampNanos) + SentryNanotimeDate(System.currentTimeMillis(), endTimeStampNanos) } else { null } @@ -69,11 +69,12 @@ class SpanFrameMetricsCollectorTest { val span = mock() val spanContext = SpanContext("op.fake") whenever(span.spanContext).thenReturn(spanContext) - whenever(span.startDate).thenReturn(SentryNanotimeDate(Date(), startTimeStampNanos)) + whenever(span.startDate) + .thenReturn(SentryNanotimeDate(System.currentTimeMillis(), startTimeStampNanos)) whenever(span.finishDate) .thenReturn( if (endTimeStampNanos != null) { - SentryNanotimeDate(Date(), endTimeStampNanos) + SentryNanotimeDate(System.currentTimeMillis(), endTimeStampNanos) } else { null } @@ -438,8 +439,8 @@ class SpanFrameMetricsCollectorTest { @Test fun `SentryNanoDate diff does nano precision`() { // having this in here, as SpanFrameMetricsCollector relies on this behavior - val a = SentryNanotimeDate(Date(1234), 567) - val b = SentryNanotimeDate(Date(1234), 0) + val a = SentryNanotimeDate(1234, 567) + val b = SentryNanotimeDate(1234, 0) assertEquals(567, a.diff(b)) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt index b2749e3940d..2505b6f7f7d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.os.BatteryManager import android.os.Build +import android.os.Handler import android.os.Looper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -27,6 +28,7 @@ import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.check import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -50,9 +52,11 @@ class SystemEventsBreadcrumbsIntegrationTest { lateinit var shadowActivityManager: ShadowActivityManager fun getSut( + contextForSut: Context = context, enableSystemEventBreadcrumbs: Boolean = true, enableSystemEventBreadcrumbsExtras: Boolean = false, executorService: ISentryExecutorService = ImmediateExecutorService(), + handler: Handler? = null, ): SystemEventsBreadcrumbsIntegration { options = SentryAndroidOptions().apply { @@ -61,8 +65,9 @@ class SystemEventsBreadcrumbsIntegrationTest { this.executorService = executorService } return SystemEventsBreadcrumbsIntegration( - context, + contextForSut, SystemEventsBreadcrumbsIntegration.getDefaultActions().toTypedArray(), + handler, ) } } @@ -309,6 +314,20 @@ class SystemEventsBreadcrumbsIntegrationTest { assertFalse(fixture.options.isEnableSystemEventBreadcrumbs) } + @Test + fun `Do not crash if receiver already unregistered`() { + val realContext = ApplicationProvider.getApplicationContext() + val sut = fixture.getSut(realContext) + + sut.register(fixture.scopes, fixture.options) + + realContext.unregisterReceiver(sut.receiver) + + val result = runCatching { sut.onBackground() } + + assertFalse(result.isFailure) + } + @Test fun `when str has full package, return last string after dot`() { val sut = fixture.getSut() @@ -585,4 +604,16 @@ class SystemEventsBreadcrumbsIntegrationTest { anyOrNull(), ) } + + @Test + fun `When a custom handler is provided, it is used upon registering the callback`() { + val customHandler = object : Handler(Looper.getMainLooper()) {} + val sut = fixture.getSut(handler = customHandler) + + sut.register(fixture.scopes, fixture.options) + + verify(fixture.context) + .registerReceiver(any(), any(), anyOrNull(), argThat { this == customHandler }, any()) + assertNotNull(sut.receiver) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt new file mode 100644 index 00000000000..e3e88d04f7a --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt @@ -0,0 +1,398 @@ +package io.sentry.android.core + +import android.app.ApplicationExitInfo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.android.core.TombstoneIntegration.TombstoneHint +import io.sentry.android.core.cache.AndroidEnvelopeCache +import java.io.File +import java.util.zip.GZIPInputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.check +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [31]) +class TombstoneIntegrationTest : ApplicationExitIntegrationTestBase() { + + override val config = + IntegrationTestConfig( + setEnabledFlag = { isTombstoneEnabled = it }, + setReportHistoricalFlag = { isReportHistoricalTombstones = it }, + createIntegration = { context -> TombstoneIntegration(context) }, + lastReportedFileName = AndroidEnvelopeCache.LAST_TOMBSTONE_REPORT, + defaultExitReason = ApplicationExitInfo.REASON_CRASH_NATIVE, + hintAccessors = + HintAccessors( + cast = { it as TombstoneHint }, + shouldEnrich = { it.shouldEnrich() }, + timestamp = { it.timestamp() }, + ), + addExitInfo = { reason, timestamp, importance, addTrace, addBadTrace -> + val builder = ApplicationExitInfoBuilder.newBuilder() + reason?.let { builder.setReason(it) } + timestamp?.let { builder.setTimestamp(it) } + importance?.let { builder.setImportance(it) } + val exitInfo = + spy(builder.build()) { + if (!addTrace) { + return@spy + } + if (addBadTrace) { + whenever(mock.traceInputStream).thenReturn("XXXXX".byteInputStream()) + } else { + whenever(mock.traceInputStream) + .thenReturn( + GZIPInputStream( + TombstoneIntegrationTest::class.java.getResourceAsStream("/tombstone.pb.gz") + ) + ) + } + } + shadowActivityManager.addApplicationExitInfo(exitInfo) + }, + flushLogPrefix = "Timed out waiting to flush Tombstone event to disk.", + ) + + override fun assertEnrichedEvent(event: SentryEvent) { + assertEquals(SentryLevel.FATAL, event.level) + assertEquals(newTimestamp, event.timestamp!!.time) + assertEquals("native", event.platform) + + val crashedThreadId = 21891L + assertEquals(crashedThreadId, event.exceptions!![0].threadId) + val crashedThread = event.threads!!.find { thread -> thread.id == crashedThreadId } + assertEquals("main", crashedThread!!.name) + assertTrue(crashedThread.isMain!!) + assertTrue(crashedThread.isCrashed!!) + + // Verify that frames from the app's native library are marked as in-app + val inAppFrames = crashedThread.stacktrace!!.frames!!.filter { it.isInApp == true } + assertTrue(inAppFrames.size >= 3, "Expected at least 3 in-app frames, got ${inAppFrames.size}") + // Should include the native sample library crash function + assertTrue( + inAppFrames.any { it.`package`?.contains("libnative-sample.so") == true }, + "Expected in-app frame from libnative-sample.so", + ) + + val image = + event.debugMeta?.images?.find { image -> image.codeId == "f60b4b74005f33fb3ef3b98aa4546008" } + assertEquals("744b0bf6-5f00-fb33-3ef3-b98aa4546008", image!!.debugId) + assertNotNull(image) + assertEquals("/system/lib64/libcompiler_rt.so", image.codeFile) + assertEquals("0x764c325000", image.imageAddr) + assertEquals(57344, image.imageSize) + } + + @Test + fun `when attachRawTombstone is enabled, raw tombstone is attached to hint`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) { options -> + options.isAttachRawTombstone = true + } + + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + any(), + argThat { + val tombstone = this.tombstone + tombstone != null && + tombstone.filename == "tombstone.pb" && + tombstone.contentType == "application/x-protobuf" && + tombstone.bytes != null && + tombstone.bytes!!.isNotEmpty() + }, + ) + } + + @Test + fun `when attachRawTombstone is disabled, no tombstone is attached to hint`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) { options -> + options.isAttachRawTombstone = false + } + + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), argThat { this.tombstone == null }) + } + + @Test + fun `when matching native event has attachments, they are added to the hint`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) { options -> + // Set up the outbox directory with the native envelope containing an attachment + // Use newTimestamp to match the tombstone timestamp + val outboxDir = File(options.outboxPath!!) + outboxDir.mkdirs() + createNativeEnvelopeWithAttachment(outboxDir, newTimestamp) + } + + // Add tombstone with timestamp matching the native event + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + any(), + argThat { + val attachments = this.attachments + attachments.size == 2 && + attachments[0].filename == "test-attachment.txt" && + attachments[0].contentType == "text/plain" && + String(attachments[0].bytes!!) == "some attachment content" && + attachments[1].filename == "test-another-attachment.txt" && + attachments[1].contentType == "text/plain" && + String(attachments[1].bytes!!) == "another attachment content" + }, + ) + } + + @Test + fun `when merging with native event, uses native event as base with tombstone stack traces`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) { options -> + val outboxDir = File(options.outboxPath!!) + outboxDir.mkdirs() + createNativeEnvelopeWithContext(outboxDir, newTimestamp) + } + + // Add tombstone with timestamp matching the native event + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + check { event -> + // Verify native SDK context is preserved + assertEquals("native-sdk-user-id", event.user?.id) + assertEquals("native-sdk-tag-value", event.getTag("native-sdk-tag")) + + // Verify tombstone stack trace data is applied + assertNotNull(event.exceptions) + assertTrue(event.exceptions!!.isNotEmpty()) + assertEquals("TombstoneMerged", event.exceptions!![0].mechanism?.type) + + // Verify tombstone debug meta is applied + assertNotNull(event.debugMeta) + assertTrue(event.debugMeta!!.images!!.isNotEmpty()) + + // Verify tombstone threads are applied (tombstone has 62 threads) + assertEquals(62, event.threads?.size) + }, + any(), + ) + } + + private fun createNativeEnvelopeWithContext(outboxDir: File, timestamp: Long): File { + val isoTimestamp = DateUtils.getTimestamp(DateUtils.getDateTime(timestamp)) + + // Native SDK event with user context and tags that should be preserved after merge + val eventJson = + """{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"$isoTimestamp","platform":"native","level":"fatal","user":{"id":"native-sdk-user-id"},"tags":{"native-sdk-tag":"native-sdk-tag-value"}}""" + val eventJsonSize = eventJson.toByteArray(Charsets.UTF_8).size + + val envelopeContent = + """ + {"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} + {"type":"event","length":$eventJsonSize,"content_type":"application/json"} + $eventJson + """ + .trimIndent() + + return File(outboxDir, "native-envelope-with-context.envelope").apply { + writeText(envelopeContent) + } + } + + @Test + fun `when native event has no message, tombstone message is applied`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) { options -> + val outboxDir = File(options.outboxPath!!) + outboxDir.mkdirs() + createNativeEnvelope(outboxDir, newTimestamp, messageJson = null) + } + + fixture.addAppExitInfo(timestamp = newTimestamp) + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + check { event -> + // Tombstone message should be applied + assertNotNull(event.message) + assertNotNull(event.message!!.formatted) + // The message contains the signal info from the tombstone + assertTrue(event.message!!.formatted!!.contains("Fatal signal")) + }, + any(), + ) + } + + @Test + fun `when native event has message with null template, tombstone message is applied`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) { options -> + val outboxDir = File(options.outboxPath!!) + outboxDir.mkdirs() + createNativeEnvelope( + outboxDir, + newTimestamp, + messageJson = """{"formatted":"some formatted text"}""", + ) + } + + fixture.addAppExitInfo(timestamp = newTimestamp) + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + check { event -> + // Tombstone message should be applied + assertNotNull(event.message) + assertNotNull(event.message!!.formatted) + assertTrue(event.message!!.formatted!!.contains("Fatal signal")) + }, + any(), + ) + } + + @Test + fun `when native event has message with empty template, tombstone message is applied`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) { options -> + val outboxDir = File(options.outboxPath!!) + outboxDir.mkdirs() + createNativeEnvelope( + outboxDir, + newTimestamp, + messageJson = """{"message":"","formatted":"some formatted text"}""", + ) + } + + fixture.addAppExitInfo(timestamp = newTimestamp) + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + check { event -> + // Tombstone message should be applied + assertNotNull(event.message) + assertNotNull(event.message!!.formatted) + assertTrue(event.message!!.formatted!!.contains("Fatal signal")) + }, + any(), + ) + } + + @Test + fun `when native event has message with content, native message is preserved`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) { options -> + val outboxDir = File(options.outboxPath!!) + outboxDir.mkdirs() + createNativeEnvelope( + outboxDir, + newTimestamp, + messageJson = + """{"message":"Native SDK crash message","formatted":"The crash happened at 0xDEADBEEF"}""", + ) + } + + fixture.addAppExitInfo(timestamp = newTimestamp) + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + check { event -> + // Native SDK message should be preserved + assertNotNull(event.message) + assertEquals("Native SDK crash message", event.message!!.message) + assertEquals("The crash happened at 0xDEADBEEF", event.message!!.formatted) + }, + any(), + ) + } + + /** + * Creates a native envelope file with an optional message field. + * + * @param messageJson The JSON for the message field (e.g., + * `{"message":"text","formatted":"text"}`), or null to omit the message field entirely. + */ + private fun createNativeEnvelope( + outboxDir: File, + timestamp: Long, + messageJson: String? = null, + fileName: String = "native-envelope.envelope", + ): File { + val isoTimestamp = DateUtils.getTimestamp(DateUtils.getDateTime(timestamp)) + val messageField = if (messageJson != null) ""","message":$messageJson""" else "" + + val eventJson = + """{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"$isoTimestamp","platform":"native","level":"fatal"$messageField}""" + val eventJsonSize = eventJson.toByteArray(Charsets.UTF_8).size + + val envelopeContent = + """ + {"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} + {"type":"event","length":$eventJsonSize,"content_type":"application/json"} + $eventJson + """ + .trimIndent() + + return File(outboxDir, fileName).apply { writeText(envelopeContent) } + } + + private fun createNativeEnvelopeWithAttachment(outboxDir: File, timestamp: Long): File { + val isoTimestamp = DateUtils.getTimestamp(DateUtils.getDateTime(timestamp)) + + val eventJson = + """{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"$isoTimestamp","platform":"native","level":"fatal"}""" + val eventJsonSize = eventJson.toByteArray(Charsets.UTF_8).size + + val attachment1Content = "some attachment content" + val attachment1ContentSize = attachment1Content.toByteArray(Charsets.UTF_8).size + + val attachment2Content = "another attachment content" + val attachment2ContentSize = attachment2Content.toByteArray(Charsets.UTF_8).size + + val envelopeContent = + """ + {"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} + {"type":"attachment","length":$attachment1ContentSize,"filename":"test-attachment.txt","content_type":"text/plain"} + $attachment1Content + {"type":"attachment","length":$attachment2ContentSize,"filename":"test-another-attachment.txt","content_type":"text/plain"} + $attachment2Content + {"type":"event","length":$eventJsonSize,"content_type":"application/json"} + $eventJson + """ + .trimIndent() + + return File(outboxDir, "native-envelope-with-attachment.envelope").apply { + writeText(envelopeContent) + } + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt index 4f1495a9875..8f20dbb1539 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt @@ -14,7 +14,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertIs import kotlin.test.assertIsNot -import kotlin.test.assertNotEquals +import kotlin.test.assertNotSame import kotlin.test.assertSame import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -39,16 +39,8 @@ class UserInteractionIntegrationTest { fun getSut( callback: Window.Callback? = null, - isAndroidXAvailable: Boolean = true, isLifecycleAvailable: Boolean = true, ): UserInteractionIntegration { - whenever( - loadClass.isClassAvailable( - eq("androidx.core.view.GestureDetectorCompat"), - anyOrNull(), - ) - ) - .thenReturn(isAndroidXAvailable) whenever( loadClass.isClassAvailable( eq("androidx.lifecycle.Lifecycle"), @@ -99,15 +91,6 @@ class UserInteractionIntegrationTest { verify(fixture.application).unregisterActivityLifecycleCallbacks(any()) } - @Test - fun `when androidx is unavailable doesn't register a callback`() { - val sut = fixture.getSut(isAndroidXAvailable = false) - - sut.register(fixture.scopes, fixture.options) - - verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) - } - @Test fun `registers window callback on activity resumed`() { val sut = fixture.getSut() @@ -166,15 +149,63 @@ class UserInteractionIntegrationTest { } @Test - fun `does not instrument if the callback is already ours`() { - val existingCallback = - SentryWindowCallback(NoOpWindowCallback(), fixture.activity, mock(), mock()) - val sut = fixture.getSut(existingCallback) + fun `resume after buried pause installs a fresh wrapper on top`() { + val sut = fixture.getSut() + sut.register(fixture.scopes, fixture.options) + + sut.onActivityResumed(fixture.activity) + val originalSentryCallback = fixture.window.callback + assertIs(originalSentryCallback) + + // Third-party wraps on top of us mid-activity. + val outerWrapper = WrapperCallback(originalSentryCallback) + fixture.window.callback = outerWrapper + + sut.onActivityPaused(fixture.activity) + sut.onActivityResumed(fixture.activity) + val newTop = fixture.window.callback + assertIs(newTop) + assertNotSame(originalSentryCallback, newTop) + assertSame(outerWrapper, newTop.delegate) + } + + @Test + fun `close unwraps windows so re-init does not double-wrap`() { + val mockCallback = mock() + fixture.window.callback = mockCallback + + val sutA = fixture.getSut() + sutA.register(fixture.scopes, fixture.options) + sutA.onActivityResumed(fixture.activity) + assertIs(fixture.window.callback) + + sutA.close() + assertSame(mockCallback, fixture.window.callback) + + val sutB = UserInteractionIntegration(fixture.application, fixture.loadClass) + sutB.register(fixture.scopes, fixture.options) + sutB.onActivityResumed(fixture.activity) + + val newWrapper = fixture.window.callback + assertIs(newWrapper) + assertSame(mockCallback, newWrapper.delegate) + } + + @Test + fun `paused with another wrapper on top does not cut it out of the chain`() { + val sut = fixture.getSut() sut.register(fixture.scopes, fixture.options) + sut.onActivityResumed(fixture.activity) + val sentryCallback = fixture.window.callback as SentryWindowCallback - assertNotEquals(existingCallback, (fixture.window.callback as SentryWindowCallback).delegate) + val outerWrapper = WrapperCallback(sentryCallback) + fixture.window.callback = outerWrapper + + sut.onActivityPaused(fixture.activity) + + assertSame(outerWrapper, fixture.window.callback) } @Test @@ -222,3 +253,7 @@ class UserInteractionIntegrationTest { private class EmptyActivity : Activity(), LifecycleOwner { override val lifecycle: Lifecycle = mock() } + +/** Simulates a third-party callback wrapper (e.g. Session Replay's FixedWindowCallback). */ +private open class WrapperCallback(@JvmField val delegate: Window.Callback) : + Window.Callback by delegate diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt new file mode 100644 index 00000000000..c4c9c947c99 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt @@ -0,0 +1,167 @@ +package io.sentry.android.core.anr + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class AnrCulpritIdentifierTest { + + @Test + fun `returns null for empty dumps`() { + val dumps = emptyList() + val result = AnrCulpritIdentifier.identify(dumps) + assertNull(result) + } + + @Test + fun `identifies single stack trace`() { + val stackTraceElements = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + val dumps = listOf(AnrStackTrace(1000, stackTraceElements)) + + val result = AnrCulpritIdentifier.identify(dumps) + + assertNotNull(result) + assertEquals(1, result.count) + assertEquals("com.example.MyClass", result.stack.first().className) + assertEquals(2, result.depth) + } + + @Test + fun `identifies most common, most detailed stack trace from multiple dumps`() { + val commonElements = + arrayOf( + StackTraceElement("com.example.CommonClass", "commonMethod1", "CommonClass.java", 42), + StackTraceElement("com.example.CommonClass", "commonMethod2", "CommonClass.java", 100), + ) + val rareElements = + arrayOf( + StackTraceElement("com.example.RareClass", "rareMethod", "RareClass.java", 50), + StackTraceElement("com.example.CommonClass", "commonMethod2", "CommonClass.java", 100), + ) + val dumps = + listOf( + AnrStackTrace(1000, commonElements), + AnrStackTrace(2000, commonElements), + AnrStackTrace(3000, rareElements), + ) + + val result = AnrCulpritIdentifier.identify(dumps) + + assertNotNull(result) + assertEquals(2, result.count) + assertEquals("com.example.CommonClass", result.stack.first().className) + assertEquals("commonMethod1", result.stack.first().methodName) + } + + @Test + fun `provides 0 quality score when stack only contains framework packages`() { + val frameworkElements = + arrayOf( + StackTraceElement("java.lang.Object", "wait", "Object.java", 42), + StackTraceElement("android.os.Handler", "handleMessage", "Handler.java", 100), + ) + val dumps = + listOf(AnrStackTrace(1000, frameworkElements), AnrStackTrace(2000, frameworkElements)) + + val result = AnrCulpritIdentifier.identify(dumps) + + assertNotNull(result) + assertEquals(0f, result.quality) + } + + @Test + fun `applies lower quality score to framework packages`() { + val frameworkElements = + arrayOf( + StackTraceElement("java.lang.Object", "wait", "Object.java", 42), + StackTraceElement("android.os.Handler", "handleMessage", "Handler.java", 100), + ) + val appElements = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("android.os.Handler", "handleMessage", "Handler.java", 100), + ) + + val dumps = listOf(AnrStackTrace(1000, frameworkElements), AnrStackTrace(2000, appElements)) + + val result = AnrCulpritIdentifier.identify(dumps) + + assertNotNull(result) + assertEquals("com.example.MyClass", result.stack.first().className) + } + + @Test + fun `prefers deeper stack traces`() { + val shallowStack = + arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val deepStack = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + StackTraceElement("com.example.ThirdClass", "method3", "ThirdClass.java", 150), + ) + val dumps = listOf(AnrStackTrace(1000, shallowStack), AnrStackTrace(2000, deepStack)) + + val result = AnrCulpritIdentifier.identify(dumps) + + assertNotNull(result) + assertEquals(3, result.depth) + assertEquals("com.example.MyClass", result.stack.first().className) + } + + @Test + fun `handles mixed framework and app code`() { + val mixedElements = + arrayOf( + StackTraceElement("com.example.Activity", "onCreate", "Activity.java", 42), + StackTraceElement("com.example.DataProcessor", "process", "DataProcessor.java", 100), + StackTraceElement("java.lang.Thread", "run", "Thread.java", 50), + ) + val dumps = listOf(AnrStackTrace(1000, mixedElements)) + + val result = AnrCulpritIdentifier.identify(dumps) + + assertNotNull(result) + assertEquals(2f / 3f, result.quality, 0.0001f) + assertEquals("com.example.Activity", result.stack.first().className) + } + + @Test + fun `isSystemFrame returns true for java lang packages`() { + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("java.lang.Object")) + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("java.lang.Thread")) + } + + @Test + fun `isSystemFrame returns true for java util packages`() { + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("java.util.ArrayList")) + } + + @Test + fun `isSystemFrame returns true for android packages`() { + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("android.app.Activity")) + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("android.os.Handler")) + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("android.os.Looper")) + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("android.view.View")) + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("android.widget.TextView")) + } + + @Test + fun `isSystemFrame returns true for internal android packages`() { + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("com.android.internal.os.ZygoteInit")) + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("com.google.android.gms.common.api.Api")) + } + + @Test + fun `isSystemFrame returns false for app packages`() { + assertEquals(false, AnrCulpritIdentifier.isSystemFrame("com.example.MyClass")) + assertEquals(false, AnrCulpritIdentifier.isSystemFrame("io.sentry.samples.MainActivity")) + assertEquals(false, AnrCulpritIdentifier.isSystemFrame("org.myapp.Feature")) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileManagerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileManagerTest.kt new file mode 100644 index 00000000000..09ee720decc --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileManagerTest.kt @@ -0,0 +1,137 @@ +package io.sentry.android.core.anr + +import io.sentry.SentryOptions +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.mock + +class AnrProfileManagerTest { + @get:Rule val tmpDir = TemporaryFolder() + + private fun createOptions(): SentryOptions { + val options = SentryOptions() + options.cacheDirPath = tmpDir.newFolder().absolutePath + options.setLogger(mock()) + return options + } + + @Test + fun `can add and load stack traces`() { + // Arrange + val options = createOptions() + val manager = AnrProfileManager(options) + val stackTraceElements = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + val trace = AnrStackTrace(1000, stackTraceElements) + + // Act + manager.add(trace) + val profile = manager.load() + + // Assert + assertNotNull(profile) + assertEquals(1, profile.stacks.size) + assertEquals(1000L, profile.stacks[0].timestampMs) + assertEquals(2, profile.stacks[0].stack.size) + } + + @Test + fun `can add multiple stack traces`() { + // Arrange + val options = createOptions() + val manager = AnrProfileManager(options) + val stackTraceElements1 = + arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + val stackTraceElements2 = + arrayOf(StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100)) + + // Act + manager.add(AnrStackTrace(1000, stackTraceElements1)) + manager.add(AnrStackTrace(2000, stackTraceElements2)) + val profile = manager.load() + + // Assert + assertNotNull(profile) + assertEquals(2, profile.stacks.size) + assertEquals(1000L, profile.stacks[0].timestampMs) + assertEquals(2000L, profile.stacks[1].timestampMs) + } + + @Test + fun `can clear all stack traces`() { + // Arrange + val options = createOptions() + val manager = AnrProfileManager(options) + val stackTraceElements = + arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + manager.add(AnrStackTrace(1000, stackTraceElements)) + + // Act + manager.clear() + val profile = manager.load() + + // Assert + assertTrue(profile.stacks.isEmpty()) + } + + @Test + fun `load empty profile when nothing added`() { + // Arrange + val options = createOptions() + val manager = AnrProfileManager(options) + + // Act + val profile = manager.load() + + // Assert + assertNotNull(profile) + assertTrue(profile.stacks.isEmpty()) + } + + @Test + fun `can deal with corrupt files`() { + // Arrange + val options = createOptions() + + val file = File(options.getCacheDirPath(), "anr_profile") + file.writeBytes("Hello World".toByteArray()) + + val manager = AnrProfileManager(options) + + // Act + val profile = manager.load() + + // Assert + assertNotNull(profile) + assertTrue(profile.stacks.isEmpty()) + } + + @Test + fun `persists profiles across manager instances`() { + // Arrange + val options = createOptions() + val stackTraceElements = + arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + // Act - add profile with first manager + var manager = AnrProfileManager(options) + manager.add(AnrStackTrace(1000, stackTraceElements)) + + // Create new manager instance from same cache dir + manager = AnrProfileManager(options) + val profile = manager.load() + + // Assert + assertNotNull(profile) + assertEquals(1, profile.stacks.size) + assertEquals(1000L, profile.stacks[0].timestampMs) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileRotationHelperTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileRotationHelperTest.kt new file mode 100644 index 00000000000..0d7d9556111 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileRotationHelperTest.kt @@ -0,0 +1,113 @@ +package io.sentry.android.core.anr + +import java.io.File +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class AnrProfileRotationHelperTest { + @get:Rule val tmpDir = TemporaryFolder() + + @BeforeTest + fun setup() { + AnrProfileRotationHelper.rotate() + } + + @Test + fun `getFileForRecording returns file with correct name`() { + val file = AnrProfileRotationHelper.getFileForRecording(tmpDir.root) + + assertEquals("anr_profile", file.name) + assertEquals(tmpDir.root, file.parentFile) + } + + @Test + fun `getLastFile returns last file`() { + val file = AnrProfileRotationHelper.getLastFile(tmpDir.root) + + assertEquals("anr_profile_old", file.name) + assertEquals(tmpDir.root, file.parentFile) + } + + @Test + fun `deleteLastFile returns true when file does not exist`() { + val result = AnrProfileRotationHelper.deleteLastFile(tmpDir.root) + + assertTrue(result) + } + + @Test + fun `deleteLastFile returns true when file is deleted successfully`() { + val lastFile = File(tmpDir.root, "anr_profile_old") + lastFile.writeText("test content") + assertTrue(lastFile.exists()) + + val result = AnrProfileRotationHelper.deleteLastFile(tmpDir.root) + + assertTrue(result) + assertFalse(lastFile.exists()) + } + + @Test + fun `rotate moves current file to last file`() { + val currentFile = File(tmpDir.root, "anr_profile") + currentFile.writeText("current content") + + val lastFile = AnrProfileRotationHelper.getLastFile(tmpDir.root) + + assertTrue(lastFile.exists()) + assertEquals("current content", lastFile.readText()) + } + + @Test + fun `rotate deletes existing last file before moving`() { + val currentFile = File(tmpDir.root, "anr_profile") + val lastFile = File(tmpDir.root, "anr_profile_old") + + lastFile.writeText("last content") + currentFile.writeText("current content") + + assertTrue(lastFile.exists()) + assertTrue(currentFile.exists()) + + val newLastFile = AnrProfileRotationHelper.getLastFile(tmpDir.root) + + assertTrue(newLastFile.exists()) + assertEquals("current content", newLastFile.readText()) + } + + @Test + fun `rotate does not directly perform file renaming`() { + val currentFile = File(tmpDir.root, "anr_profile") + currentFile.writeText("current") + + val lastFile = File(tmpDir.root, "anr_profile_old") + lastFile.writeText("last") + + AnrProfileRotationHelper.rotate() + + // content is still the same + assertEquals("current", currentFile.readText()) + assertEquals("last", lastFile.readText()) + + // but once rotated, the last file should now contain the current file's content + AnrProfileRotationHelper.getFileForRecording(tmpDir.root) + assertEquals("current", lastFile.readText()) + } + + @Test + fun `getFileForRecording triggers rotation when needed`() { + val currentFile = File(tmpDir.root, "anr_profile") + currentFile.writeText("content before rotation") + + AnrProfileRotationHelper.getFileForRecording(tmpDir.root) + + val lastFile = File(tmpDir.root, "anr_profile_old") + assertTrue(lastFile.exists()) + assertEquals("content before rotation", lastFile.readText()) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt new file mode 100644 index 00000000000..2ae48fb3253 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -0,0 +1,313 @@ +package io.sentry.android.core.anr + +import android.os.SystemClock +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.android.core.AppState +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.test.getProperty +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.mock + +@RunWith(AndroidJUnit4::class) +class AnrProfilingIntegrationTest { + + @get:Rule val tmpDir = TemporaryFolder() + + private lateinit var mockScopes: IScopes + private lateinit var mockLogger: ILogger + private lateinit var options: SentryAndroidOptions + + @BeforeTest + fun setup() { + mockScopes = mock() + mockLogger = mock() + options = + SentryAndroidOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + anrProfilingSampleRate = 1.0 + } + AppState.getInstance().resetInstance() + } + + @AfterTest + fun cleanup() { + AppState.getInstance().resetInstance() + } + + @Test + fun `onForeground starts monitoring thread`() { + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + + integration.onForeground() + Thread.sleep(100) // Allow thread to start + + val thread = integration.getProperty("thread") + assertNotNull(thread) + assertTrue(thread.isAlive) + assertEquals("AnrProfilingIntegration", thread.name) + } + + @Test + fun `onBackground pauses monitoring thread`() { + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + integration.onForeground() + Thread.sleep(100) + + val thread = integration.getProperty("thread") + assertNotNull(thread) + + integration.onBackground() + Thread.sleep(200) // Allow thread to enter wait state + + // Thread should still be alive but waiting + assertTrue(thread.isAlive) + + integration.close() + thread.join(2000) + assertFalse(thread.isAlive) + } + + @Test + fun `close disables integration and interrupts thread`() { + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + integration.onForeground() + Thread.sleep(100) + + val thread = integration.getProperty("thread") + assertNotNull(thread) + + assertTrue(AppState.getInstance().lifecycleObserver.listeners.isNotEmpty()) + + integration.close() + thread.join(2000) + + assertTrue(!thread.isAlive) + val enabled = integration.getProperty("enabled") + assertTrue(!enabled.get()) + assertTrue(AppState.getInstance().lifecycleObserver.listeners.isEmpty()) + } + + @Test + fun `lifecycle methods have no influence after close`() { + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + integration.close() + integration.onForeground() + integration.onBackground() + + val thread = integration.getProperty("thread") + assertTrue(thread == null || !thread.isAlive) + } + + @Test + fun `multiple foreground calls do not create multiple threads`() { + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + + integration.onForeground() + Thread.sleep(100) + val thread1 = integration.getProperty("thread") + + integration.onForeground() + Thread.sleep(100) + val thread2 = integration.getProperty("thread") + + assertNotNull(thread1) + assertNotNull(thread2) + assertEquals(thread1, thread2, "Should reuse the same thread") + + integration.close() + } + + @Test + fun `foreground after background reuses thread`() { + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + + integration.onForeground() + Thread.sleep(100) + val thread1 = integration.getProperty("thread") + + integration.onBackground() + integration.onForeground() + + Thread.sleep(100) + val thread2 = integration.getProperty("thread") + + assertNotNull(thread1) + assertNotNull(thread2) + assertSame(thread1, thread2, "Should reuse the same thread after background") + assertTrue(thread1.isAlive) + + integration.close() + } + + @Test + fun `properly walks through state transitions and collects stack traces`() { + val mainThread = Thread.currentThread() + SystemClock.setCurrentTimeMillis(1_00) + + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + anrProfilingSampleRate = 1.0 + } + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + // Drive the state machine synchronously to avoid racing the background polling thread. + + SystemClock.setCurrentTimeMillis(1_000) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) + assertTrue(integration.profileManager.load().stacks.isEmpty()) + + SystemClock.setCurrentTimeMillis(3_000) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.SUSPICIOUS, integration.state) + + SystemClock.setCurrentTimeMillis(6_000) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.ANR_DETECTED, integration.state) + assertEquals(2, integration.profileManager.load().stacks.size) + + for (i in 0 until AnrProfilingIntegration.MAX_NUM_STACKS + 1) { + integration.checkMainThread(mainThread) + } + assertEquals(AnrProfilingIntegration.MAX_NUM_STACKS, integration.numCollectedStacks.get()) + } + + @Test + fun `background foreground transitions don't trigger an ANR`() { + val mainThread = Thread.currentThread() + SystemClock.setCurrentTimeMillis(1_000) + + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + anrProfilingSampleRate = 1.0 + } + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + integration.onBackground() + + SystemClock.setCurrentTimeMillis(20_000) + integration.onForeground() + + Thread.sleep(100) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) + } + + @Test + fun `does not register when options is not SentryAndroidOptions`() { + val plainOptions = + SentryOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + } + + val integration = AnrProfilingIntegration() + + try { + integration.register(mockScopes, plainOptions) + } catch (e: IllegalArgumentException) { + // ignored + } + + // Verify no listeners were added + val lifecycleObserver = AppState.getInstance().lifecycleObserver + if (lifecycleObserver != null) { + assertTrue(lifecycleObserver.listeners.isEmpty()) + } + } + + @Test + fun `does not register when ANR profiling is disabled`() { + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + anrProfilingSampleRate = null + } + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + + // When ANR profiling is disabled, the integration doesn't add itself to AppState + // So the lifecycle observer may be null or have no listeners + val lifecycleObserver = AppState.getInstance().lifecycleObserver + if (lifecycleObserver != null) { + assertTrue(lifecycleObserver.listeners.isEmpty()) + } + } + + @Test + fun `does not collect stacks when sample rate is zero`() { + val mainThread = Thread.currentThread() + SystemClock.setCurrentTimeMillis(1_00) + + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + anrProfilingSampleRate = 0.0 + } + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + integration.onForeground() + + // Transition to suspicious + SystemClock.setCurrentTimeMillis(3_000) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.SUSPICIOUS, integration.state) + + // Transition to ANR + SystemClock.setCurrentTimeMillis(6_000) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.ANR_DETECTED, integration.state) + + // No stacks should have been collected + assertEquals(0, integration.numCollectedStacks.get()) + } + + @Test + fun `registers when ANR profiling is enabled`() { + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + anrProfilingSampleRate = 1.0 + } + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + + assertFalse(AppState.getInstance().lifecycleObserver.listeners.isEmpty()) + assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("AnrProfiling")) + + integration.close() + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt new file mode 100644 index 00000000000..9dcde8eb4a6 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt @@ -0,0 +1,249 @@ +package io.sentry.android.core.anr + +import org.junit.Assert +import org.junit.Test + +class AnrStackTraceConverterTest { + @Test + fun testConvertSimpleStackTrace() { + val elements = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + + val anrStackTrace = AnrStackTrace(1000, elements) + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(anrStackTrace) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + Assert.assertNotNull(profile) + // Two samples are present b/c the converter adds a synthetic one to keep Relay happy. + Assert.assertEquals(2, profile.samples.size) + Assert.assertEquals(2, profile.frames.size) + Assert.assertEquals(1, profile.stacks.size) + + val frame0 = profile.frames[0] + Assert.assertEquals("MyClass.java", frame0.filename) + Assert.assertEquals("method1", frame0.function) + Assert.assertEquals("com.example.MyClass", frame0.module) + Assert.assertEquals(42, frame0.lineno) + + val frame1 = profile.frames[1] + Assert.assertEquals("AnotherClass.java", frame1.filename) + Assert.assertEquals("method2", frame1.function) + Assert.assertEquals("com.example.AnotherClass", frame1.module) + Assert.assertEquals(100, frame1.lineno) + + val stack = profile.stacks[0] + Assert.assertEquals(2, stack.size) + Assert.assertEquals(0, (stack[0] as Int)) + Assert.assertEquals(1, (stack[1] as Int)) + + val sample = profile.samples[0] + Assert.assertEquals(0, sample.stackId) + Assert.assertEquals("0", sample.threadId) + Assert.assertEquals(1.0, sample.timestamp, 0.001) // 1000ms = 1s + } + + @Test + fun testAddSyntheticSampleIfOnlyOneSamplePresent() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + val originalSample = profile.samples[0] + val syntheticSample = profile.samples[1] + val expectedOffsetSeconds = AnrProfilingIntegration.POLLING_INTERVAL_MS / 2.0 / 1000.0 + + Assert.assertEquals( + originalSample.timestamp + expectedOffsetSeconds, + syntheticSample.timestamp, + 0.001, + ) + + Assert.assertTrue(profile.stacks[syntheticSample.stackId].isNotEmpty()) + Assert.assertEquals(2, profile.samples.size) + Assert.assertEquals(1, profile.stacks.size) + Assert.assertEquals(1, profile.frames.size) + } + + @Test + fun testDoNotAddSyntheticSampleIfMultipleSamplesPresent() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements)) + anrStackTraces.add(AnrStackTrace(2000, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + Assert.assertEquals(2, profile.samples.size) + Assert.assertEquals(1.0, profile.samples[0].timestamp, 0.001) + Assert.assertEquals(2.0, profile.samples[1].timestamp, 0.001) + Assert.assertEquals(1, profile.stacks.size) + Assert.assertEquals(1, profile.frames.size) + } + + @Test + fun testDoNotAddSyntheticSampleIfNoSamplesPresent() { + val profile = StackTraceConverter.convert(AnrProfile(ArrayList())) + + Assert.assertEquals(0, profile.samples.size) + Assert.assertEquals(0, profile.stacks.size) + Assert.assertEquals(0, profile.frames.size) + } + + @Test + fun testFrameDeduplication() { + // Create two stack traces with duplicate frames + val elements1 = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + + val elements2 = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.ThirdClass", "method3", "ThirdClass.java", 200), + ) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements1)) + anrStackTraces.add(AnrStackTrace(2000, elements2)) + + // Convert to profile + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + // Should have 3 frames total (dedup removes duplicate) + Assert.assertEquals(3, profile.frames.size) + + // First sample uses stack [0, 1] + val stack1 = profile.stacks[0] + Assert.assertEquals(2, stack1.size) + Assert.assertEquals(0, (stack1[0] as Int)) + Assert.assertEquals(1, (stack1[1] as Int)) + + // Second sample uses stack [0, 2] (frame 0 reused) + val stack2 = profile.stacks[1] + Assert.assertEquals(2, stack2.size) + Assert.assertEquals(0, (stack2[0] as Int)) + Assert.assertEquals(2, (stack2[1] as Int)) + } + + @Test + fun testStackDeduplication() { + // Create two stack traces with identical frames in same order + val elements = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements)) + anrStackTraces.add(AnrStackTrace(2000, elements.clone())) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + // Should have 2 frames and 1 stack (dedup stack) + Assert.assertEquals(2, profile.frames.size) + Assert.assertEquals(1, profile.stacks.size) + + // Both samples should reference the same stack + Assert.assertEquals(0, profile.samples[0].stackId) + Assert.assertEquals(0, profile.samples[1].stackId) + } + + @Test + fun testTimestampConversion() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val timestampsMs = longArrayOf(1000, 1500, 5000) + val anrStackTraces: MutableList = ArrayList() + + for (ts in timestampsMs) { + anrStackTraces.add(AnrStackTrace(ts, elements)) + } + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + Assert.assertEquals(1.0, profile.samples[0].timestamp, 0.001) + Assert.assertEquals(1.5, profile.samples[1].timestamp, 0.001) + Assert.assertEquals(5.0, profile.samples[2].timestamp, 0.001) + } + + @Test + fun testNativeMethodHandling() { + val elements = arrayOf(StackTraceElement("java.lang.System", "doSomething", null, -2)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + val frame = profile.frames[0] + Assert.assertTrue(frame.isNative()!!) + } + + @Test + fun testThreadMetadata() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + val threadMetadata = profile.threadMetadata["0"] + Assert.assertNotNull(threadMetadata) + Assert.assertEquals("main", threadMetadata!!.name) + Assert.assertEquals(Thread.NORM_PRIORITY, threadMetadata.priority) + } + + @Test + fun testEmptyStackTraceList() { + val anrStackTraces: MutableList = ArrayList() + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + Assert.assertNotNull(profile) + Assert.assertEquals(0, profile.samples.size) + Assert.assertEquals(0, profile.frames.size) + Assert.assertEquals(0, profile.stacks.size) + Assert.assertTrue(profile.threadMetadata.containsKey("0")) + } + + @Test + fun testSampleProperties() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(12345, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + val sample = profile.samples[0] + Assert.assertEquals("0", sample.threadId) + Assert.assertEquals(0, sample.stackId) + Assert.assertEquals(12.345, sample.timestamp, 0.001) + } + + @Test + fun testInAppFrameFlag() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + val frame = profile.frames[0] + Assert.assertNull(frame.isInApp()) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceTest.kt new file mode 100644 index 00000000000..e53ad507bc1 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceTest.kt @@ -0,0 +1,127 @@ +package io.sentry.android.core.anr + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class AnrStackTraceTest { + + @Test + fun `serialize and deserialize preserves stack trace data`() { + val stackTraceElements = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", null, 42), + StackTraceElement("com.example.MyClass", "method1", "", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + val original = AnrStackTrace(1234567890L, stackTraceElements) + + val bytes = ByteArrayOutputStream() + val dos = DataOutputStream(bytes) + original.serialize(dos) + dos.flush() + + val dis = DataInputStream(ByteArrayInputStream(bytes.toByteArray())) + val deserialized = AnrStackTrace.deserialize(dis) + + assertNotNull(deserialized) + assertEquals(original.timestampMs, deserialized.timestampMs) + assertEquals(original.stack.size, deserialized.stack.size) + + for (i in original.stack.indices) { + assertEquals(original.stack[i].className, deserialized.stack[i].className) + assertEquals(original.stack[i].methodName, deserialized.stack[i].methodName) + assertEquals(original.stack[i].fileName, deserialized.stack[i].fileName) + assertEquals(original.stack[i].lineNumber, deserialized.stack[i].lineNumber) + } + } + + @Test + fun `compareTo sorts by timestamp ascending`() { + val trace1 = AnrStackTrace(3000L, emptyArray()) + val trace2 = AnrStackTrace(1000L, emptyArray()) + val trace3 = AnrStackTrace(2000L, emptyArray()) + + val list = listOf(trace3, trace1, trace2) + val sorted = list.sorted() + + assertEquals(1000L, sorted[0].timestampMs) + assertEquals(2000L, sorted[1].timestampMs) + assertEquals(3000L, sorted[2].timestampMs) + } + + @Test + fun `serialize and deserialize handles empty stack`() { + val original = AnrStackTrace(1234567890L, emptyArray()) + + val bytes = ByteArrayOutputStream() + val dos = DataOutputStream(bytes) + original.serialize(dos) + dos.flush() + + val dis = DataInputStream(ByteArrayInputStream(bytes.toByteArray())) + val deserialized = AnrStackTrace.deserialize(dis) + + assertNotNull(deserialized) + assertEquals(0, deserialized.stack.size) + assertEquals(original.timestampMs, deserialized.timestampMs) + } + + @Test + fun `serialize and deserialize handles native methods with no line number`() { + val stackTraceElements = + arrayOf( + StackTraceElement("java.lang.reflect.Method", "invoke", null, -2), + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + ) + val original = AnrStackTrace(1234567890L, stackTraceElements) + + val bytes = ByteArrayOutputStream() + val dos = DataOutputStream(bytes) + original.serialize(dos) + dos.flush() + + val dis = DataInputStream(ByteArrayInputStream(bytes.toByteArray())) + val deserialized = AnrStackTrace.deserialize(dis) + + assertNotNull(deserialized) + assertEquals(-2, deserialized.stack[0].lineNumber) + assertNull(deserialized.stack[0].fileName) + assertEquals(42, deserialized.stack[1].lineNumber) + } + + @Test + fun `deserialize returns null for oversized stack length`() { + val bytes = ByteArrayOutputStream() + val dos = DataOutputStream(bytes) + dos.writeShort(1) // version + dos.writeLong(1234567890L) // timestamp + dos.writeInt(1001) // stackLength exceeds MAX_STACK_LENGTH + dos.flush() + + val dis = DataInputStream(ByteArrayInputStream(bytes.toByteArray())) + val deserialized = AnrStackTrace.deserialize(dis) + + assertNull(deserialized) + } + + @Test + fun `deserialize returns null for negative stack length`() { + val bytes = ByteArrayOutputStream() + val dos = DataOutputStream(bytes) + dos.writeShort(1) // version + dos.writeLong(1234567890L) // timestamp + dos.writeInt(-1) // negative stackLength + dos.flush() + + val dis = DataInputStream(ByteArrayInputStream(bytes.toByteArray())) + val deserialized = AnrStackTrace.deserialize(dis) + + assertNull(deserialized) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt index 09d3a779df0..a4063ccb148 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt @@ -125,6 +125,20 @@ class AndroidEnvelopeCacheTest { assertTrue(fixture.startupCrashMarkerFile.exists()) } + @Test + fun `creates outbox dir when writing startup crash file and dir does not exist yet`() { + val cache = fixture.getSut(dir = tmpDir, appStartMillis = 1000L, currentTimeMillis = 2000L) + + val outboxDir = File(fixture.options.outboxPath!!) + assertTrue(outboxDir.deleteRecursively()) + assertFalse(outboxDir.exists()) + + val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) + cache.storeEnvelope(fixture.envelope, hints) + + assertTrue(fixture.startupCrashMarkerFile.exists()) + } + @Test fun `when no AnrV2 hint exists, does not write last anr report file`() { val cache = fixture.getSut(tmpDir) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoaderTest.kt index ba11c3c7966..76f13b63822 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoaderTest.kt @@ -45,12 +45,12 @@ class AssetsDebugMetaLoaderTest { fixture.getSut( content = """ - #Generated by sentry-maven-plugin - #Wed May 17 15:33:34 CEST 2023 - io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b - io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68 - io.sentry.build-tool=maven - """ + #Generated by sentry-maven-plugin + #Wed May 17 15:33:34 CEST 2023 + io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b + io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68 + io.sentry.build-tool=maven + """ .trimIndent() ) @@ -68,12 +68,12 @@ class AssetsDebugMetaLoaderTest { fixture.getSut( content = """ - #Generated by sentry-maven-plugin - #Wed May 17 15:33:34 CEST 2023 - io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b - io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68,8d11a44a-facd-46c1-a49b-87d256227101 - io.sentry.build-tool=maven - """ + #Generated by sentry-maven-plugin + #Wed May 17 15:33:34 CEST 2023 + io.sentry.ProguardUuids=34077988-a0e5-4839-9618-7400e1616d1b + io.sentry.bundle-ids=88ba82db-cd26-4c09-8b31-21461d286b68,8d11a44a-facd-46c1-a49b-87d256227101 + io.sentry.build-tool=maven + """ .trimIndent() ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureDetectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureDetectorTest.kt new file mode 100644 index 00000000000..7967c4a3f0c --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureDetectorTest.kt @@ -0,0 +1,414 @@ +package io.sentry.android.core.internal.gestures + +import android.os.SystemClock +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlin.test.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +@RunWith(AndroidJUnit4::class) +class SentryGestureDetectorTest { + + class Fixture { + val listener = mock() + val context = ApplicationProvider.getApplicationContext() + val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + + fun getSut(): SentryGestureDetector { + return SentryGestureDetector(context, listener) + } + } + + private val fixture = Fixture() + + @Test + fun `tap - DOWN followed by UP within touch slop fires onSingleTapUp`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + val up = MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_UP, 100f, 100f, 0) + + sut.onTouchEvent(down) + sut.onTouchEvent(up) + + verify(fixture.listener).onDown(down) + verify(fixture.listener).onSingleTapUp(up) + verify(fixture.listener, never()).onScroll(any(), any(), any(), any()) + verify(fixture.listener, never()).onFling(anyOrNull(), any(), any(), any()) + + down.recycle() + up.recycle() + } + + @Test + fun `no tap - DOWN followed by MOVE beyond slop and UP does not fire onSingleTapUp`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + val beyondSlop = fixture.touchSlop + 10f + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + val move = + MotionEvent.obtain( + downTime, + downTime + 16, + MotionEvent.ACTION_MOVE, + 100f + beyondSlop, + 100f, + 0, + ) + val up = + MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_UP, 100f + beyondSlop, 100f, 0) + + sut.onTouchEvent(down) + sut.onTouchEvent(move) + sut.onTouchEvent(up) + + verify(fixture.listener, never()).onSingleTapUp(any()) + + down.recycle() + move.recycle() + up.recycle() + } + + @Test + fun `scroll - DOWN followed by MOVE beyond slop fires onScroll with correct deltas`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + val beyondSlop = fixture.touchSlop + 10f + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 200f, 0) + val move = + MotionEvent.obtain( + downTime, + downTime + 16, + MotionEvent.ACTION_MOVE, + 100f + beyondSlop, + 200f, + 0, + ) + + sut.onTouchEvent(down) + sut.onTouchEvent(move) + + // scrollX = lastX - currentX = 100 - (100 + beyondSlop) = -beyondSlop + verify(fixture.listener).onScroll(anyOrNull(), eq(move), eq(-beyondSlop), eq(0f)) + + down.recycle() + move.recycle() + } + + @Test + fun `fling - fast swipe fires onFling`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + val beyondSlop = fixture.touchSlop + 10f + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + // Move far and fast (large distance in short time = high velocity) + val move = + MotionEvent.obtain( + downTime, + downTime + 10, + MotionEvent.ACTION_MOVE, + 100f + beyondSlop, + 100f, + 0, + ) + val up = MotionEvent.obtain(downTime, downTime + 20, MotionEvent.ACTION_UP, 500f, 100f, 0) + + sut.onTouchEvent(down) + sut.onTouchEvent(move) + sut.onTouchEvent(up) + + verify(fixture.listener).onFling(anyOrNull(), eq(up), any(), any()) + + down.recycle() + move.recycle() + up.recycle() + } + + @Test + fun `slow release - DOWN MOVE and slow UP does not fire onFling`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + val beyondSlop = fixture.touchSlop + 1f + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + // Move just beyond slop + val move = + MotionEvent.obtain( + downTime, + downTime + 100, + MotionEvent.ACTION_MOVE, + 100f + beyondSlop, + 100f, + 0, + ) + // Stay at the same position for a long time to ensure near-zero velocity + val moveStill = + MotionEvent.obtain( + downTime, + downTime + 10000, + MotionEvent.ACTION_MOVE, + 100f + beyondSlop, + 100f, + 0, + ) + val up = + MotionEvent.obtain( + downTime, + downTime + 10001, + MotionEvent.ACTION_UP, + 100f + beyondSlop, + 100f, + 0, + ) + + sut.onTouchEvent(down) + sut.onTouchEvent(move) + sut.onTouchEvent(moveStill) + sut.onTouchEvent(up) + + verify(fixture.listener, never()).onFling(anyOrNull(), any(), any(), any()) + + down.recycle() + move.recycle() + moveStill.recycle() + up.recycle() + } + + @Test + fun `cancel - DOWN followed by CANCEL does not fire tap or fling callbacks`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + val cancel = + MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_CANCEL, 100f, 100f, 0) + + sut.onTouchEvent(down) + sut.onTouchEvent(cancel) + + verify(fixture.listener).onDown(down) + verify(fixture.listener, never()).onSingleTapUp(any()) + verify(fixture.listener, never()).onScroll(any(), any(), any(), any()) + verify(fixture.listener, never()).onFling(anyOrNull(), any(), any(), any()) + + down.recycle() + cancel.recycle() + } + + @Test + fun `multi-touch - POINTER_DOWN cancels tap so UP does not fire onSingleTapUp`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + // Second finger touches — encoded as ACTION_POINTER_DOWN with pointer index 1 + val pointerDown = + MotionEvent.obtain( + downTime, + downTime + 20, + (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) or MotionEvent.ACTION_POINTER_DOWN, + 100f, + 100f, + 0, + ) + val up = MotionEvent.obtain(downTime, downTime + 100, MotionEvent.ACTION_UP, 100f, 100f, 0) + + sut.onTouchEvent(down) + sut.onTouchEvent(pointerDown) + sut.onTouchEvent(up) + + verify(fixture.listener).onDown(down) + verify(fixture.listener, never()).onSingleTapUp(any()) + + down.recycle() + pointerDown.recycle() + up.recycle() + } + + @Test + fun `multi-touch - POINTER_DOWN suppresses fling on fast UP`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + // Second finger touches + val pointerDown = + MotionEvent.obtain( + downTime, + downTime + 20, + (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) or MotionEvent.ACTION_POINTER_DOWN, + 100f, + 100f, + 0, + ) + // Second finger lifts + val pointerUp = + MotionEvent.obtain( + downTime, + downTime + 40, + (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) or MotionEvent.ACTION_POINTER_UP, + 100f, + 100f, + 0, + ) + // Last finger lifts quickly and far — would normally trigger fling + val up = MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_UP, 500f, 100f, 0) + + sut.onTouchEvent(down) + sut.onTouchEvent(pointerDown) + sut.onTouchEvent(pointerUp) + sut.onTouchEvent(up) + + verify(fixture.listener, never()).onSingleTapUp(any()) + verify(fixture.listener, never()).onFling(anyOrNull(), any(), any(), any()) + + down.recycle() + pointerDown.recycle() + pointerUp.recycle() + up.recycle() + } + + @Test + fun `multi-touch - tap works again after multi-touch gesture ends`() { + val sut = fixture.getSut() + var downTime = SystemClock.uptimeMillis() + + // First gesture: multi-touch (suppressed) + val down1 = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + val pointerDown = + MotionEvent.obtain( + downTime, + downTime + 20, + (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) or MotionEvent.ACTION_POINTER_DOWN, + 100f, + 100f, + 0, + ) + val up1 = MotionEvent.obtain(downTime, downTime + 100, MotionEvent.ACTION_UP, 100f, 100f, 0) + + sut.onTouchEvent(down1) + sut.onTouchEvent(pointerDown) + sut.onTouchEvent(up1) + + verify(fixture.listener, never()).onSingleTapUp(any()) + + // Second gesture: normal tap — ignoreUpEvent should have been reset + downTime = SystemClock.uptimeMillis() + val down2 = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 200f, 200f, 0) + val up2 = MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_UP, 200f, 200f, 0) + + sut.onTouchEvent(down2) + sut.onTouchEvent(up2) + + verify(fixture.listener).onSingleTapUp(up2) + + down1.recycle() + pointerDown.recycle() + up1.recycle() + down2.recycle() + up2.recycle() + } + + @Test + fun `recycle mid-gesture - subsequent gesture still fires onSingleTapUp`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + + // Start a gesture, then simulate stopTracking() racing in mid-gesture. + val down1 = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + sut.onTouchEvent(down1) + sut.recycle() + + verify(fixture.listener).onDown(down1) + + // New gesture after recycle — velocityTracker and currentDownEvent should be re-obtained + // lazily and the tap path should work as normal. + val downTime2 = SystemClock.uptimeMillis() + val down2 = MotionEvent.obtain(downTime2, downTime2, MotionEvent.ACTION_DOWN, 200f, 200f, 0) + val up2 = MotionEvent.obtain(downTime2, downTime2 + 50, MotionEvent.ACTION_UP, 200f, 200f, 0) + + sut.onTouchEvent(down2) + sut.onTouchEvent(up2) + + verify(fixture.listener).onSingleTapUp(up2) + + down1.recycle() + down2.recycle() + up2.recycle() + } + + @Test + fun `sequential gestures - state resets between tap and scroll`() { + val sut = fixture.getSut() + val beyondSlop = fixture.touchSlop + 10f + + // First gesture: tap + var downTime = SystemClock.uptimeMillis() + val down1 = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + val up1 = MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_UP, 100f, 100f, 0) + + sut.onTouchEvent(down1) + sut.onTouchEvent(up1) + verify(fixture.listener).onSingleTapUp(up1) + + // Second gesture: scroll + downTime = SystemClock.uptimeMillis() + val down2 = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 200f, 200f, 0) + val move2 = + MotionEvent.obtain( + downTime, + downTime + 16, + MotionEvent.ACTION_MOVE, + 200f + beyondSlop, + 200f, + 0, + ) + val up2 = + MotionEvent.obtain( + downTime, + downTime + 5000, + MotionEvent.ACTION_UP, + 200f + beyondSlop, + 200f, + 0, + ) + + sut.onTouchEvent(down2) + sut.onTouchEvent(move2) + sut.onTouchEvent(up2) + + verify(fixture.listener).onScroll(anyOrNull(), eq(move2), any(), any()) + // onSingleTapUp should NOT have been called again for the second gesture + verify(fixture.listener, never()).onSingleTapUp(up2) + + // Third gesture: another tap to verify clean reset + downTime = SystemClock.uptimeMillis() + val down3 = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 300f, 300f, 0) + val up3 = MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_UP, 300f, 300f, 0) + + sut.onTouchEvent(down3) + sut.onTouchEvent(up3) + verify(fixture.listener).onSingleTapUp(up3) + + down1.recycle() + up1.recycle() + down2.recycle() + move2.recycle() + up2.recycle() + down3.recycle() + up3.recycle() + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt index efe651e4389..81950647fa9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt @@ -17,6 +17,7 @@ import io.sentry.Scope.IWithPropagationContext import io.sentry.ScopeCallback import io.sentry.SentryLevel.INFO import io.sentry.android.core.SentryAndroidOptions +import io.sentry.util.LazyEvaluator import kotlin.test.Test import kotlin.test.assertEquals import org.mockito.kotlin.any @@ -38,7 +39,7 @@ class SentryGestureListenerClickTest { SentryAndroidOptions().apply { isEnableUserInteractionBreadcrumbs = true isEnableUserInteractionTracing = true - gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) + gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true })) dsn = "https://key@sentry.io/proj" } val scopes = mock() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerPeekDecorViewTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerPeekDecorViewTest.kt new file mode 100644 index 00000000000..7b26aa55c79 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerPeekDecorViewTest.kt @@ -0,0 +1,48 @@ +package io.sentry.android.core.internal.gestures + +import android.app.Activity +import android.view.MotionEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Breadcrumb +import io.sentry.IScopes +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.util.LazyEvaluator +import kotlin.test.Test +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.Robolectric.buildActivity + +@RunWith(AndroidJUnit4::class) +class SentryGestureListenerPeekDecorViewTest { + + @Test + fun `does not force decor view creation when peekDecorView returns null`() { + // A plain Activity that never calls setContentView — peekDecorView() should return null + val activity = buildActivity(Activity::class.java).create().get() + + // Sanity check: decor view has not been created yet + assertNull(activity.window.peekDecorView()) + + val scopes = mock() + val options = + SentryAndroidOptions().apply { + isEnableUserInteractionBreadcrumbs = true + gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true })) + dsn = "https://key@sentry.io/proj" + } + + val sut = SentryGestureListener(activity, scopes, options) + sut.onSingleTapUp(mock()) + + // The key assertion: peekDecorView is still null — we did not force view hierarchy creation + assertNull(activity.window.peekDecorView()) + + // And no breadcrumb was captured + verify(scopes, never()).addBreadcrumb(any(), anyOrNull()) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt index 3dd1f726d7b..633bb2fdb86 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt @@ -20,6 +20,7 @@ import io.sentry.ScopeCallback import io.sentry.SentryLevel import io.sentry.SentryLevel.INFO import io.sentry.android.core.SentryAndroidOptions +import io.sentry.util.LazyEvaluator import kotlin.test.Test import kotlin.test.assertEquals import org.mockito.kotlin.any @@ -46,7 +47,7 @@ class SentryGestureListenerScrollTest { dsn = "https://key@sentry.io/proj" isEnableUserInteractionBreadcrumbs = true isEnableUserInteractionTracing = true - gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) + gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true })) } val scopes = mock() val scope = mock() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt index 3f8ba2d3003..9d7606bfe44 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt @@ -23,6 +23,7 @@ import io.sentry.TransactionOptions import io.sentry.android.core.SentryAndroidOptions import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource +import io.sentry.util.LazyEvaluator import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals @@ -65,7 +66,8 @@ class SentryGestureListenerTracingTest { options.tracesSampleRate = tracesSampleRate options.isEnableUserInteractionTracing = isEnableUserInteractionTracing options.isEnableUserInteractionBreadcrumbs = true - options.gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) + options.gestureTargetLocators = + listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true })) options.isEnableAutoTraceIdGeneration = isEnableAutoTraceIdGeneration whenever(scopes.options).thenReturn(options) @@ -158,6 +160,18 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) } + @Test + fun `when a transaction is already bound to the Scope, does not start a new UI transaction`() { + val sut = fixture.getSut() + val boundTransaction = SentryTracer(TransactionContext("bound", "op"), fixture.scopes) + whenever(fixture.scope.transaction).thenReturn(boundTransaction) + + sut.onSingleTapUp(fixture.event) + + verify(fixture.scopes, never()).startTransaction(any(), any()) + assertEquals(false, boundTransaction.isFinished) + } + @Test fun `stopTracing remove transaction from scope`() { val sut = fixture.getSut() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryWindowCallbackTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryWindowCallbackTest.kt index 856e6d0f156..be0438d9345 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryWindowCallbackTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryWindowCallbackTest.kt @@ -2,7 +2,6 @@ package io.sentry.android.core.internal.gestures import android.view.MotionEvent import android.view.Window -import androidx.core.view.GestureDetectorCompat import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.internal.gestures.SentryWindowCallback.MotionEventObtainer import kotlin.test.Test @@ -18,7 +17,7 @@ class SentryWindowCallbackTest { class Fixture { val delegate = mock() val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } - val gestureDetector = mock() + val gestureDetector = mock() val gestureListener = mock() val motionEventCopy = mock() @@ -83,4 +82,18 @@ class SentryWindowCallbackTest { verify(fixture.gestureDetector, never()).onTouchEvent(any()) } + + @Test + fun `after stopTracking does not forward touches to detector or listener`() { + val event = mock { whenever(it.actionMasked).thenReturn(MotionEvent.ACTION_UP) } + val sut = fixture.getSut() + + sut.stopTracking() + sut.dispatchTouchEvent(event) + + verify(fixture.gestureDetector, never()).onTouchEvent(any()) + verify(fixture.gestureListener, never()).onUp(any()) + // super.dispatchTouchEvent still delegates to the wrapped delegate so the chain keeps working. + verify(fixture.delegate).dispatchTouchEvent(event) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt index 86123d0a3a2..15123ce0a31 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt @@ -5,9 +5,6 @@ import android.content.res.Resources import android.view.MotionEvent import android.view.View import android.view.Window -import kotlin.math.abs -import org.mockito.kotlin.any -import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -22,7 +19,7 @@ internal inline fun Window.mockDecorView( finalize: (T) -> Unit = {}, ): T { val view = mockView(id, event, touchWithinBounds, clickable, visible, context, finalize) - whenever(decorView).doReturn(view) + whenever(peekDecorView()).doReturn(view) return view } @@ -35,31 +32,17 @@ internal inline fun mockView( context: Context? = null, finalize: (T) -> Unit = {}, ): T { - val coordinates = IntArray(2) - if (!touchWithinBounds) { - coordinates[0] = (event.x).toInt() + 10 - coordinates[1] = (event.y).toInt() + 10 - } else { - coordinates[0] = (event.x).toInt() - 10 - coordinates[1] = (event.y).toInt() - 10 - } + // The decor-view-relative touch point used in these tests is (0, 0), and child views are mocked + // at offset (0, 0), so the point reaches every view unchanged. A view therefore contains the + // touch iff its width/height are non-negative; a negative size marks the touch as outside. + val size = if (touchWithinBounds) 10 else -1 val mockView: T = mock { whenever(it.id).thenReturn(id) whenever(it.context).thenReturn(context) whenever(it.isClickable).thenReturn(clickable) whenever(it.visibility).thenReturn(if (visible) View.VISIBLE else View.GONE) - - whenever(it.getLocationOnScreen(any())).doAnswer { - val array = it.arguments[0] as IntArray - array[0] = coordinates[0] - array[1] = coordinates[1] - null - } - - val diffPosX = abs(event.x - coordinates[0]).toInt() - val diffPosY = abs(event.y - coordinates[1]).toInt() - whenever(it.width).thenReturn(diffPosX + 10) - whenever(it.height).thenReturn(diffPosY + 10) + whenever(it.width).thenReturn(size) + whenever(it.height).thenReturn(size) finalize(this.mock) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt index 77a38e6ccc1..ed3e6d8ca89 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt @@ -2,24 +2,120 @@ package io.sentry.android.core.internal.gestures import android.content.Context import android.content.res.Resources +import android.graphics.Matrix import android.view.View +import android.view.ViewGroup +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.internal.gestures.UiElement +import io.sentry.util.LazyEvaluator import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doThrow import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@RunWith(AndroidJUnit4::class) class ViewUtilsTest { @Test - fun `getResourceId returns resourceId when available`() { + fun `findTarget hit-tests children in their own local coordinate space`() { + val child = clickableChild() + val decorView = + mock { + whenever(it.width).thenReturn(1000) + whenever(it.height).thenReturn(1000) + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(child) + } + val options = optionsWithViewLocator() + + // (120, 220) maps to (20, 20) in the child's space -> inside its 50x50 bounds. + assertNotNull(ViewUtils.findTarget(options, decorView, 120f, 220f, UiElement.Type.CLICKABLE)) + + // (90, 220) maps to (-10, 20) in the child's space -> outside, despite being inside the decor. + assertNull(ViewUtils.findTarget(options, decorView, 90f, 220f, UiElement.Type.CLICKABLE)) + } + + @Test + fun `findTarget accounts for parent scroll when mapping into a child`() { + val child = clickableChild() + val decorView = + mock { + whenever(it.width).thenReturn(1000) + whenever(it.height).thenReturn(1000) + whenever(it.scrollX).thenReturn(30) + whenever(it.scrollY).thenReturn(40) + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(child) + } + val options = optionsWithViewLocator() + + // With scroll (30, 40), (90, 180) maps to (90 + 30 - 100, 180 + 40 - 200) = (20, 20) -> inside. + assertNotNull(ViewUtils.findTarget(options, decorView, 90f, 180f, UiElement.Type.CLICKABLE)) + + // The same point without accounting for scroll would map to (-10, -20) -> outside the child. + assertNull(ViewUtils.findTarget(options, decorView, 50f, 140f, UiElement.Type.CLICKABLE)) + } + + @Test + fun `findTarget applies the inverse of a non-identity child matrix`() { + // The child is visually translated by (40, 40) within its parent, so a parent-space point is + // mapped back by (-40, -40) to reach the child's own coordinate space. + val matrix = Matrix().apply { setTranslate(40f, 40f) } + val child = clickableChild { whenever(it.matrix).thenReturn(matrix) } + val decorView = + mock { + whenever(it.width).thenReturn(1000) + whenever(it.height).thenReturn(1000) + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(child) + } + val options = optionsWithViewLocator() + + // (180, 280) lands at (80, 80) before the matrix (outside 50x50), but the inverse pulls it to + // (40, 40) -> inside. + assertNotNull(ViewUtils.findTarget(options, decorView, 180f, 280f, UiElement.Type.CLICKABLE)) + + // (130, 230) lands at (30, 30) before the matrix (inside), but the inverse pushes it to + // (-10, -10) -> outside. + assertNull(ViewUtils.findTarget(options, decorView, 130f, 230f, UiElement.Type.CLICKABLE)) + } + + // A clickable child positioned at (100, 200) within its parent, 50x50 in size. + private fun clickableChild(finalize: (View) -> Unit = {}): View { + val context = mock() + val resources = mock() + whenever(context.resources).thenReturn(resources) + whenever(resources.getResourceEntryName(any())).thenReturn("child") + return mock { + whenever(it.id).thenReturn(0x7f010001) + whenever(it.context).thenReturn(context) + whenever(it.isClickable).thenReturn(true) + whenever(it.visibility).thenReturn(View.VISIBLE) + whenever(it.left).thenReturn(100) + whenever(it.top).thenReturn(200) + whenever(it.width).thenReturn(50) + whenever(it.height).thenReturn(50) + finalize(this.mock) + } + } + + private fun optionsWithViewLocator(): SentryAndroidOptions = + SentryAndroidOptions().apply { + gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true })) + } + + @Test + fun `getResourceIdOrNull returns resource name when available`() { val view = mock { - whenever(it.id).doReturn(View.generateViewId()) + whenever(it.id).doReturn(0x7f010001) val context = mock() val resources = mock() @@ -28,56 +124,50 @@ class ViewUtilsTest { whenever(it.context).thenReturn(context) } - assertEquals(ViewUtils.getResourceId(view), "test_view") + assertEquals("test_view", ViewUtils.getResourceIdOrNull(view)) } @Test - fun `getResourceId throws when resource id is not available`() { + fun `getResourceIdOrNull returns null without throwing for generated id`() { + val context = mock() val view = mock { - whenever(it.id).doReturn(View.generateViewId()) - - val context = mock() - val resources = mock() - whenever(resources.getResourceEntryName(any())).doThrow(Resources.NotFoundException()) - whenever(context.resources).thenReturn(resources) + // View.generateViewId() starts with 1 + whenever(it.id).doReturn(1) whenever(it.context).thenReturn(context) } - assertFailsWith { ViewUtils.getResourceId(view) } + assertNull(ViewUtils.getResourceIdOrNull(view)) + verify(context, never()).resources } @Test - fun `when view has no id set, resource name is not looked up `() { + fun `getResourceIdOrNull returns null without throwing when view has no id`() { val context = mock() - val resources = mock() - whenever(context.resources).thenReturn(resources) - val view = mock { whenever(it.id).doReturn(View.NO_ID) whenever(it.context).thenReturn(context) } - assertFailsWith { ViewUtils.getResourceId(view) } + assertNull(ViewUtils.getResourceIdOrNull(view)) verify(context, never()).resources } @Test - fun `when view id is generated, resource name is not looked up `() { - val context = mock() - val resources = mock() - whenever(context.resources).thenReturn(resources) - + fun `getResourceIdOrNull returns null without throwing when resource not found`() { val view = mock { - // View.generateViewId() starts with 1 - whenever(it.id).doReturn(1) + whenever(it.id).doReturn(1234) + + val context = mock() + val resources = mock() + whenever(resources.getResourceEntryName(it.id)).thenThrow(Resources.NotFoundException()) + whenever(context.resources).thenReturn(resources) whenever(it.context).thenReturn(context) } - assertFailsWith { ViewUtils.getResourceId(view) } - verify(context, never()).resources + assertNull(ViewUtils.getResourceIdOrNull(view)) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/modules/AssetsModulesLoaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/modules/AssetsModulesLoaderTest.kt index 9df02a067d0..34d03b175d7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/modules/AssetsModulesLoaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/modules/AssetsModulesLoaderTest.kt @@ -2,7 +2,8 @@ package io.sentry.android.core.internal.modules import android.content.Context import android.content.res.AssetManager -import io.sentry.ILogger +import io.sentry.SentryOptions +import io.sentry.test.ImmediateExecutorService import java.io.FileNotFoundException import java.nio.charset.Charset import kotlin.test.Test @@ -16,7 +17,7 @@ class AssetsModulesLoaderTest { class Fixture { val context = mock() val assets = mock() - val logger = mock() + val options = SentryOptions().apply { executorService = ImmediateExecutorService() } fun getSut( fileName: String = "sentry-external-modules.txt", @@ -31,7 +32,7 @@ class AssetsModulesLoaderTest { whenever(assets.open(fileName)).thenThrow(FileNotFoundException()) } whenever(context.assets).thenReturn(assets) - return AssetsModulesLoader(context, logger) + return AssetsModulesLoader(context, options) } } @@ -43,9 +44,9 @@ class AssetsModulesLoaderTest { fixture.getSut( content = """ - com.squareup.okhttp3:okhttp:3.14.9 - com.squareup.okio:okio:1.17.2 - """ + com.squareup.okhttp3:okhttp:3.14.9 + com.squareup.okio:okio:1.17.2 + """ .trimIndent() ) @@ -61,9 +62,9 @@ class AssetsModulesLoaderTest { fixture.getSut( content = """ - com.squareup.okhttp3:okhttp:3.14.9 - com.squareup.okio:okio:1.17.2 - """ + com.squareup.okhttp3:okhttp:3.14.9 + com.squareup.okio:okio:1.17.2 + """ .trimIndent() ) @@ -92,8 +93,8 @@ class AssetsModulesLoaderTest { fixture.getSut( content = """ - com.squareup.okhttp3;3.14.9 - """ + com.squareup.okhttp3;3.14.9 + """ .trimIndent() ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ArtContextParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ArtContextParserTest.kt new file mode 100644 index 00000000000..b468127660c --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ArtContextParserTest.kt @@ -0,0 +1,130 @@ +package io.sentry.android.core.internal.threaddump + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ArtContextParserTest { + + @Test + fun `parses pretty size bytes`() { + val parser = ArtContextParser() + parser.parseLine("Free memory 0B") + assertEquals(0L, parser.artContext!!.freeMemory) + + val parser2 = ArtContextParser() + parser2.parseLine("Free memory 512B") + assertEquals(512L, parser2.artContext!!.freeMemory) + } + + @Test + fun `parses pretty size kilobytes`() { + val parser = ArtContextParser() + parser.parseLine("Free memory 3107KB") + assertEquals(3107L * 1024, parser.artContext!!.freeMemory) + } + + @Test + fun `parses pretty size megabytes`() { + val parser = ArtContextParser() + parser.parseLine("Free memory until OOME 187MB") + assertEquals(187L * 1024 * 1024, parser.artContext!!.freeMemoryUntilOome) + } + + @Test + fun `parses pretty size gigabytes`() { + val parser = ArtContextParser() + parser.parseLine("Max memory 2GB") + assertEquals(2L * 1024 * 1024 * 1024, parser.artContext!!.maxMemory) + } + + @Test + fun `sets null for invalid pretty size`() { + val parser = ArtContextParser() + parser.parseLine("Free memory 100TB") + assertNull(parser.artContext!!.freeMemory) + } + + @Test + fun `parses time in milliseconds`() { + val parser = ArtContextParser() + parser.parseLine("Total GC time: 11.807ms") + assertEquals(11.807, parser.artContext!!.gcTotalTime) + } + + @Test + fun `parses time in seconds`() { + val parser = ArtContextParser() + parser.parseLine("Total GC time: 2.5s") + assertEquals(2500.0, parser.artContext!!.gcTotalTime) + } + + @Test + fun `parses time in microseconds`() { + val parser = ArtContextParser() + parser.parseLine("Total GC time: 500us") + assertEquals(0.5, parser.artContext!!.gcTotalTime) + } + + @Test + fun `parses time in nanoseconds`() { + val parser = ArtContextParser() + parser.parseLine("Total GC time: 1000000ns") + assertEquals(1.0, parser.artContext!!.gcTotalTime) + } + + @Test + fun `parses zero duration`() { + val parser = ArtContextParser() + parser.parseLine("Total GC time: 0") + assertEquals(0.0, parser.artContext!!.gcTotalTime) + } + + @Test + fun `parses all memory fields`() { + val parser = ArtContextParser() + parser.parseLine("Free memory 3107KB") + parser.parseLine("Free memory until GC 3107KB") + parser.parseLine("Free memory until OOME 187MB") + parser.parseLine("Total memory 7592KB") + parser.parseLine("Max memory 192MB") + + val info = parser.artContext + assertNotNull(info) + assertEquals(3107L * 1024, info.freeMemory) + assertEquals(3107L * 1024, info.freeMemoryUntilGc) + assertEquals(187L * 1024 * 1024, info.freeMemoryUntilOome) + assertEquals(7592L * 1024, info.totalMemory) + assertEquals(192L * 1024 * 1024, info.maxMemory) + } + + @Test + fun `parses all gc fields`() { + val parser = ArtContextParser() + parser.parseLine("Total time waiting for GC to complete: 8.054ms") + parser.parseLine("Total GC count: 1") + parser.parseLine("Total GC time: 11.807ms") + parser.parseLine("Total blocking GC count: 1") + parser.parseLine("Total blocking GC time: 11.873ms") + parser.parseLine("Total pre-OOME GC count: 0") + + val info = parser.artContext + assertNotNull(info) + assertEquals(8.054, info.gcWaitingTime) + assertEquals(1L, info.gcTotalCount) + assertEquals(11.807, info.gcTotalTime) + assertEquals(1L, info.gcBlockingCount) + assertEquals(11.873, info.gcBlockingTime) + assertEquals(0L, info.gcPreOomeCount) + } + + @Test + fun `ignores unrelated lines`() { + val parser = ArtContextParser() + parser.parseLine("some random line") + parser.parseLine("DALVIK THREADS (29):") + parser.parseLine("") + assertNull(parser.artContext) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt index 604e2e84189..c5798be2111 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt @@ -52,8 +52,9 @@ class ThreadDumpParserTest { assertEquals(SentryLockReason.SLEEPING, blockingThread.heldLocks!!["0x09228c2d"]!!.type) assertEquals(null, blockingThread.heldLocks!!["0x09228c2d"]!!.threadId) - val randomThread = - threads.find { it.name == "io.sentry.android.core.internal.util.SentryFrameMetricsCollector" } + val randomThread = threads.find { + it.name == "io.sentry.android.core.internal.util.SentryFrameMetricsCollector" + } assertEquals(19, randomThread!!.id) assertEquals("Native", randomThread.state) assertEquals(false, randomThread.isCrashed) @@ -100,12 +101,15 @@ class ThreadDumpParserTest { parser.parse(lines) val threads = parser.threads // just verifying a few important threads, as there are many - val thread = threads.find { it.name == "samples.android" } + // the OS named the main thread after the process; it is detected via sysTid==processId (9955) + // and its name is normalized back to "main" + val thread = threads.find { it.isMain == true } assertEquals(9955, thread!!.id) + assertEquals("main", thread.name) assertNull(thread.state) - assertEquals(false, thread.isCrashed) - assertEquals(false, thread.isMain) - assertEquals(false, thread.isCurrent) + assertEquals(true, thread.isCrashed) + assertEquals(true, thread.isMain) + assertEquals(true, thread.isCurrent) // Reverse frames so we can index them with the active frame at index 0 val frames = thread.stacktrace!!.frames!!.reversed() @@ -152,14 +156,69 @@ class ThreadDumpParserTest { assertNull(deletedFrame.addrMode) val debugImages = parser.debugImages - val image = - debugImages.first { image -> image.debugId == "499d48ba-c085-17cf-3209-da67405662f9" } + val image = debugImages.first { image -> + image.debugId == "499d48ba-c085-17cf-3209-da67405662f9" + } assertNotNull(image) assertEquals("499d48ba-c085-17cf-3209-da67405662f9", image.debugId) assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", image.codeFile) assertEquals("ba489d4985c0cf173209da67405662f9", image.codeId) } + @Test + fun `parses memory info from thread dump`() { + val lines = Lines.readLines(File("src/test/resources/thread_dump.txt")) + val parser = + ThreadDumpParser(SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false) + parser.parse(lines) + + val artContext = parser.artContext + assertNotNull(artContext) + assertEquals(3107L * 1024, artContext.freeMemory) + assertEquals(3107L * 1024, artContext.freeMemoryUntilGc) + assertEquals(187L * 1024 * 1024, artContext.freeMemoryUntilOome) + assertEquals(7592L * 1024, artContext.totalMemory) + assertEquals(192L * 1024 * 1024, artContext.maxMemory) + assertEquals(1L, artContext.gcTotalCount) + assertEquals(11.807, artContext.gcTotalTime) + assertEquals(1L, artContext.gcBlockingCount) + assertEquals(11.873, artContext.gcBlockingTime) + assertEquals(0L, artContext.gcPreOomeCount) + assertEquals(8.054, artContext.gcWaitingTime) + } + + @Test + fun `detects main thread via sysTid matching the process id when OS renames it`() { + val lines = Lines.readLines(File("src/test/resources/thread_dump_process_name_main.txt")) + val parser = + ThreadDumpParser(SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false) + parser.parse(lines) + val threads = parser.threads + // the main thread has been renamed to the (truncated) process name, but its sysTid equals the + // process id, which is how we detect it - its name is then normalized back to "main" + val main = threads.find { it.isMain == true } + assertNotNull(main) + assertEquals("main", main!!.name) + assertEquals(true, main.isCrashed) + assertEquals(true, main.isCurrent) + val background = threads.find { it.name == "Thread-2" } + assertNotNull(background) + assertEquals(false, background!!.isMain) + assertEquals(false, background.isCrashed) + } + + @Test + fun `skips threads without a stacktrace`() { + val lines = Lines.readLines(File("src/test/resources/thread_dump_no_stacktrace.txt")) + val parser = + ThreadDumpParser(SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false) + parser.parse(lines) + val threads = parser.threads + // the thread without any frames is skipped, only the one with a stacktrace remains + assertEquals(1, threads.size) + assertEquals("main", threads.first().name) + } + @Test fun `thread dump garbage`() { val lines = Lines.readLines(File("src/test/resources/thread_dump_bad_data.txt")) @@ -168,4 +227,13 @@ class ThreadDumpParserTest { parser.parse(lines) assertTrue(parser.threads.isEmpty()) } + + @Test + fun `garbage thread dump has no memory info`() { + val lines = Lines.readLines(File("src/test/resources/thread_dump_bad_data.txt")) + val parser = + ThreadDumpParser(SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false) + parser.parse(lines) + assertNull(parser.artContext) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt new file mode 100644 index 00000000000..70fc48fd9be --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt @@ -0,0 +1,509 @@ +package io.sentry.android.core.internal.tombstone + +import com.abovevacant.epitaph.core.BacktraceFrame +import com.abovevacant.epitaph.core.MemoryMapping +import com.abovevacant.epitaph.core.Signal +import com.abovevacant.epitaph.core.Tombstone +import com.abovevacant.epitaph.core.TombstoneThread +import io.sentry.ILogger +import io.sentry.JsonObjectWriter +import io.sentry.protocol.DebugMeta +import java.io.StringWriter +import java.util.zip.GZIPInputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import org.mockito.kotlin.mock + +class TombstoneParserTest { + val expectedRegisters = + setOf( + "x8", + "x9", + "esr", + "lr", + "pst", + "x10", + "x12", + "x11", + "x14", + "x13", + "x16", + "x15", + "sp", + "x18", + "x17", + "x19", + "pc", + "x21", + "x20", + "x0", + "x23", + "x1", + "x22", + "x2", + "x25", + "x3", + "x24", + "x4", + "x27", + "x5", + "x26", + "x6", + "x29", + "x7", + "x28", + ) + + val inAppIncludes = arrayListOf("io.sentry.samples.android") + val inAppExcludes = arrayListOf() + val nativeLibraryDir = + "/data/app/~~gu-2hA9_Zg6tfIuDAbLpKA==/io.sentry.samples.android-MFqmKAMnl9AjNlHcO3mejA==/lib/arm64" + + val parser = TombstoneParser(inAppIncludes, inAppExcludes, nativeLibraryDir) + + @Test + fun `parses a snapshot tombstone into Event`() { + val tombstoneStream = + GZIPInputStream(TombstoneParserTest::class.java.getResourceAsStream("/tombstone.pb.gz")) + val streamParser = + TombstoneParser(tombstoneStream, inAppIncludes, inAppExcludes, nativeLibraryDir) + val event = streamParser.parse() + + // top-level data + assertNotNull(event.eventId) + assertEquals( + "Fatal signal SIGSEGV (11), SEGV_MAPERR (1), pid = 21891 (io.sentry.samples.android)", + event.message!!.formatted, + ) + assertEquals("native", event.platform) + assertEquals("FATAL", event.level!!.name) + + // exception + // we only track one native exception (no nesting, one crashed thread) + assertEquals(1, event.exceptions!!.size) + val exception = event.exceptions!![0] + assertEquals("SIGSEGV", exception.type) + assertEquals("Segfault", exception.value) + val crashedThreadId = exception.threadId + assertNotNull(crashedThreadId) + + val mechanism = exception.mechanism + assertEquals("Tombstone", mechanism!!.type) + assertEquals(false, mechanism.isHandled) + assertEquals(true, mechanism.synthetic) + assertEquals("SIGSEGV", mechanism.meta!!["name"]) + assertEquals(11, mechanism.meta!!["number"]) + assertEquals("SEGV_MAPERR", mechanism.meta!!["code_name"]) + assertEquals(1, mechanism.meta!!["code"]) + + // threads + assertEquals(62, event.threads!!.size) + val mainThread = event.threads!!.single { it.isMain == true } + assertEquals(21891, mainThread.id) + assertEquals("main", mainThread.name) + + for (thread in event.threads!!) { + assertNotNull(thread.id) + if (thread.id == crashedThreadId) { + assert(thread.isCrashed == true) + } + assert(thread.stacktrace!!.frames!!.isNotEmpty()) + + for (frame in thread.stacktrace!!.frames!!) { + assertNotNull(frame.function) + assertNotNull(frame.`package`) + assertNotNull(frame.instructionAddr) + + if (thread.id == crashedThreadId) { + if (frame.isInApp!!) { + assert( + frame.function!!.startsWith(inAppIncludes[0]) || + frame.`package`!!.startsWith(nativeLibraryDir) + ) + } + } + } + + assert(thread.stacktrace!!.registers!!.keys.containsAll(expectedRegisters)) + } + + // debug-meta + assertEquals(352, event.debugMeta!!.images!!.size) + for (image in event.debugMeta!!.images!!) { + assertEquals("elf", image.type) + assertNotNull(image.debugId) + assertNotNull(image.codeId) + assertNotNull(image.codeFile) + val imageAddress = image.imageAddr!!.removePrefix("0x").toLong(16) + assert(imageAddress > 0) + assert(image.imageSize!! > 0) + } + } + + @Test + fun `coalesces multiple memory mappings into single module`() { + // Simulate typical Android memory mappings where a single ELF file has multiple + // mappings with different permissions (r--p, r-xp, r--p, rw-p) + val buildId = "f1c3bcc0279865fe3058404b2831d9e64135386c" + + val tombstone = + Tombstone.Builder() + .pid(1234) + .tid(1234) + .signal(Signal(11, "SIGSEGV", 1, "SEGV_MAPERR", false, 0, 0, false, 0, null)) + // First mapping: r--p at offset 0 (ELF header, has build_id) + .addMemoryMapping( + MemoryMapping( + 0x7000000000, + 0x7000001000, + 0, + true, + false, + false, + "/system/lib64/libc.so", + buildId, + 0, + ) + ) + // Second mapping: r-xp at offset 0x1000 (executable segment) + .addMemoryMapping( + MemoryMapping( + 0x7000001000, + 0x7000010000, + 0x1000, + true, + false, + true, + "/system/lib64/libc.so", + buildId, + 0, + ) + ) + // Third mapping: r--p at offset 0x10000 (read-only data) + .addMemoryMapping( + MemoryMapping( + 0x7000010000, + 0x7000011000, + 0x10000, + true, + false, + false, + "/system/lib64/libc.so", + buildId, + 0, + ) + ) + // Fourth mapping: rw-p at offset 0x11000 (writable data) + .addMemoryMapping( + MemoryMapping( + 0x7000011000, + 0x7000012000, + 0x11000, + true, + true, + false, + "/system/lib64/libc.so", + buildId, + 0, + ) + ) + .addThread( + TombstoneThread( + 1234, + "main", + emptyList(), + emptyList(), + emptyList(), + listOf(BacktraceFrame(0, 0x7000001100, 0, "crash", 0, "/system/lib64/libc.so", 0, "")), + emptyList(), + 0, + 0, + ) + ) + .build() + + val event = parser.parse(tombstone) + + // All 4 mappings should be coalesced into a single module + val images = event.debugMeta!!.images!! + assertEquals(1, images.size) + + val image = images[0] + assertEquals("/system/lib64/libc.so", image.codeFile) + assertEquals(buildId, image.codeId) + // Module should span from first mapping start to last mapping end + assertEquals("0x7000000000", image.imageAddr) + assertEquals(0x7000012000 - 0x7000000000, image.imageSize) + } + + @Test + fun `handles duplicate mappings at offset 0 on Android`() { + // On some Android versions, the same ELF can have multiple mappings at offset 0 + // with different permissions (r--p and r-xp both at offset 0) + val buildId = "f1c3bcc0279865fe3058404b2831d9e64135386c" + + val tombstone = + Tombstone.Builder() + .pid(1234) + .tid(1234) + .signal(Signal(11, "SIGSEGV", 1, "SEGV_MAPERR", false, 0, 0, false, 0, null)) + // First mapping: r--p at offset 0 + .addMemoryMapping( + MemoryMapping( + 0x7000000000, + 0x7000001000, + 0, + true, + false, + false, + "/system/lib64/libdl.so", + buildId, + 0, + ) + ) + // Second mapping: r-xp at offset 0 (duplicate!) + .addMemoryMapping( + MemoryMapping( + 0x7000001000, + 0x7000002000, + 0, + true, + false, + true, + "/system/lib64/libdl.so", + buildId, + 0, + ) + ) + // Third mapping: r--p at offset 0 (another duplicate!) + .addMemoryMapping( + MemoryMapping( + 0x7000002000, + 0x7000003000, + 0, + true, + false, + false, + "/system/lib64/libdl.so", + buildId, + 0, + ) + ) + .addThread( + TombstoneThread( + 1234, + "main", + emptyList(), + emptyList(), + emptyList(), + listOf(BacktraceFrame(0, 0x7000001100, 0, "crash", 0, "/system/lib64/libdl.so", 0, "")), + emptyList(), + 0, + 0, + ) + ) + .build() + + val event = parser.parse(tombstone) + + val images = event.debugMeta!!.images!! + assertEquals(1, images.size) + + val image = images[0] + assertEquals("/system/lib64/libdl.so", image.codeFile) + // Module should span from first to last mapping + assertEquals("0x7000000000", image.imageAddr) + assertEquals(0x7000003000 - 0x7000000000, image.imageSize) + } + + @Test + fun `debugId falls back to codeId when OleGuidFormatter conversion fails`() { + // Create a tombstone with a memory mapping that has an invalid buildId + // (contains 'ZZ' which are not valid hex characters) + val invalidBuildId = "ZZ00112233445566778899aabbccddeeff00112233" + val validBuildId = "f1c3bcc0279865fe3058404b2831d9e64135386c" + + val tombstone = + Tombstone.Builder() + .pid(1234) + .tid(1234) + .signal(Signal(11, "SIGSEGV", 1, "SEGV_MAPERR", false, 0, 0, false, 0, null)) + .addMemoryMapping( + MemoryMapping( + 0x7000000000, + 0x7000001000, + 0, + true, + false, + true, + "/system/lib64/libc.so", + invalidBuildId, + 0, + ) + ) + .addMemoryMapping( + MemoryMapping( + 0x7000002000, + 0x7000003000, + 0, + true, + false, + true, + "/system/lib64/libm.so", + validBuildId, + 0, + ) + ) + .addThread( + TombstoneThread( + 1234, + "main", + emptyList(), + emptyList(), + emptyList(), + listOf(BacktraceFrame(0, 0x7000000100, 0, "crash", 0, "/system/lib64/libc.so", 0, "")), + emptyList(), + 0, + 0, + ) + ) + .build() + + val event = parser.parse(tombstone) + + val images = event.debugMeta!!.images!! + assertEquals(2, images.size) + + // First image has invalid buildId -> debugId should fall back to codeId + val invalidImage = images.find { it.codeFile == "/system/lib64/libc.so" }!! + assertEquals(invalidBuildId, invalidImage.codeId) + assertEquals(invalidBuildId, invalidImage.debugId) + + // Second image has valid buildId -> debugId should be converted + val validImage = images.find { it.codeFile == "/system/lib64/libm.so" }!! + assertEquals(validBuildId, validImage.codeId) + assertEquals("c0bcc3f1-9827-fe65-3058-404b2831d9e6", validImage.debugId) + } + + @Test + fun `debug meta images snapshot test`() { + // test against a full snapshot so that we can track regressions in the VMA -> module reduction + val tombstoneStream = + GZIPInputStream(TombstoneParserTest::class.java.getResourceAsStream("/tombstone.pb.gz")) + val streamParser = + TombstoneParser(tombstoneStream, inAppIncludes, inAppExcludes, nativeLibraryDir) + val event = streamParser.parse() + + val actualJson = serializeDebugMeta(event.debugMeta!!) + val expectedJson = readGzippedResourceFile("/tombstone_debug_meta.json.gz") + + assertEquals(expectedJson, actualJson) + } + + @Test + fun `identifies the main thread via pid matching the thread id and normalizes its name`() { + val tombstone = + Tombstone.Builder() + .pid(1000) + .tid(2000) + .signal(Signal(11, "SIGSEGV", 1, "SEGV_MAPERR", false, 0, 0, false, 0, null)) + // main thread: id == pid, but the OS renamed it to the process name + .addThread( + TombstoneThread( + 1000, + "io.sentry.samples.android", + emptyList(), + emptyList(), + emptyList(), + listOf(BacktraceFrame(0, 0x100, 0, "main", 0, "/system/lib64/libc.so", 0, "")), + emptyList(), + 0, + 0, + ) + ) + .addThread( + TombstoneThread( + 2000, + "crashed-worker", + emptyList(), + emptyList(), + emptyList(), + listOf(BacktraceFrame(0, 0x200, 0, "crash", 0, "/system/lib64/libc.so", 0, "")), + emptyList(), + 0, + 0, + ) + ) + .addThread( + TombstoneThread( + 3000, + "Thread-3", + emptyList(), + emptyList(), + emptyList(), + listOf(BacktraceFrame(0, 0x300, 0, "work", 0, "/system/lib64/libc.so", 0, "")), + emptyList(), + 0, + 0, + ) + ) + .build() + + val event = parser.parse(tombstone) + val threads = event.threads!! + + val main = threads.single { it.isMain == true } + assertEquals(1000, main.id) + assertEquals("main", main.name) + + val crashed = threads.single { it.isCrashed == true } + assertEquals(2000, crashed.id) + assertNotEquals(true, crashed.isMain) + assertEquals("crashed-worker", crashed.name) + + val background = threads.single { it.id == 3000L } + assertNotEquals(true, background.isMain) + assertNotEquals(true, background.isCrashed) + assertEquals("Thread-3", background.name) + } + + @Test + fun `parses tombstone when nativeLibraryDir is null`() { + val tombstoneStream = + GZIPInputStream(TombstoneParserTest::class.java.getResourceAsStream("/tombstone.pb.gz")) + val parser = TombstoneParser(tombstoneStream, inAppIncludes, inAppExcludes, null) + val event = parser.parse() + + // Parsing should succeed without NPE + assertNotNull(event) + assertEquals(62, event.threads!!.size) + + // Without nativeLibraryDir, frames can only be marked inApp via inAppIncludes + // All frames should still have inApp set (either true or false) + for (thread in event.threads!!) { + for (frame in thread.stacktrace!!.frames!!) { + assertNotNull(frame.isInApp) + } + } + } + + private fun serializeDebugMeta(debugMeta: DebugMeta): String { + val logger = mock() + val writer = StringWriter() + val jsonWriter = JsonObjectWriter(writer, 100) + debugMeta.serialize(jsonWriter, logger) + return writer.toString() + } + + private fun readGzippedResourceFile(path: String): String { + return TombstoneParserTest::class + .java + .getResourceAsStream(path) + ?.let { GZIPInputStream(it) } + ?.bufferedReader() + ?.use { it.readText().replace(Regex("[\\n\\r\\s]"), "") } + ?: throw RuntimeException("Cannot read resource file: $path") + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt index 7d27984a599..4dd80624647 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt @@ -15,6 +15,8 @@ import android.net.NetworkCapabilities.TRANSPORT_ETHERNET import android.net.NetworkCapabilities.TRANSPORT_WIFI import android.net.NetworkInfo import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.IConnectionStatusProvider import io.sentry.ILogger @@ -38,6 +40,7 @@ import org.mockito.MockedStatic import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq @@ -274,6 +277,7 @@ class AndroidConnectionStatusProviderTest { contextMock, logger, buildInfo, + null, mock(), ) ) @@ -841,4 +845,20 @@ class AndroidConnectionStatusProviderTest { // Verify no additional unregister calls verifyNoInteractions(connectivityManager) } + + @Test + fun `registerNetworkCallback with a custom handlers calls connectivityManager with it`() { + val customHandler = object : Handler(Looper.getMainLooper()) {} + whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) + AndroidConnectionStatusProvider.registerNetworkCallback( + contextMock, + logger, + buildInfo, + customHandler, + mock(), + ) + + verify(connectivityManager) + .registerDefaultNetworkCallback(any(), argThat { this == customHandler }) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt index a6611e17e9a..c3993b94efd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt @@ -14,14 +14,13 @@ class CpuInfoUtilsTest { private lateinit var cpuDirs: File private lateinit var ciu: CpuInfoUtils - private fun populateCpuFiles(values: List) = - values.mapIndexed { i, v -> - val cpuMaxFreqFile = - File(cpuDirs, "cpu$i${File.separator}${CpuInfoUtils.CPUINFO_MAX_FREQ_PATH}") - cpuMaxFreqFile.parentFile?.mkdirs() - cpuMaxFreqFile.writeText(v) - cpuMaxFreqFile - } + private fun populateCpuFiles(values: List) = values.mapIndexed { i, v -> + val cpuMaxFreqFile = + File(cpuDirs, "cpu$i${File.separator}${CpuInfoUtils.CPUINFO_MAX_FREQ_PATH}") + cpuMaxFreqFile.parentFile?.mkdirs() + cpuMaxFreqFile.writeText(v) + cpuMaxFreqFile + } @BeforeTest fun `set up`() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt index 008a036cbfc..44d6d9fd03a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt @@ -128,6 +128,37 @@ class FirstDrawDoneListenerTest { assertTrue(fixture.onDrawListeners.isEmpty()) } + @Test + fun `OnGlobalLayoutListener is removed after cleanup`() { + val view = fixture.getSut() + + // Initialize mOnGlobalLayoutListeners via a dummy add/remove + val dummyGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {} + view.viewTreeObserver.addOnGlobalLayoutListener(dummyGlobalListener) + view.viewTreeObserver.removeOnGlobalLayoutListener(dummyGlobalListener) + + // CopyOnWriteArray wraps an internal ArrayList called mData + val copyOnWriteArray: Any = view.viewTreeObserver.getProperty("mOnGlobalLayoutListeners") + val mDataField = copyOnWriteArray.javaClass.getDeclaredField("mData") + mDataField.isAccessible = true + + @Suppress("UNCHECKED_CAST") + fun globalLayoutListeners(): ArrayList<*> = mDataField.get(copyOnWriteArray) as ArrayList<*> + + assertTrue(globalLayoutListeners().isEmpty()) + + FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo) + + // onDraw registers a cleanup OnGlobalLayoutListener + view.viewTreeObserver.dispatchOnDraw() + assertFalse(globalLayoutListeners().isEmpty()) + + // onGlobalLayout fires the cleanup, which removes both the draw and layout listeners + view.viewTreeObserver.dispatchOnGlobalLayout() + assertTrue(globalLayoutListeners().isEmpty()) + assertTrue(fixture.onDrawListeners.isEmpty()) + } + @Test fun `registerForNextDraw calls the given callback on the main thread after onDraw`() { val view = fixture.getSut() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt index b3b018e87b2..334ce229066 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt @@ -19,7 +19,6 @@ import io.sentry.test.getCtor import io.sentry.test.getProperty import io.sentry.test.injectForField import java.lang.ref.WeakReference -import java.lang.reflect.Field import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test @@ -141,6 +140,16 @@ class SentryFrameMetricsCollectorTest { assertNotNull(id) } + @Test + fun `handler thread is started lazily on first startCollection`() { + val collector = fixture.getSut(context) + // not started during construction (would block the main thread on getLooper at SDK init) + assertNull(collector.getProperty("handler")) + + collector.startCollection(mock()) + assertNotNull(collector.getProperty("handler")) + } + @Test fun `collector calls addOnFrameMetricsAvailableListener when an activity starts`() { val collector = fixture.getSut(context) @@ -292,18 +301,41 @@ class SentryFrameMetricsCollectorTest { } @Test - fun `collector accesses choreographer instance on creation on main thread`() { + fun `collector accesses choreographer instance and field asynchronously on main thread`() { val collector = fixture.getSut(context) - val field: Field? = collector.getProperty("choreographerLastFrameTimeField") + + val field: Any? = collector.getProperty("choreographerLastFrameTimeField") var choreographer: Choreographer? = collector.getProperty("choreographer") - // Choreographer instance is accessed on main thread, but the field accessor happens in whatever - // thread created the collector - assertNotNull(field) + assertNull(choreographer) + assertNull(field) + // Execute all posted tasks Shadows.shadowOf(Looper.getMainLooper()).idle() choreographer = collector.getProperty("choreographer") assertNotNull(choreographer) + assertNotNull(collector.getProperty("choreographerLastFrameTimeField")) + } + + // Frame callbacks on API 26+ read their per-frame start timestamp directly from FrameMetrics, + // which can make the Choreographer fallback look like it should be specific to APIs < 26. + // But SpanFrameMetricsCollector separately calls getLastKnownFrameStartTimeNanos() on every + // API level for pending-frame interpolation, so API 26+ still needs the Choreographer + // fallback to be initialized. + @Test + fun `collector keeps choreographer fallback available on version O+`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + + Shadows.shadowOf(Looper.getMainLooper()).idle() + + val choreographer = collector.getProperty("choreographer") + assertNotNull(collector.getProperty("choreographerLastFrameTimeField")) + + choreographer.injectForField("mLastFrameTimeNanos", 100) + + assertEquals(100, collector.getLastKnownFrameStartTimeNanos()) } @Test @@ -577,6 +609,146 @@ class SentryFrameMetricsCollectorTest { assertEquals(0, collector.getProperty>("trackedWindows").size) } + @Test + fun `getFramesDelay returns -1 when not available`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.M) } + val collector = fixture.getSut(context, buildInfo) + + val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(1)) + assertEquals(-1.0, result.delaySeconds) + assertEquals(0, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay returns -1 for invalid time range`() { + val collector = fixture.getSut(context) + + val result = collector.getFramesDelay(2000, 1000) + assertEquals(-1.0, result.delaySeconds) + assertEquals(0, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay returns zero delay when no slow frames recorded`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + Shadows.shadowOf(Looper.getMainLooper()).idle() + val listener = + collector.getProperty("frameMetricsAvailableListener") + + collector.startCollection(mock()) + + // emit a fast frame (21ns cpu time — well under 16ms budget) + listener.onFrameMetricsAvailable(createMockWindow(), createMockFrameMetrics(), 0) + + val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(1)) + assertEquals(0.0, result.delaySeconds) + assertEquals(0, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay calculates delay from slow frames`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + val listener = + collector.getProperty("frameMetricsAvailableListener") + + collector.startCollection(mock()) + + // emit a slow frame (~100ms extra = ~116ms total, well over 16ms budget) + listener.onFrameMetricsAvailable( + createMockWindow(), + createMockFrameMetrics( + extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100), + intendedVsyncTimestampNanos = TimeUnit.SECONDS.toNanos(1), + ), + 0, + ) + + // emit a frozen frame (~1000ms extra = ~1016ms total, well over 700ms) + listener.onFrameMetricsAvailable( + createMockWindow(), + createMockFrameMetrics( + extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(1000), + intendedVsyncTimestampNanos = TimeUnit.SECONDS.toNanos(2), + ), + 0, + ) + + val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(5)) + assertTrue(result.delaySeconds > 0) + assertEquals(2, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay handles partial frame overlap`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + val listener = + collector.getProperty("frameMetricsAvailableListener") + + collector.startCollection(mock()) + + // emit a frozen frame (~1s) + listener.onFrameMetricsAvailable( + createMockWindow(), + createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.SECONDS.toNanos(1)), + 0, + ) + + // The frame's delay interval is roughly [~16ms, ~1000ms]. + // Query from 500ms so the range clips the delay interval in half. + val queryStart = TimeUnit.MILLISECONDS.toNanos(500) + val queryEnd = TimeUnit.SECONDS.toNanos(5) + + val fullResult = collector.getFramesDelay(0, queryEnd) + val partialResult = collector.getFramesDelay(queryStart, queryEnd) + + // partial overlap should yield less delay than the full range + assertTrue(partialResult.delaySeconds > 0) + assertTrue(partialResult.delaySeconds < fullResult.delaySeconds) + assertEquals(1, partialResult.framesContributingToDelayCount) + } + + @Test + fun `old frames are automatically pruned`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + Shadows.shadowOf(Looper.getMainLooper()).idle() + val listener = + collector.getProperty("frameMetricsAvailableListener") + + collector.startCollection(mock()) + + val t0 = TimeUnit.MINUTES.toNanos(10) // start at a realistic base time + + // emit a slow frame at t0 + val frameMetrics1 = + createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100)) + whenever(frameMetrics1.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)).thenReturn(t0) + listener.onFrameMetricsAvailable(createMockWindow(), frameMetrics1, 0) + + // verify frame exists + val resultBefore = collector.getFramesDelay(t0, t0 + TimeUnit.SECONDS.toNanos(1)) + assertEquals(1, resultBefore.framesContributingToDelayCount) + + // emit another slow frame >5 minutes later to trigger auto-pruning + val t1 = t0 + TimeUnit.MINUTES.toNanos(6) + val frameMetrics2 = + createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100)) + whenever(frameMetrics2.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)).thenReturn(t1) + listener.onFrameMetricsAvailable(createMockWindow(), frameMetrics2, 0) + + // the first frame should have been pruned (>5min old) + val resultAfter = collector.getFramesDelay(t0, t0 + TimeUnit.SECONDS.toNanos(1)) + assertEquals(0, resultAfter.framesContributingToDelayCount) + } + private fun createMockWindow(refreshRate: Float = 60F): Window { val mockWindow = mock() val mockDisplay = mock() @@ -600,6 +772,7 @@ class SentryFrameMetricsCollectorTest { syncNanos: Long = 6, extraCpuDurationNanos: Long = 0, totalDurationNanos: Long = 60, + intendedVsyncTimestampNanos: Long = 50, ): FrameMetrics { val frameMetrics = mock() whenever(frameMetrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)) @@ -612,7 +785,8 @@ class SentryFrameMetricsCollectorTest { whenever(frameMetrics.getMetric(FrameMetrics.DRAW_DURATION)).thenReturn(drawNanos) whenever(frameMetrics.getMetric(FrameMetrics.SYNC_DURATION)).thenReturn(syncNanos) whenever(frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION)).thenReturn(totalDurationNanos) - whenever(frameMetrics.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)).thenReturn(50) + whenever(frameMetrics.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)) + .thenReturn(intendedVsyncTimestampNanos) return frameMetrics } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt index 710fc835acd..ef048978795 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt @@ -12,7 +12,6 @@ import io.sentry.SpanDataConvention import io.sentry.SpanOptions import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext -import java.util.Date import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test @@ -31,8 +30,8 @@ class ActivityLifecycleSpanHelperTest { val appStartSpan: ISpan val scopes = mock() val options = SentryOptions() - val date = SentryNanotimeDate(Date(1), 1000000) - val endDate = SentryNanotimeDate(Date(3), 3000000) + val date = SentryNanotimeDate(1, 1000000) + val endDate = SentryNanotimeDate(3, 3000000) init { whenever(scopes.options).thenReturn(options) @@ -59,7 +58,7 @@ class ActivityLifecycleSpanHelperTest { @Test fun `createAndStopOnCreateSpan creates and finishes onCreate span`() { val helper = fixture.getSut() - val date = SentryNanotimeDate(Date(1), 1) + val date = SentryNanotimeDate(1, 1) helper.setOnCreateStartTimestamp(date) helper.createAndStopOnCreateSpan(fixture.appStartSpan) @@ -99,7 +98,7 @@ class ActivityLifecycleSpanHelperTest { @Test fun `createAndStopOnStartSpan creates and finishes onStart span`() { val helper = fixture.getSut() - val date = SentryNanotimeDate(Date(1), 1) + val date = SentryNanotimeDate(1, 1) helper.setOnStartStartTimestamp(date) helper.createAndStopOnStartSpan(fixture.appStartSpan) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index 24159cab5cb..6a32e7d453b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -5,18 +5,23 @@ import android.app.Application import android.content.ContentProvider import android.os.Build import android.os.Bundle +import android.os.Handler import android.os.Looper import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.DateUtils import io.sentry.IContinuousProfiler +import io.sentry.ITransaction import io.sentry.ITransactionProfiler import io.sentry.SentryNanotimeDate +import io.sentry.android.core.AppStartExtension +import io.sentry.android.core.ContextUtils import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess -import java.util.Date +import io.sentry.protocol.SentryId import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -26,6 +31,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.Before import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -43,6 +49,7 @@ class AppStartMetricsTest { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -64,6 +71,7 @@ class AppStartMetricsTest { metrics.appStartProfiler = mock() metrics.appStartContinuousProfiler = mock() metrics.appStartSamplingDecision = mock() + metrics.setAppStartTraceId(SentryId()) metrics.clear() @@ -77,6 +85,7 @@ class AppStartMetricsTest { assertNull(metrics.appStartProfiler) assertNull(metrics.appStartContinuousProfiler) assertNull(metrics.appStartSamplingDecision) + assertNull(metrics.getAppStartTraceId()) } @Test @@ -137,7 +146,7 @@ class AppStartMetricsTest { appStartTimeSpan.start() assertTrue(appStartTimeSpan.hasStarted()) AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() val options = SentryAndroidOptions().apply { isEnablePerformanceV2 = false } @@ -164,12 +173,12 @@ class AppStartMetricsTest { } // when the looper runs - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() - // but no activity creation happened + // but a headless start happened // then the app wasn't launched in foreground and nothing should be sent assertFalse(metrics.isAppLaunchedInForeground) - assertFalse(metrics.shouldSendStartMeasurements()) + assertFalse(metrics.shouldSendStartMeasurements(false)) val now = TimeUnit.MINUTES.toMillis(2) + 1234567 SystemClock.setCurrentTimeMillis(now) @@ -179,7 +188,7 @@ class AppStartMetricsTest { // then it should restart the timespan assertTrue(metrics.isAppLaunchedInForeground) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) assertTrue(metrics.appStartTimeSpan.hasStarted()) assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) @@ -193,8 +202,8 @@ class AppStartMetricsTest { metrics.sdkInitTimeSpan.start() metrics.registerLifecycleCallbacks(mock()) - // when the handler callback is executed and no activity was launched - Shadows.shadowOf(Looper.getMainLooper()).idle() + // when the handler callback is executed and the start is headless + waitForMainLooperIdle() // isAppLaunchedInForeground should be false assertFalse(metrics.isAppLaunchedInForeground) @@ -207,6 +216,177 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } + @Test + fun `headless app start defaults UNKNOWN appStartType to COLD`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `headless app start does not overwrite existing appStartType`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartMetrics.AppStartType.WARM + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `headless app start fires HeadlessAppStartListener`() = + withProcessImportance(false) { + val listenerCalls = AtomicInteger() + + AppStartMetrics.getInstance().setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `foreground process does not fire HeadlessAppStartListener`() { + // Deferred/late SDK init inside an already-running Activity: we missed onActivityCreated, but + // the process is foreground (Robolectric default importance), so this is a real launch, not a + // headless start. The listener must not fire and the headless reclassification must not run. + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(0, listenerCalls.get()) + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + val activity = mock() + whenever(activity.isChangingConfigurations).thenReturn(false) + metrics.onActivityCreated(activity, null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + + metrics.onActivityDestroyed(activity) + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `activity start prevents HeadlessAppStartListener`() { + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.onActivityCreated(mock(), null) + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(0, listenerCalls.get()) + } + + @Test + fun `resolveHeadlessAppStartEndTime uses applicationOnCreate stop when Gradle plugin instrumented`() = + withProcessImportance(false) { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener {} + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime falls back to CLASS_LOADED_UPTIME_MS when no plugin and no ApplicationStartInfo`() = + withProcessImportance(false) { + val metrics = AppStartMetrics.getInstance() + metrics.setClassLoadedUptimeMs(200) + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener {} + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime does not overwrite stopped appStartTimeSpan`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.apply { + setStartedAt(100) + setStoppedAt(150) + } + metrics.setHeadlessAppStartListener {} + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(50, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `headless app start without listener does not stop sdkInitTimeSpan`() { + val metrics = AppStartMetrics.getInstance() + metrics.sdkInitTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertTrue(metrics.sdkInitTimeSpan.hasNotStopped()) + } + + @Test + fun `getAppStartTimeSpanForHeadless falls back to sdkInitTimeSpan when appStartSpan has not stopped`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.sdkInitTimeSpan.apply { + setStartedAt(120) + setStoppedAt(180) + } + + assertSame(metrics.sdkInitTimeSpan, metrics.getAppStartTimeSpanForHeadless()) + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + Shadows.shadowOf(Looper.getMainLooper()).idle() + } + + /** + * Mocks the process importance to simulate a user initiated start (e.g. launcher) or a real + * headless start (broadcast/service), i.e. a non-foreground-importance process. + * + * The Robolectric default importance in this test class is IMPORTANCE_FOREGROUND, so any headless + * scenarios must opt into a background importance explicitly. + */ + private fun withProcessImportance(isForeground: Boolean, block: () -> T): T = + mockStatic(ContextUtils::class.java).use { contextUtils -> + contextUtils + .`when` { ContextUtils.isForegroundImportance() } + .thenReturn(isForeground) + block() + } + @Test fun `if app start span is at most 1 minute, appStartTimeSpanWithFallback returns the app start span`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan @@ -231,7 +411,7 @@ class AppStartMetricsTest { appStartTimeSpan.setStartedAt(1) assertTrue(appStartTimeSpan.hasStarted()) // Job on main thread checks if activity was launched - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(SentryAndroidOptions()) @@ -246,7 +426,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() verify(profiler).close() } @@ -259,7 +439,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() verify(profiler).close(eq(true)) } @@ -273,7 +453,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() verify(profiler, never()).close() } @@ -287,7 +467,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() verify(profiler, never()).close(any()) } @@ -325,13 +505,13 @@ class AppStartMetricsTest { } @Test - fun `registerApplicationForegroundCheck set foreground state to false if no activity is running`() { + fun `registerApplicationForegroundCheck set foreground state to false for headless start`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) - // Main thread performs the check and sets the flag to false if no activity was created - Shadows.shadowOf(Looper.getMainLooper()).idle() + // Main thread performs the check and sets the flag to false if the start is headless + waitForMainLooperIdle() assertFalse(AppStartMetrics.getInstance().isAppLaunchedInForeground) } @@ -344,7 +524,7 @@ class AppStartMetricsTest { // An activity was created AppStartMetrics.getInstance().onActivityCreated(mock(), null) // Main thread performs the check and keeps the flag to true - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) } @@ -363,11 +543,11 @@ class AppStartMetricsTest { val appStartMetrics = AppStartMetrics.getInstance() appStartMetrics.addActivityLifecycleTimeSpans(mock()) appStartMetrics.contentProviderOnCreateTimeSpans.add(mock()) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) appStartMetrics.onAppStartSpansSent() assertTrue(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) assertTrue(appStartMetrics.contentProviderOnCreateTimeSpans.isEmpty()) - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) } @Test @@ -381,18 +561,18 @@ class AppStartMetricsTest { // then the app start type should be cold and measurements should be sent assertEquals(AppStartMetrics.AppStartType.COLD, appStartMetrics.appStartType) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) // when the activity gets destroyed appStartMetrics.onAppStartSpansSent() - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) appStartMetrics.onActivityDestroyed(activity0) // then it should reset sending the measurements for the next warm activity appStartMetrics.onActivityCreated(mock(), mock()) assertEquals(AppStartMetrics.AppStartType.WARM, appStartMetrics.appStartType) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) } @Test @@ -434,6 +614,7 @@ class AppStartMetricsTest { val metrics = AppStartMetrics.getInstance() assertEquals(AppStartMetrics.AppStartType.UNKNOWN, AppStartMetrics.getInstance().appStartType) val app = mock() + metrics.appStartTimeSpan.start() // Need to start the span for timeout check to work metrics.registerLifecycleCallbacks(app) // when an activity is created later with a null bundle @@ -466,7 +647,7 @@ class AppStartMetricsTest { @Test fun `createProcessInitSpan creates a span`() { val appStartMetrics = AppStartMetrics.getInstance() - val startDate = SentryNanotimeDate(Date(1), 1000000) + val startDate = SentryNanotimeDate(1, 1000000) appStartMetrics.classLoadedUptimeMs = 10 val startMillis = DateUtils.nanosToMillis(startDate.nanoTimestamp().toDouble()).toLong() appStartMetrics.appStartTimeSpan.setStartedAt(1) @@ -537,4 +718,432 @@ class AppStartMetricsTest { assertEquals(secondActivity, CurrentActivityHolder.getInstance().activity) } + + @Test + fun `firstIdle is properly cleared`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertTrue(metrics.firstIdle > 0) + + metrics.clear() + + assertEquals(-1, metrics.firstIdle) + } + + @Test + fun `firstIdle is set when registerLifecycleCallbacks is called`() { + SystemClock.setCurrentTimeMillis(90) + + val metrics = AppStartMetrics.getInstance() + val beforeRegister = SystemClock.uptimeMillis() + + SystemClock.setCurrentTimeMillis(100) + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + SystemClock.setCurrentTimeMillis(110) + val afterIdle = SystemClock.uptimeMillis() + + assertTrue(metrics.firstIdle >= beforeRegister) + assertTrue(metrics.firstIdle <= afterIdle) + } + + @Test + fun `Sets app launch type to WARM when activity created after firstIdle`() { + val metrics = AppStartMetrics.getInstance() + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `Sets app launch type to COLD when activity created before firstIdle executes`() { + val metrics = AppStartMetrics.getInstance() + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + + metrics.registerLifecycleCallbacks(mock()) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + } + + @Test + fun `savedInstanceState check takes precedence over firstIdle timing`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), mock()) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `timeout check takes precedence over firstIdle timing`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + val futureTime = SystemClock.uptimeMillis() + TimeUnit.MINUTES.toMillis(2) + SystemClock.setCurrentTimeMillis(futureTime) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertTrue(metrics.appStartTimeSpan.hasStarted()) + assertEquals(futureTime, metrics.appStartTimeSpan.startUptimeMs) + } + + @Test + fun `firstIdle timing does not affect subsequent activity creations`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), null) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + + metrics.onActivityCreated(mock(), mock()) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `COLD start when activity created at same uptime as firstIdle with null savedInstanceState`() { + val metrics = AppStartMetrics.getInstance() + + // Manually set firstIdle to a known value + val testTime = SystemClock.uptimeMillis() + metrics.firstIdle = testTime + + // Set current time to exactly match firstIdle time + SystemClock.setCurrentTimeMillis(testTime) + metrics.onActivityCreated(mock(), null) + + // When nowUptimeMs <= firstIdle, should be COLD + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + } + + @Test + fun `WARM start when activity created 1ms after firstIdle with null savedInstanceState`() { + val metrics = AppStartMetrics.getInstance() + + val beforeRegister = SystemClock.uptimeMillis() + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + // Activity created just 1ms after firstIdle executed + SystemClock.setCurrentTimeMillis(beforeRegister + 1) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `COLD start when activity created before firstIdle runs despite later wall time`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + // Don't let the looper idle yet - simulates activity created before firstIdle executes + + // Even if we advance wall time significantly + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 1000) + metrics.onActivityCreated(mock(), null) + + // Should still be COLD because firstIdle hasn't executed yet + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + // Now let firstIdle execute + waitForMainLooperIdle() + + // Should remain COLD (not change to WARM) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + } + + @Test + fun `WARM start takes precedence when both savedInstanceState and firstIdle indicate WARM`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + // Both conditions indicate warm: savedInstanceState != null AND after firstIdle + metrics.onActivityCreated(mock(), mock()) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `WARM start when savedInstanceState is non-null even if created before firstIdle`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + // Don't idle - activity created before firstIdle + + // savedInstanceState check takes precedence + metrics.onActivityCreated(mock(), mock()) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `firstIdle is -1 initially and after clear`() { + val metrics = AppStartMetrics.getInstance() + + // Should be -1 initially (already tested in existing test, but good to verify) + metrics.clear() + val initialValue = metrics.firstIdle + assertEquals(-1, initialValue) + + // Register and let it set + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + val afterRegister = metrics.firstIdle + assertTrue(afterRegister > 0) + + // Clear should reset it + metrics.clear() + val afterClear = metrics.firstIdle + assertEquals(-1, afterClear) + } + + @Test + fun `COLD start when firstIdle is still -1 and no savedInstanceState`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + // Don't idle - firstIdle will still be -1 + + // Verify firstIdle hasn't executed yet + assertEquals(-1, metrics.firstIdle) + + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + } + + @Test + fun `App start type priority order is timeout, savedInstanceState, then firstIdle timing`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + // Test timeout takes precedence over everything + val futureTime = SystemClock.uptimeMillis() + TimeUnit.MINUTES.toMillis(2) + SystemClock.setCurrentTimeMillis(futureTime) + metrics.onActivityCreated(mock(), null) // null savedInstanceState + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `Multiple consecutive warm starts are correctly detected`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + + // First activity - cold start (before firstIdle) + val firstActivity = mock() + whenever(firstActivity.isChangingConfigurations).thenReturn(false) + metrics.onActivityCreated(firstActivity, null) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertTrue(metrics.shouldSendStartMeasurements(false)) + metrics.onAppStartSpansSent() + waitForMainLooperIdle() + + // Simulate app going to background (destroy first activity) + metrics.onActivityDestroyed(firstActivity) + + // Second activity - should be warm (process still alive, new activity launch) + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + val secondActivity = mock() + metrics.onActivityCreated(secondActivity, null) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertTrue(metrics.isAppLaunchedInForeground) + assertTrue(metrics.shouldSendStartMeasurements(false)) + metrics.onAppStartSpansSent() + + // Third activity - should still be warm + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), null) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertTrue(metrics.isAppLaunchedInForeground) + assertFalse(metrics.shouldSendStartMeasurements(false)) + } + + @Test + fun `WARM start when user returns from background with null savedInstanceState`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + + // Initial cold start + val mainActivity = mock() + whenever(mainActivity.isChangingConfigurations).thenReturn(false) + metrics.onActivityCreated(mainActivity, null) // savedInstanceState = null + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + waitForMainLooperIdle() + + // User presses home, activity destroyed (not configuration change) + metrics.onActivityDestroyed(mainActivity) + + // User returns to app - MainActivity recreated with NULL savedInstanceState + // (Android doesn't save state when user navigates away normally) + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 500) + metrics.onActivityCreated(mock(), null) // savedInstanceState = null! + + // Should be WARM because process was alive and firstIdle timing detects it + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `WARM start when launching different activity in same process with null savedInstanceState`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + + // Cold start with MainActivity + val mainActivity = mock() + metrics.onActivityCreated(mainActivity, null) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + waitForMainLooperIdle() + + metrics.onActivityDestroyed(mainActivity) + + // Later, user navigates to another activity + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 200) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `canExtendAppStart is true on a fresh foreground start`() { + assertTrue(AppStartMetrics.getInstance().canExtendAppStart()) + } + + @Test + fun `canExtendAppStart is true for a headless (non-foreground) start`() { + val metrics = AppStartMetrics.getInstance() + metrics.isAppLaunchedInForeground = false + assertTrue(metrics.canExtendAppStart()) + } + + @Test + fun `canExtendAppStart is false once an activity was created`() { + val metrics = AppStartMetrics.getInstance() + metrics.onActivityCreated(mock(), null) + assertFalse(metrics.canExtendAppStart()) + } + + @Test + fun `canExtendAppStart is false once the first frame was drawn`() { + val metrics = AppStartMetrics.getInstance() + metrics.onFirstFrameDrawn() + assertFalse(metrics.canExtendAppStart()) + } + + @Test + fun `canExtendAppStart is false once start measurements were sent`() { + val metrics = AppStartMetrics.getInstance() + metrics.onAppStartSpansSent() + assertFalse(metrics.canExtendAppStart()) + } + + /** Drives the singleton's eager extension into the active state via the listener path. */ + private fun activateExtension(metrics: AppStartMetrics) { + metrics.appStartExtension.setExtendAppStartListener { + AppStartExtension.ExtendedAppStart(mock(), mock()) + } + metrics.appStartExtension.extendAppStart() + assertTrue(metrics.appStartExtension.isActive) + } + + @Test + fun `clear resets the extension state`() { + val metrics = AppStartMetrics.getInstance() + activateExtension(metrics) + metrics.clear() + assertFalse(metrics.appStartExtension.isActive) + metrics.appStartExtension.setExtendAppStartListener(null) + } + + @Test + fun `onAppStartSpansSent resets the extension state`() { + val metrics = AppStartMetrics.getInstance() + activateExtension(metrics) + metrics.onAppStartSpansSent() + assertFalse(metrics.appStartExtension.isActive) + metrics.appStartExtension.setExtendAppStartListener(null) + } + + @Test + fun `late first activity does not reset the app start while the extension is active`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartMetrics.AppStartType.COLD + metrics.appStartTimeSpan.setStartedAt(1) + activateExtension(metrics) + + SystemClock.setCurrentTimeMillis(TimeUnit.MINUTES.toMillis(2)) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertEquals(1, metrics.appStartTimeSpan.startUptimeMs) + metrics.appStartExtension.setExtendAppStartListener(null) + } + + @Test + fun `late first activity resets the app start once the extension has finished`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartMetrics.AppStartType.COLD + metrics.appStartTimeSpan.setStartedAt(1) + val transaction = mock() + whenever(transaction.isFinished).thenReturn(true) + metrics.appStartExtension.setExtendAppStartListener { + AppStartExtension.ExtendedAppStart(transaction, mock()) + } + metrics.appStartExtension.extendAppStart() + assertFalse(metrics.appStartExtension.isActive) + + val now = TimeUnit.MINUTES.toMillis(2) + SystemClock.setCurrentTimeMillis(now) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) + metrics.appStartExtension.setExtendAppStartListener(null) + } + + @Test + fun `broadcast starts are not considered a foreground start`() = + withProcessImportance(false) { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `typical app starts are considered a foreground start`() = + withProcessImportance(true) { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + assertTrue(metrics.isAppLaunchedInForeground) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt new file mode 100644 index 00000000000..a7eace97371 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -0,0 +1,382 @@ +package io.sentry.android.core.performance + +import android.app.Activity +import android.app.ActivityManager.RunningAppProcessInfo +import android.app.Application +import android.app.ApplicationStartInfo +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.SentryShadowActivityManager +import io.sentry.android.core.SentryShadowProcess +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.Before +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM], + shadows = [SentryShadowProcess::class, SentryShadowActivityManager::class], +) +class AppStartMetricsTestApi35 { + @Before + fun setup() { + AppStartMetrics.getInstance().clear() + SentryShadowProcess.setStartUptimeMillis(42) + SentryShadowProcess.setStartElapsedRealtime(42) + SentryShadowActivityManager.reset() + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) + AppStartMetrics.getInstance().isAppLaunchedInForeground = true + } + + @Test + fun `detects cold start using ApplicationStartInfo on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + + val app = ApplicationProvider.getApplicationContext() + AppStartMetrics.getInstance().registerLifecycleCallbacks(app) + + assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `known ApplicationStartInfo type without listener does not schedule headless check`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertEquals(-1, metrics.firstIdle) + } + + @Test + fun `detects warm start using ApplicationStartInfo on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_WARM) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + + val app = ApplicationProvider.getApplicationContext() + AppStartMetrics.getInstance().registerLifecycleCallbacks(app) + + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `does not set app start type when ApplicationStartInfo list is invalid`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState) + .thenReturn(ApplicationStartInfo.STARTUP_STATE_FIRST_FRAME_DRAWN) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_WARM) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + } + + @Test + fun `does not set app start type when ApplicationStartInfo list is empty`() { + SentryShadowActivityManager.setHistoricalProcessStartReasons(emptyList()) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + } + + @Test + fun `headless app start keeps COLD appStartType from ApplicationStartInfo`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `known ApplicationStartInfo type with listener handles headless app start`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_WARM) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setClassLoadedUptimeMs(200) + metrics.setHeadlessAppStartListener {} + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime uses ApplicationStartInfo onCreate uptime timestamp`() { + val appStartUptimeMs = 100L + // START_TIMESTAMP_APPLICATION_ONCREATE is captured with SystemClock.uptimeNanos() (the same + // base as TimeSpan) right before Application.onCreate is invoked, so it is used directly as + // an uptime value marking the onCreate start, without any clock re-anchoring. + val onCreateStartUptimeMs = 350L + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps) + .thenReturn( + mapOf( + ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE to + TimeUnit.MILLISECONDS.toNanos(onCreateStartUptimeMs) + ) + ) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(appStartUptimeMs) + metrics.setHeadlessAppStartListener {} + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(250, metrics.appStartTimeSpan.durationMs) + assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) + } + + @Test + fun `listener fires when set after registerLifecycleCallbacks resolves type on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + // Listener set AFTER registerLifecycleCallbacks — mirrors production ordering + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `getAppStartReason maps ApplicationStartInfo reason to string on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_BROADCAST) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertEquals("broadcast", metrics.appStartReason) + } + + @Test + fun `getAppStartReason returns null when no ApplicationStartInfo is available`() { + SentryShadowActivityManager.setHistoricalProcessStartReasons(emptyList()) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertNull(metrics.appStartReason) + } + + @Test + fun `getAppStartReason returns null for an unmapped reason`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(Int.MAX_VALUE) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertNull(metrics.appStartReason) + } + + @Test + fun `does not crash when getHistoricalProcessStartReasons throws RuntimeException`() { + SentryShadowActivityManager.setHistoricalProcessStartReasonsException( + RuntimeException("isolated process") + ) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + assertNull(metrics.appStartReason) + } + + @Test + fun `background start reason marks app as not launched in foreground`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_PUSH) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `all background start reasons mark app as not launched in foreground`() { + val backgroundReasons = + listOf( + ApplicationStartInfo.START_REASON_ALARM, + ApplicationStartInfo.START_REASON_BACKUP, + ApplicationStartInfo.START_REASON_BOOT_COMPLETE, + ApplicationStartInfo.START_REASON_BROADCAST, + ApplicationStartInfo.START_REASON_CONTENT_PROVIDER, + ApplicationStartInfo.START_REASON_JOB, + ApplicationStartInfo.START_REASON_PUSH, + ApplicationStartInfo.START_REASON_SERVICE, + ) + + val app = ApplicationProvider.getApplicationContext() + for (reason in backgroundReasons) { + AppStartMetrics.getInstance().clear() + SentryShadowActivityManager.reset() + + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(reason) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + + AppStartMetrics.getInstance().registerLifecycleCallbacks(app) + + assertFalse( + AppStartMetrics.getInstance().isAppLaunchedInForeground, + "reason $reason should not be launched in foreground", + ) + } + } + + @Test + fun `user-initiated start reason keeps app launched in foreground`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertTrue(metrics.isAppLaunchedInForeground) + } + + @Test + fun `unknown start reason falls back to foreground importance check`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_OTHER) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertTrue(metrics.isAppLaunchedInForeground) + } + + @Test + fun `background-spawned start is re-classified as warm on the first activity`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_PUSH) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + // App start span anchored at background process creation. + metrics.appStartTimeSpan.setStartedAt(42) + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + // User opens the app 20s later (under the 1-minute warm threshold). + val activityCreatedUptimeMs = 20_000L + SystemClock.setCurrentTimeMillis(activityCreatedUptimeMs) + metrics.onActivityCreated(mock(), null) + + // Re-classified as a warm start re-anchored at activity creation. + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertTrue(metrics.isAppLaunchedInForeground) + assertEquals(activityCreatedUptimeMs, metrics.appStartTimeSpan.startUptimeMs) + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + Shadows.shadowOf(Looper.getMainLooper()).idle() + } +} diff --git a/sentry-android-core/src/test/resources/Tongariro.jpg b/sentry-android-core/src/test/resources/Tongariro.jpg new file mode 100644 index 00000000000..96e2f074f0d Binary files /dev/null and b/sentry-android-core/src/test/resources/Tongariro.jpg differ diff --git a/sentry-android-core/src/test/resources/envelopes/attachment.txt b/sentry-android-core/src/test/resources/envelopes/attachment.txt new file mode 100644 index 00000000000..04a6e32325e --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/attachment.txt @@ -0,0 +1,3 @@ +{} +{"type":"attachment","length":61,"filename":"attachment.txt","content_type":"text/plain"} +some plain text attachment file which include two line breaks diff --git a/sentry-android-core/src/test/resources/envelopes/event-attachment.txt b/sentry-android-core/src/test/resources/envelopes/event-attachment.txt new file mode 100644 index 00000000000..4abe1bc18f1 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/event-attachment.txt @@ -0,0 +1,10 @@ +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +{"type":"event","length":107,"content_type":"application/json"} +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc", "sdk": {"name":"sentry-android","version":"2.0.0-SNAPSHOT"} +{"type":"attachment","length":61,"filename":"attachment.txt","content_type":"text/plain","attachment_type":"event.minidump"} +some plain text attachment file which include two line breaks +{"type":"attachment","length":29,"filename":"log.txt","content_type":"text/plain"} +attachment +with +line breaks + diff --git a/sentry-android-core/src/test/resources/envelopes/feedback.txt b/sentry-android-core/src/test/resources/envelopes/feedback.txt new file mode 100644 index 00000000000..21202669864 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/feedback.txt @@ -0,0 +1,3 @@ +{"event_id":"bdd63725a2b84c1eabd761106e17d390","sdk":{"name":"sentry.dart.flutter","version":"6.0.0-beta.3","packages":[{"name":"pub:sentry","version":"6.0.0-beta.3"},{"name":"pub:sentry_flutter","version":"6.0.0-beta.3"}],"integrations":["isolateErrorIntegration","runZonedGuardedIntegration","widgetsFlutterBindingIntegration","flutterErrorIntegration","widgetsBindingIntegration","nativeSdkIntegration","loadAndroidImageListIntegration","loadReleaseIntegration"]}} +{"content_type":"application/json","type":"user_report","length":103} +{"event_id":"bdd63725a2b84c1eabd761106e17d390","name":"jonas","email":"a@b.com","comments":"bad stuff"} diff --git a/sentry-android-core/src/test/resources/envelopes/java-event.txt b/sentry-android-core/src/test/resources/envelopes/java-event.txt new file mode 100644 index 00000000000..9d16e5bf4e0 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/java-event.txt @@ -0,0 +1,3 @@ +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +{"type":"event","length":121,"content_type":"application/json"} +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T10:30:00.000Z","platform":"java","level":"error"} diff --git a/sentry-android-core/src/test/resources/envelopes/java-then-native-large.txt b/sentry-android-core/src/test/resources/envelopes/java-then-native-large.txt new file mode 100644 index 00000000000..97a0329ca15 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/java-then-native-large.txt @@ -0,0 +1,5 @@ +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +{"type":"event","length":10136,"content_type":"application/json"} +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T10:30:00.000Z","platform":"java","level":"error","extra_data":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} +{"type":"event","length":122,"content_type":"application/json"} +{"event_id":"aac79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T10:31:00.000Z","platform":"native","level":"fatal"} diff --git a/sentry-android-core/src/test/resources/envelopes/native-event.txt b/sentry-android-core/src/test/resources/envelopes/native-event.txt new file mode 100644 index 00000000000..5809f424035 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/native-event.txt @@ -0,0 +1,3 @@ +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +{"type":"event","length":122,"content_type":"application/json"} +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T10:30:00.000Z","platform":"native","level":"fatal"} diff --git a/sentry-android-core/src/test/resources/envelopes/native-with-attachment.txt b/sentry-android-core/src/test/resources/envelopes/native-with-attachment.txt new file mode 100644 index 00000000000..f7ea1e0a705 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/native-with-attachment.txt @@ -0,0 +1,5 @@ +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +{"type":"attachment","length":20,"filename":"log.txt","content_type":"text/plain"} +some attachment data +{"type":"event","length":122,"content_type":"application/json"} +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T11:45:30.500Z","platform":"native","level":"fatal"} diff --git a/sentry-android-core/src/test/resources/envelopes/session-only.txt b/sentry-android-core/src/test/resources/envelopes/session-only.txt new file mode 100644 index 00000000000..2b616d77e23 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/session-only.txt @@ -0,0 +1,3 @@ +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +{"type":"session","length":85,"content_type":"application/json"} +{"sid":"12345678-1234-1234-1234-123456789012","status":"ok","timestamp":"2023-07-15T10:30:00.000Z"} diff --git a/sentry-android-core/src/test/resources/envelopes/session.txt b/sentry-android-core/src/test/resources/envelopes/session.txt new file mode 100644 index 00000000000..fe34ebf32e2 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/session.txt @@ -0,0 +1,3 @@ +{} +{"content_type":"application/json","type":"session","length":306} +{"sid":"c81d4e2e-bcf2-11e6-869b-7df92533d2db","did":"123","init":true,"started":"2020-02-07T14:16:00Z","status":"ok","seq":123456,"errors":2,"duration":6000.0,"timestamp":"2020-02-07T14:16:00Z","attrs":{"release":"io.sentry@1.0+123","environment":"debug","ip_address":"127.0.0.1","user_agent":"jamesBond"}} diff --git a/sentry-android-core/src/test/resources/envelopes/transaction.txt b/sentry-android-core/src/test/resources/envelopes/transaction.txt new file mode 100644 index 00000000000..a685facab65 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/transaction.txt @@ -0,0 +1,3 @@ +{"event_id":"3367f5196c494acaae85bbbd535379ac","trace":{"trace_id":"b156a475de54423d9c1571df97ec7eb6","public_key":"key"}} +{"type":"transaction","length":640,"content_type":"application/json"} +{"transaction":"a-transaction","type":"transaction","start_timestamp":"2020-10-23T10:24:01.791Z","timestamp":"2020-10-23T10:24:02.791Z","event_id":"3367f5196c494acaae85bbbd535379ac","contexts":{"trace":{"trace_id":"b156a475de54423d9c1571df97ec7eb6","span_id":"0a53026963414893","op":"http","status":"ok"},"custom":{"some-key":"some-value"}},"spans":[{"start_timestamp":"2021-03-05T08:51:12.838Z","timestamp":"2021-03-05T08:51:12.949Z","trace_id":"2b099185293344a5bfdd7ad89ebf9416","span_id":"5b95c29a5ded4281","parent_span_id":"a3b2d1d58b344b07","op":"PersonService.create","description":"desc","status":"aborted","tags":{"name":"value"}}]} diff --git a/sentry-android-core/src/test/resources/thread_dump_no_stacktrace.txt b/sentry-android-core/src/test/resources/thread_dump_no_stacktrace.txt new file mode 100644 index 00000000000..e0411327546 --- /dev/null +++ b/sentry-android-core/src/test/resources/thread_dump_no_stacktrace.txt @@ -0,0 +1,21 @@ + +----- pid 12345 at 2024-01-01 10:00:00.000000000+0000 ----- +Cmd line: io.sentry.samples.android +Build fingerprint: 'google/sdk_gphone64_arm64/emu64a:13/TE1A.220922.012/9302419:userdebug/dev-keys' +ABI: 'arm64' + +DALVIK THREADS (2): +"main" prio=5 tid=1 Runnable + | group="main" sCount=0 ucsCount=0 flags=0 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=12345 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=R schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity.onCreate(MainActivity.java:42) + +"Thread-2" prio=5 tid=2 Sleeping + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x136c0518 self=0xb400007cabc82ad0 + | sysTid=12346 nice=0 cgrp=top-app sched=0/0 handle=0x7ace0a9cb0 + | state=S schedstat=( 574039 4838087 11 ) utm=0 stm=0 core=1 HZ=100 + | stack=0x7acdfb2000-0x7acdfb4000 stackSize=991KB + | held mutexes= diff --git a/sentry-android-core/src/test/resources/thread_dump_process_name_main.txt b/sentry-android-core/src/test/resources/thread_dump_process_name_main.txt new file mode 100644 index 00000000000..80b864aae73 --- /dev/null +++ b/sentry-android-core/src/test/resources/thread_dump_process_name_main.txt @@ -0,0 +1,24 @@ + +----- pid 12345 at 2024-01-01 10:00:00.000000000+0000 ----- +Cmd line: io.sentry.samples.android +Build fingerprint: 'google/sdk_gphone64_arm64/emu64a:13/TE1A.220922.012/9302419:userdebug/dev-keys' +ABI: 'arm64' + +DALVIK THREADS (2): +"io.sentry.samples.android" prio=5 tid=1 Runnable + | group="main" sCount=0 ucsCount=0 flags=0 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=12345 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=R schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity.onCreate(MainActivity.java:42) + +"Thread-2" prio=5 tid=2 Sleeping + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x136c0518 self=0xb400007cabc82ad0 + | sysTid=12346 nice=0 cgrp=top-app sched=0/0 handle=0x7ace0a9cb0 + | state=S schedstat=( 574039 4838087 11 ) utm=0 stm=0 core=1 HZ=100 + | stack=0x7acdfb2000-0x7acdfb4000 stackSize=991KB + | held mutexes= + at java.lang.Thread.sleep(Native method) + at io.sentry.samples.android.BackgroundWorker.run(BackgroundWorker.java:20) + diff --git a/sentry-android-core/src/test/resources/tombstone.pb.gz b/sentry-android-core/src/test/resources/tombstone.pb.gz new file mode 100644 index 00000000000..29505138d46 Binary files /dev/null and b/sentry-android-core/src/test/resources/tombstone.pb.gz differ diff --git a/sentry-android-core/src/test/resources/tombstone_debug_meta.json.gz b/sentry-android-core/src/test/resources/tombstone_debug_meta.json.gz new file mode 100644 index 00000000000..7bb9bdbadb2 Binary files /dev/null and b/sentry-android-core/src/test/resources/tombstone_debug_meta.json.gz differ diff --git a/sentry-android-distribution/README.md b/sentry-android-distribution/README.md new file mode 100644 index 00000000000..ccfa938e88f --- /dev/null +++ b/sentry-android-distribution/README.md @@ -0,0 +1,3 @@ +# sentry-android-distribution + +This module contains the client library for the Sentry Android Build Distribution that checks for updates for your application automatically for internal testing purposes. diff --git a/sentry-android-distribution/api/sentry-android-distribution.api b/sentry-android-distribution/api/sentry-android-distribution.api index d9c7ced1cfe..b14aaec9eba 100644 --- a/sentry-android-distribution/api/sentry-android-distribution.api +++ b/sentry-android-distribution/api/sentry-android-distribution.api @@ -1,8 +1,9 @@ public final class io/sentry/android/distribution/DistributionIntegration : io/sentry/IDistributionApi, io/sentry/Integration { public fun (Landroid/content/Context;)V - public fun checkForUpdate (Lio/sentry/IDistributionApi$UpdateCallback;)V + public fun checkForUpdate ()Ljava/util/concurrent/Future; public fun checkForUpdateBlocking ()Lio/sentry/UpdateStatus; public fun downloadUpdate (Lio/sentry/UpdateInfo;)V + public fun isEnabled ()Z public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } diff --git a/sentry-android-distribution/build.gradle.kts b/sentry-android-distribution/build.gradle.kts index 2d23bf3ab74..c699c364c3b 100644 --- a/sentry-android-distribution/build.gradle.kts +++ b/sentry-android-distribution/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { @@ -12,6 +13,10 @@ android { defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } buildFeatures { buildConfig = false } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + testOptions { unitTests.apply { isReturnDefaultValues = true @@ -21,7 +26,7 @@ android { } kotlin { - jvmToolchain(17) + compilerOptions.jvmTarget = JVM_1_8 compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 explicitApi() } diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt index c4b2fcff8e3..ed2bbd18b1d 100644 --- a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt @@ -27,6 +27,7 @@ internal class DistributionHttpClient(private val options: SentryOptions) { val versionCode: Long, val versionName: String, val buildConfiguration: String, + val installGroupsOverride: List? = null, ) /** @@ -58,6 +59,9 @@ internal class DistributionHttpClient(private val options: SentryOptions) { append("&build_number=${URLEncoder.encode(params.versionCode.toString(), "UTF-8")}") append("&build_version=${URLEncoder.encode(params.versionName, "UTF-8")}") append("&build_configuration=${URLEncoder.encode(params.buildConfiguration, "UTF-8")}") + params.installGroupsOverride?.forEach { group -> + append("&install_groups=${URLEncoder.encode(group, "UTF-8")}") + } } val url = URL(urlString) diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt index b22154b87ed..4a53b557a03 100644 --- a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt @@ -14,6 +14,7 @@ import io.sentry.UpdateInfo import io.sentry.UpdateStatus import java.net.SocketTimeoutException import java.net.UnknownHostException +import java.util.concurrent.Future import org.jetbrains.annotations.ApiStatus /** @@ -84,14 +85,12 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut } /** - * Check for available updates asynchronously using a callback. + * Check for available updates asynchronously. * - * @param onResult Callback that will be called with the UpdateStatus result + * @return Future that will resolve to an UpdateStatus result */ - public override fun checkForUpdate(onResult: IDistributionApi.UpdateCallback) { - // TODO implement this in a async way - val result = checkForUpdateBlocking() - onResult.onResult(result) + public override fun checkForUpdate(): Future { + return sentryOptions.executorService.submit { checkForUpdateBlocking() } } /** @@ -112,6 +111,15 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut } } + /** + * Check if the distribution integration is enabled. + * + * @return true if the distribution integration is enabled + */ + public override fun isEnabled(): Boolean { + return true + } + private fun createUpdateCheckParams(): DistributionHttpClient.UpdateCheckParams { return try { val packageManager = context.packageManager @@ -142,6 +150,7 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut versionCode = versionCode, versionName = versionName, buildConfiguration = buildConfiguration, + installGroupsOverride = sentryOptions.distribution.installGroupsOverride, ) } catch (e: PackageManager.NameNotFoundException) { sentryOptions.logger.log(SentryLevel.ERROR, e, "Failed to get package info") diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt index e97e0a5ea4c..0734b80f485 100644 --- a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt @@ -57,6 +57,7 @@ internal class UpdateResponseParser(private val options: SentryOptions) { val downloadUrl = json.optString("download_url", "") val appName = json.optString("app_name", "") val createdDate = json.optString("created_date", "") + val installGroups = parseInstallGroups(json) // Validate required fields (optString returns "null" for null values) val missingFields = mutableListOf() @@ -77,6 +78,26 @@ internal class UpdateResponseParser(private val options: SentryOptions) { ) } - return UpdateInfo(id, buildVersion, buildNumber, downloadUrl, appName, createdDate) + return UpdateInfo( + id, + buildVersion, + buildNumber, + downloadUrl, + appName, + createdDate, + installGroups, + ) + } + + private fun parseInstallGroups(json: JSONObject): List? { + val installGroupsArray = json.optJSONArray("install_groups") ?: return null + val installGroups = mutableListOf() + for (i in 0 until installGroupsArray.length()) { + val group = installGroupsArray.optString(i) + if (group.isNotEmpty() && group != "null") { + installGroups.add(group) + } + } + return if (installGroups.isEmpty()) null else installGroups } } diff --git a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt index 473339d3b68..f1817a39f34 100644 --- a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt +++ b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt @@ -1,5 +1,6 @@ package io.sentry.android.distribution +import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.SentryOptions import io.sentry.UpdateStatus import org.junit.Assert.assertEquals @@ -7,9 +8,10 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config -@RunWith(RobolectricTestRunner::class) +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) class UpdateResponseParserTest { private lateinit var options: SentryOptions @@ -32,11 +34,12 @@ class UpdateResponseParserTest { "build_number": 42, "download_url": "https://example.com/download", "app_name": "Test App", - "created_date": "2023-10-01T00:00:00Z" + "created_date": "2023-10-01T00:00:00Z", + "install_groups": ["beta", "internal"] }, "current": null } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -49,6 +52,7 @@ class UpdateResponseParserTest { assertEquals("https://example.com/download", updateInfo.downloadUrl) assertEquals("Test App", updateInfo.appName) assertEquals("2023-10-01T00:00:00Z", updateInfo.createdDate) + assertEquals(listOf("beta", "internal"), updateInfo.installGroups) } @Test @@ -66,7 +70,7 @@ class UpdateResponseParserTest { "created_date": "2023-09-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -88,7 +92,7 @@ class UpdateResponseParserTest { "created_date": "2023-09-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -144,7 +148,7 @@ class UpdateResponseParserTest { "build_version": "2.0.0" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -171,7 +175,7 @@ class UpdateResponseParserTest { "created_date": "" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -212,7 +216,7 @@ class UpdateResponseParserTest { "created_date": "2023-10-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -238,7 +242,7 @@ class UpdateResponseParserTest { "created_date": "2023-10-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -264,7 +268,7 @@ class UpdateResponseParserTest { "created_date": "2023-10-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -290,7 +294,7 @@ class UpdateResponseParserTest { "created_date": "2023-10-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -314,7 +318,7 @@ class UpdateResponseParserTest { "created_date": "2023-10-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -343,7 +347,7 @@ class UpdateResponseParserTest { "created_date": "2023-10-01T00:00:00Z" } } - """ + """ .trimIndent() val result = parser.parseResponse(200, responseBody) @@ -355,4 +359,103 @@ class UpdateResponseParserTest { error.message.contains("Missing required fields in API response: id"), ) } + + @Test + fun `parseResponse returns null installGroups when not present`() { + val responseBody = + """ + { + "update": { + "id": "update-123", + "build_version": "2.0.0", + "build_number": 42, + "download_url": "https://example.com/download", + "app_name": "Test App", + "created_date": "2023-10-01T00:00:00Z" + } + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return NewRelease", result is UpdateStatus.NewRelease) + val updateInfo = (result as UpdateStatus.NewRelease).info + assertEquals(null, updateInfo.installGroups) + } + + @Test + fun `parseResponse returns null installGroups when array is empty`() { + val responseBody = + """ + { + "update": { + "id": "update-123", + "build_version": "2.0.0", + "build_number": 42, + "download_url": "https://example.com/download", + "app_name": "Test App", + "created_date": "2023-10-01T00:00:00Z", + "install_groups": [] + } + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return NewRelease", result is UpdateStatus.NewRelease) + val updateInfo = (result as UpdateStatus.NewRelease).info + assertEquals(null, updateInfo.installGroups) + } + + @Test + fun `parseResponse returns null installGroups when array is null`() { + val responseBody = + """ + { + "update": { + "id": "update-123", + "build_version": "2.0.0", + "build_number": 42, + "download_url": "https://example.com/download", + "app_name": "Test App", + "created_date": "2023-10-01T00:00:00Z", + "install_groups": null + } + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return NewRelease", result is UpdateStatus.NewRelease) + val updateInfo = (result as UpdateStatus.NewRelease).info + assertEquals(null, updateInfo.installGroups) + } + + @Test + fun `parseResponse returns single installGroup`() { + val responseBody = + """ + { + "update": { + "id": "update-123", + "build_version": "2.0.0", + "build_number": 42, + "download_url": "https://example.com/download", + "app_name": "Test App", + "created_date": "2023-10-01T00:00:00Z", + "install_groups": ["beta-testers"] + } + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return NewRelease", result is UpdateStatus.NewRelease) + val updateInfo = (result as UpdateStatus.NewRelease).info + assertEquals(listOf("beta-testers"), updateInfo.installGroups) + } } diff --git a/sentry-android-fragment/build.gradle.kts b/sentry-android-fragment/build.gradle.kts index 7a4178b0652..3ef1c1934f8 100644 --- a/sentry-android-fragment/build.gradle.kts +++ b/sentry-android-fragment/build.gradle.kts @@ -1,10 +1,10 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } @@ -25,10 +25,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt index 230510fb4de..374713ba969 100644 --- a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt +++ b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt @@ -76,14 +76,7 @@ public class SentryFragmentLifecycleCallbacks( ) { addBreadcrumb(fragment, FragmentLifecycleState.CREATED) - // we only start the tracing for the fragment if the fragment has been added to its activity - // and not only to the backstack - if (fragment.isAdded) { - if (scopes.options.isEnableScreenTracking) { - scopes.configureScope { it.screen = getFragmentName(fragment) } - } - startTracing(fragment) - } + startTracing(fragment) } override fun onFragmentViewCreated( @@ -93,17 +86,30 @@ public class SentryFragmentLifecycleCallbacks( savedInstanceState: Bundle?, ) { addBreadcrumb(fragment, FragmentLifecycleState.VIEW_CREATED) + + // For detach/attach navigation (e.g. manual tab switching, ViewPager v1 with + // FragmentPagerAdapter, custom navigation frameworks), onFragmentCreated is never called for + // off-screen fragments that are re-attached. Starting here enables a narrower + // "view created -> resumed" span for those paths. startTracing is idempotent, so for the + // normal onFragmentCreated -> onFragmentViewCreated path this is a no-op. + startTracing(fragment) } override fun onFragmentStarted(fragmentManager: FragmentManager, fragment: Fragment) { addBreadcrumb(fragment, FragmentLifecycleState.STARTED) - // ViewPager2 locks background fragments to STARTED state + // ViewPager2 locks background fragments to STARTED state, so we stop here to avoid + // spans hanging for off-screen fragments that never reach RESUMED. stopTracing(fragment) } override fun onFragmentResumed(fragmentManager: FragmentManager, fragment: Fragment) { addBreadcrumb(fragment, FragmentLifecycleState.RESUMED) + + // For detach/attach navigation, onFragmentStarted may not fire before onFragmentResumed. + // If a span is still running here, stop it now. stopTracing is idempotent, so this is a + // no-op for the normal path where onFragmentStarted already stopped the span. + stopTracing(fragment) } override fun onFragmentPaused(fragmentManager: FragmentManager, fragment: Fragment) { @@ -116,6 +122,10 @@ public class SentryFragmentLifecycleCallbacks( override fun onFragmentViewDestroyed(fragmentManager: FragmentManager, fragment: Fragment) { addBreadcrumb(fragment, FragmentLifecycleState.VIEW_DESTROYED) + + // Failsafe: cancel any span that didn't finish via the normal started/resumed path + // (e.g. fragment view destroyed before reaching STARTED or RESUMED). + stopTracing(fragment) } override fun onFragmentDestroyed(fragmentManager: FragmentManager, fragment: Fragment) { @@ -153,6 +163,16 @@ public class SentryFragmentLifecycleCallbacks( fragmentsWithOngoingTransactions.containsKey(fragment) private fun startTracing(fragment: Fragment) { + if (!fragment.isAdded) { + return + } + + val fragmentName = getFragmentName(fragment) + + if (scopes.options.isEnableScreenTracking) { + scopes.configureScope { it.screen = fragmentName } + } + if (!isPerformanceEnabled || isRunningSpan(fragment)) { return } @@ -160,7 +180,6 @@ public class SentryFragmentLifecycleCallbacks( var transaction: ISpan? = null scopes.configureScope { transaction = it.transaction } - val fragmentName = getFragmentName(fragment) val span = transaction?.startChild(FRAGMENT_LOAD_OP, fragmentName) span?.let { diff --git a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt index 9446e1caef5..997c1206398 100644 --- a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt +++ b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt @@ -43,9 +43,15 @@ class SentryFragmentLifecycleCallbacksTest { enableAutoFragmentLifecycleTracing: Boolean = false, tracesSampleRate: Double? = 1.0, isAdded: Boolean = true, + enableScreenTracking: Boolean = false, ): SentryFragmentLifecycleCallbacks { whenever(scopes.options) - .thenReturn(SentryOptions().apply { setTracesSampleRate(tracesSampleRate) }) + .thenReturn( + SentryOptions().apply { + setTracesSampleRate(tracesSampleRate) + isEnableScreenTracking = enableScreenTracking + } + ) whenever(span.spanContext) .thenReturn(SpanContext(SentryId.EMPTY_ID, SpanId.EMPTY_ID, "op", null, null)) whenever(transaction.startChild(any(), any())).thenReturn(span) @@ -251,6 +257,115 @@ class SentryFragmentLifecycleCallbacksTest { verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) } + @Test + fun `When fragment view is created via detach-attach, it should start tracing if enabled`() { + // Simulates detach/attach navigation: onFragmentCreated is NOT called, only + // onFragmentViewCreated + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.transaction) + .startChild( + check { assertEquals(SentryFragmentLifecycleCallbacks.FRAGMENT_LOAD_OP, it) }, + check { assertEquals("androidx.fragment.app.Fragment", it) }, + ) + } + + @Test + fun `When fragment view is created via detach-attach, it should update screen name`() { + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true, enableScreenTracking = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.scope).screen = "androidx.fragment.app.Fragment" + } + + @Test + fun `When performance is disabled, it should still update screen name`() { + val sut = + fixture.getSut(enableAutoFragmentLifecycleTracing = false, enableScreenTracking = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.scope).screen = "androidx.fragment.app.Fragment" + verify(fixture.transaction, never()).startChild(any(), any()) + } + + @Test + fun `When fragment view is created after onFragmentCreated, it should not start a second span`() { + // Normal path: onFragmentCreated already started the span; onFragmentViewCreated is a no-op + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.transaction).startChild(any(), any()) + } + + @Test + fun `When fragment is resumed, it should stop tracing if span is still running`() { + // Simulates detach/attach path where onFragmentStarted may be skipped + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + sut.onFragmentResumed(fixture.fragmentManager, fixture.fragment) + + verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) + } + + @Test + fun `When fragment is resumed after started, it should not double-finish the span`() { + // Normal path: onFragmentStarted already stopped the span; onFragmentResumed is a no-op + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) + sut.onFragmentStarted(fixture.fragmentManager, fixture.fragment) + sut.onFragmentResumed(fixture.fragmentManager, fixture.fragment) + + verify(fixture.span).finish(any()) + } + + @Test + fun `When fragment view is destroyed before started, it should stop tracing as failsafe`() { + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + sut.onFragmentViewDestroyed(fixture.fragmentManager, fixture.fragment) + + verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) + } + private fun verifyBreadcrumbAdded(expectedState: String) { verify(fixture.scopes) .addBreadcrumb( diff --git a/sentry-android-integration-tests/metrics-test.yml b/sentry-android-integration-tests/metrics-test.yml index c6b3fdd54de..a73ca1ef7c0 100644 --- a/sentry-android-integration-tests/metrics-test.yml +++ b/sentry-android-integration-tests/metrics-test.yml @@ -10,7 +10,3 @@ startupTimeTest: runs: 50 diffMin: 0 diffMax: 150 - -binarySizeTest: - diffMin: 500 KiB - diffMax: 700 KiB diff --git a/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts index e6480d8b37d..c3ca2379a76 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -46,21 +47,9 @@ android { } } - testBuildType = System.getProperty("testBuildType", "debug") + testBuildType = "release" buildTypes { - getByName("debug") { - isMinifyEnabled = true - signingConfig = signingConfigs.getByName("debug") - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "benchmark-proguard-rules.pro", - ) - testProguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "benchmark-proguard-rules.pro", - ) - } getByName("release") { isMinifyEnabled = true isShrinkResources = true @@ -76,7 +65,7 @@ android { } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + kotlin { compilerOptions.jvmTarget = JvmTarget.JVM_11 } lint { warningsAsErrors = true @@ -87,7 +76,9 @@ android { } androidComponents.beforeVariants { - it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + if (it.buildType == "debug") { + it.enable = false + } } } diff --git a/sentry-android-integration-tests/sentry-uitest-android-benchmark/src/androidTest/java/io/sentry/uitest/android/benchmark/SentryBenchmarkTest.kt b/sentry-android-integration-tests/sentry-uitest-android-benchmark/src/androidTest/java/io/sentry/uitest/android/benchmark/SentryBenchmarkTest.kt index f2066577c6e..05a50895f25 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-benchmark/src/androidTest/java/io/sentry/uitest/android/benchmark/SentryBenchmarkTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android-benchmark/src/androidTest/java/io/sentry/uitest/android/benchmark/SentryBenchmarkTest.kt @@ -1,6 +1,5 @@ package io.sentry.uitest.android.benchmark -import android.content.Context import android.os.Bundle import androidx.lifecycle.Lifecycle import androidx.test.core.app.launchActivity @@ -12,12 +11,6 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.runner.AndroidJUnitRunner import io.sentry.ITransaction -import io.sentry.Sentry -import io.sentry.Sentry.OptionsConfiguration -import io.sentry.SentryOptions -import io.sentry.android.core.SentryAndroid -import io.sentry.android.core.SentryAndroidOptions -import io.sentry.test.applyTestOptions import io.sentry.uitest.android.benchmark.util.BenchmarkOperation import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -71,49 +64,6 @@ class SentryBenchmarkTest : BaseBenchmarkTest() { // respectively. } - @Test - fun benchmarkProfiledTransaction() { - // We compare the same operation with and without profiled transaction. - // We expect the profiled transaction operation to be slower, but not slower than 5%. - val benchmarkOperationNoTransaction = - BenchmarkOperation(choreographer, op = getOperation(runner)) - val benchmarkOperationProfiled = - BenchmarkOperation( - choreographer, - before = { - runner.runOnMainSync { - initForTest(context) { options: SentryOptions -> - options.dsn = "https://key@uri/1234567" - options.tracesSampleRate = 1.0 - options.profilesSampleRate = 1.0 - options.isEnableAutoSessionTracking = false - } - } - }, - op = getOperation(runner) { Sentry.startTransaction("Benchmark", "ProfiledTransaction") }, - after = { runner.runOnMainSync { Sentry.close() } }, - ) - val refreshRate = BenchmarkActivity.refreshRate ?: 60F - val comparisonResults = - BenchmarkOperation.compare( - benchmarkOperationNoTransaction, - "NoTransaction", - benchmarkOperationProfiled, - "ProfiledTransaction", - refreshRate, - measuredIterations = 40, - ) - comparisonResults.printAllRuns("Profiling Benchmark") - val comparisonResult = comparisonResults.getSummaryResult() - comparisonResult.printResults() - - // Currently we just want to assert the cpu overhead - assertTrue( - comparisonResult.cpuTimeIncreasePercentage in 0F..5.5F, - "Expected ${comparisonResult.cpuTimeIncreasePercentage} to be in range 0 < x < 5.5", - ) - } - /** * Operation that will be compared: it launches [BenchmarkActivity], swipe the list and closes it. * The [transactionBuilder] is used to create the transaction before the swipes. @@ -149,13 +99,3 @@ class SentryBenchmarkTest : BaseBenchmarkTest() { } } } - -fun initForTest( - context: Context, - optionsConfiguration: OptionsConfiguration, -) { - SentryAndroid.init(context) { - applyTestOptions(it) - optionsConfiguration.configure(it) - } -} diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts index 4b0cd68ca90..6f875e7a5e9 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts @@ -1,4 +1,5 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -31,7 +32,7 @@ android { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + kotlin { compilerOptions.jvmTarget = JvmTarget.JVM_11 } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } androidComponents.beforeVariants { diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/.maestro/config.yaml b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/.maestro/config.yaml new file mode 100644 index 00000000000..ebb5a3a8e6d --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/.maestro/config.yaml @@ -0,0 +1,3 @@ +platform: + android: + disableAnimations: true diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml new file mode 100644 index 00000000000..7356bd4bf08 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml @@ -0,0 +1,71 @@ +appId: io.sentry.uitest.android.critical +name: App Start Tests +--- +# Test 1: A fresh start is considered a cold start +- launchApp: + stopApp: false +- assertVisible: "Welcome!" +- assertVisible: "App Start Type: COLD" + +# Test 2: Background/foreground transition (WARM start) +- launchApp: + stopApp: false +- assertVisible: "Welcome!" +- tapOn: "Finish Activity" +- launchApp: + stopApp: false +- assertVisible: "App Start Type: WARM" + +# Test 3: Notification (WARM start) +- launchApp: + stopApp: true + permissions: + all: allow +- assertVisible: "Welcome!" +- tapOn: "Trigger Notification" +- tapOn: "Finish Activity" +- assertNotVisible: "Welcome!" +- waitForAnimationToEnd +- repeat: + times: 3 + while: + notVisible: "Sentry Test Notification" + commands: + - swipe: + start: 90%, 0% + end: 90%, 100% + duration: 4000 +- tapOn: "Sentry Test Notification" +- assertVisible: "App Start Type: WARM" + +# Test 4: Notification (COLD start) +- launchApp: + stopApp: true + permissions: + all: allow +- assertVisible: "Welcome!" +- tapOn: "Trigger Notification" +- tapOn: "Finish Activity" +- assertNotVisible: "Welcome!" +- killApp +- waitForAnimationToEnd +- repeat: + times: 3 + while: + notVisible: "Sentry Test Notification" + commands: + - swipe: + start: 90%, 0% + end: 90%, 100% + duration: 4000 +- tapOn: "Sentry Test Notification" +- assertVisible: "App Start Type: COLD" +# Test 5: Launch app after a broadcast receiver already created the application +# Uncomment once https://github.com/mobile-dev-inc/Maestro/pull/2925 is merged +# - killApp +# - sendBroadcast: +# action: io.sentry.uitest.android.critical.ACTION +# receiver: io.sentry.uitest.android.critical/.EmptyBroadcastReceiver +# - launchApp: +# stopApp: false +# - assertVisible: "App Start Type: WARM" diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml index 0ab5e6052df..d9d9b7f7d1b 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml @@ -1,21 +1,39 @@ + xmlns:tools="http://schemas.android.com/tools"> - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/App.kt b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/App.kt new file mode 100644 index 00000000000..a24d8c54cb4 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/App.kt @@ -0,0 +1,16 @@ +package io.sentry.uitest.android.critical + +import android.app.Application +import android.util.Log + +class App : Application() { + + companion object { + private const val TAG = "App" + } + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "onCreate: Application Created") + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/EmptyBroadcastReceiver.kt b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/EmptyBroadcastReceiver.kt new file mode 100644 index 00000000000..3aef794189b --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/EmptyBroadcastReceiver.kt @@ -0,0 +1,22 @@ +package io.sentry.uitest.android.critical + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +class EmptyBroadcastReceiver : BroadcastReceiver() { + companion object { + private const val TAG = "EmptyBroadcastReceiver" + } + + override fun onReceive(context: Context?, intent: Intent?) { + val pendingResult = goAsync() + Log.d(TAG, "onReceive: broadcast received") + Thread { + Thread.sleep(1000) + pendingResult.finish() + } + .start() + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt index f5e731ecd56..7e0ff9d61c3 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt @@ -1,37 +1,80 @@ package io.sentry.uitest.android.critical +import android.content.Intent +import android.os.Build import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import io.sentry.Sentry +import io.sentry.android.core.performance.AppStartMetrics +import io.sentry.uitest.android.critical.NotificationHelper.showNotification import java.io.File +import kotlinx.coroutines.delay class MainActivity : ComponentActivity() { + private lateinit var requestPermissionLauncher: ActivityResultLauncher + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(android.R.style.Theme_DeviceDefault_NoActionBar) + super.onCreate(savedInstanceState) val outboxPath = Sentry.getCurrentHub().options.outboxPath ?: throw RuntimeException("Outbox path is not set.") + requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> + if (isGranted) { + // Permission granted, show notification + postNotification() + } else { + // Permission denied, handle accordingly + Toast.makeText(this, "Notification permission denied", Toast.LENGTH_SHORT).show() + } + } setContent { + var appStartType by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + delay(100) + appStartType = AppStartMetrics.getInstance().appStartType.name + } + MaterialTheme { Surface { - Column { + Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { Text(text = "Welcome!") + Text(text = "App Start Type: $appStartType") + Button(onClick = { throw RuntimeException("Crash the test app.") }) { Text("Crash") } Button(onClick = { Sentry.close() }) { Text("Close SDK") } Button( onClick = { + // The SDK creates the outbox dir lazily on its executor, so an external + // writer has to create it itself. + File(outboxPath).mkdirs() val file = File(outboxPath, "corrupted.envelope") val corruptedEnvelopeContent = """ - {"event_id":"1990b5bc31904b7395fd07feb72daf1c","sdk":{"name":"sentry.java.android","version":"7.21.0"}} - {"type":"test","length":50} - """ + {"event_id":"1990b5bc31904b7395fd07feb72daf1c","sdk":{"name":"sentry.java.android","version":"7.21.0"}} + {"type":"test","length":50} + """ .trimIndent() file.writeText(corruptedEnvelopeContent) println("Wrote corrupted envelope to: ${file.absolutePath}") @@ -39,9 +82,44 @@ class MainActivity : ComponentActivity() { ) { Text("Write Corrupted Envelope") } + Button(onClick = { finish() }) { Text("Finish Activity") } + Button( + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } else { + postNotification() + } + } + ) { + Text("Trigger Notification") + } + Button( + onClick = { + startActivity( + Intent(this@MainActivity, MainActivity::class.java).apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + ) + } + ) + } + ) { + Text("Launch Main Activity (singleTask)") + } } } } } } + + fun postNotification() { + NotificationHelper.showNotification( + this@MainActivity, + "Sentry Test Notification", + "This is a test notification.", + ) + } } diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/NotificationHelper.kt b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/NotificationHelper.kt new file mode 100644 index 00000000000..4cbaede1b7f --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/NotificationHelper.kt @@ -0,0 +1,52 @@ +package io.sentry.uitest.android.critical + +import android.R +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat + +object NotificationHelper { + + private const val CHANNEL_ID = "channel_id" + private const val NOTIFICATION_ID = 1 + + fun showNotification(context: Context, title: String?, message: String?) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Create notification channel for Android 8.0+ (API 26+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = + NotificationChannel(CHANNEL_ID, "Notifications", NotificationManager.IMPORTANCE_DEFAULT) + channel.description = "description" + notificationManager.createNotificationChannel(channel) + } + + // Intent to open when notification is tapped + val intent = Intent(context, MainActivity::class.java) + val pendingIntent = + PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + // Build the notification + val builder = + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) // Dismiss when tapped + + // Show the notification + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/README.md b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/README.md new file mode 100644 index 00000000000..4b89b7b6105 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/README.md @@ -0,0 +1,57 @@ +# sentry-uitest-android-macrobenchmark + +Jetpack Macrobenchmark for cold-start of `sentry-samples-android`, used to evaluate SDK-init +performance changes on a real device in a **stable, reproducible** way. Not run in CI. + +## What it measures + +`SentryStartupBenchmark` runs a cold start and reports two metrics per iteration: + +- **`timeToInitialDisplay`** (`StartupTimingMetric`) — the whole app cold start, taken from + framework trace events. Because it captures the entire start, an SDK change has to be large enough + (roughly tens of milliseconds) to show above cold-start noise. +- **`SentryAndroid.init`** (`TraceSectionMetric`) — the duration of the `SentryAndroid.init` + `android.os.Trace` section the SDK emits, which isolates SDK-init cost from the rest of the start + and resolves changes that `timeToInitialDisplay` would lose in the noise. + +For even finer detail (sub-millisecond changes, or cost inside init), capture a perfetto trace and +inspect the relevant slices directly (each iteration's trace is saved under +`build/outputs/connected_android_test_additional_output/`). + +`CompilationMode.Full()` pins ART AOT so dexopt state can't drift between runs. `StartupMode.COLD` +does the correct force-stop sequencing (it does **not** `pm clear`, so app data/permissions are +kept). Iterations are capped at 12 because back-to-back cold starts thermally throttle an +unlocked-clock device after ~14 iterations, inflating the tail of longer runs. + +## Running + +Connect a device, then: + +```bash +./gradlew :sentry-android-integration-tests:sentry-uitest-android-macrobenchmark:connectedBenchmarkAndroidTest +``` + +Results print to the console and are written to +`build/outputs/connected_android_test_additional_output/.../*-benchmarkData.json`. + +### Device hygiene (do this for trustworthy numbers) + +- **Wake and unlock the device first** — the launch check fails with "Unable to confirm activity + launch completion" on a dozing/locked screen + (`adb shell input keyevent KEYCODE_WAKEUP && adb shell wm dismiss-keyguard`). +- **Charge above 25%** — Macrobenchmark refuses to run below that. +- **Lock CPU clocks** if the device is rooted: this is the single biggest cure for thermal drift. +- Otherwise: let the device cool between runs, keep it on AC power, enable airplane mode, and turn + animations off (`adb shell settings put global window_animation_scale 0`, plus + `transition_animation_scale` and `animator_duration_scale`). +- Heed Macrobenchmark's warnings about unlocked clocks / low battery — they mean the numbers are + noisy. + +## A/B-ing an SDK change + +Macrobenchmark measures one build per run, so compare separate runs — but **interleave them**: +running all of variant A followed by all of variant B lets thermal drift systematically penalize +whichever variant runs second. Instead, alternate A/B rounds (build variant A, run, build variant +B, run, repeat 2–3 times), keep each round's `*-benchmarkData.json`, and compare the values pooled +per variant. Prefer the `SentryAndroid.init` metric for SDK-init changes — it isolates init cost, so +it moves on changes that `timeToInitialDisplay` would bury in cold-start noise. diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts new file mode 100644 index 00000000000..a00d76d6029 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts @@ -0,0 +1,48 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + +plugins { + id("com.android.test") + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "io.sentry.uitest.android.macrobenchmark" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + // Macrobenchmark requires API 23+. + minSdk = 24 + targetSdk = libs.versions.targetSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + // Pairs with the app's release build via matchingFallbacks. The test APK itself must be + // debuggable (to instrument) and signed (to install); only the target app needs to be + // genuinely release-like. + create("benchmark") { + isDebuggable = true + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlin { compilerOptions.jvmTarget = JVM_11 } + + targetProjectPath = ":sentry-samples:sentry-samples-android" + // Run the test in its own process so it measures the target app cold, not itself. + experimentalProperties["android.experimental.self-instrumenting"] = true +} + +// Benchmarks only make sense against the release build; drop the debug variant entirely. +androidComponents { beforeVariants(selector().withBuildType("debug")) { it.enable = false } } + +dependencies { + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.benchmark.macro.junit4) +} diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/AndroidManifest.xml b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..b2d3ea12352 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt new file mode 100644 index 00000000000..ee49fe8beff --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt @@ -0,0 +1,56 @@ +package io.sentry.uitest.android.macrobenchmark + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.ExperimentalMetricApi +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.TraceSectionMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Cold-start benchmark for the sentry-samples-android app, used to evaluate SDK-init changes on a + * real device in a stable, repeatable way. + * + * Reports two metrics per iteration: + * - timeToInitialDisplay ([StartupTimingMetric]) — the whole app cold start from framework trace + * events. Because it captures the entire start, an SDK change has to be large enough (roughly + * tens of milliseconds) to show above cold-start noise. + * - SentryAndroid.init ([TraceSectionMetric]) — the duration of the `SentryAndroid.init` + * [android.os.Trace] section the SDK emits, isolating SDK-init cost from the rest of the start. + * + * [CompilationMode.Full] pins ART AOT compilation so dexopt state does not drift between runs. + * Iterations are capped at 12: on an unthrottled Pixel 3, back-to-back cold starts hit thermal + * throttling after ~14 iterations, which inflates the tail of longer runs. This is NOT a CI test; + * it requires a connected device. To A/B an SDK change, see README.md (build the app twice, once + * per SDK variant, in interleaved rounds). + */ +@OptIn(ExperimentalMetricApi::class) +@RunWith(AndroidJUnit4::class) +class SentryStartupBenchmark { + + @get:Rule val benchmarkRule = MacrobenchmarkRule() + + @Test + fun startupFullCompilation() = + benchmarkRule.measureRepeated( + packageName = TARGET_PACKAGE, + metrics = listOf(StartupTimingMetric(), TraceSectionMetric(INIT_TRACE_SECTION)), + compilationMode = CompilationMode.Full(), + startupMode = StartupMode.COLD, + iterations = 12, + setupBlock = { pressHome() }, + ) { + startActivityAndWait() + } + + private companion object { + const val TARGET_PACKAGE = "io.sentry.samples.android" + + // Matches the android.os.Trace section name in SentryAndroid.init. + const val INIT_TRACE_SECTION = "SentryAndroid.init" + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android/README.md b/sentry-android-integration-tests/sentry-uitest-android/README.md index c11397383d0..08389e0c16c 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/README.md +++ b/sentry-android-integration-tests/sentry-uitest-android/README.md @@ -14,14 +14,12 @@ You can run benchmark tests only with `./gradlew :sentry-android-integration-tes To run on saucelabs execute following commands (need also `SAUCE_USERNAME` and `SAUCE_ACCESS_KEY` environment variables): For Benchmarks: ``` -./gradlew :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleRelease -./gradlew :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleAndroidTest -DtestBuildType=release +./gradlew :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleRelease :sentry-android-integration-tests:sentry-uitest-android-benchmark:assembleAndroidTest saucectl run -c .sauce/sentry-uitest-android-benchmark.yml ``` For End 2 End: ``` -./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease -./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest -DtestBuildType=release +./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest saucectl run -c .sauce/sentry-uitest-android-end2end.yml ``` diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 0c32cbad941..52c17199e4d 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -51,25 +52,19 @@ android { } } - testBuildType = System.getProperty("testBuildType", "debug") + testBuildType = "release" buildTypes { - getByName("debug") { - isMinifyEnabled = true - signingConfig = signingConfigs.getByName("debug") - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - testProguardFiles("proguard-rules.pro") - } getByName("release") { isMinifyEnabled = true - isShrinkResources = false + isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName("debug") // to be able to run release mode testProguardFiles("proguard-rules.pro") } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + kotlin { compilerOptions.jvmTarget = JvmTarget.JVM_11 } lint { warningsAsErrors = true @@ -80,12 +75,18 @@ android { } androidComponents.beforeVariants { - it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + if (it.buildType == "debug") { + it.enable = false + } } } val applySentryIntegrations = System.getenv("APPLY_SENTRY_INTEGRATIONS")?.toBoolean() ?: true +if (applySentryIntegrations) { + android.sourceSets["androidTest"].java.srcDirs("src/androidTestReplay/java") +} + dependencies { implementation( kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt index 03667b537c1..62be835eb2b 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt @@ -157,8 +157,7 @@ internal fun SentryEnvelope.describeForTest(): String { val deserialized = JsonSerializer(SentryOptions()) .deserialize(item.data.inputStream().reader(), SentryEvent::class.java)!! - descr += - "Event (${deserialized.eventId}) - message: ${deserialized.message!!.formatted} -- " + descr += "Event (${deserialized.eventId}) - message: ${deserialized.message?.formatted} -- " } SentryItemType.Transaction -> { val deserialized = diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt index 30cdfefde25..3fa2c904873 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt @@ -262,15 +262,20 @@ class EnvelopeTests : BaseUiTest() { optionsRef = options } + // The SDK creates the outbox dir lazily on its executor, so an external writer racing + // Sentry.init has to create it itself. + val outboxDir = File(optionsRef!!.outboxPath!!) + outboxDir.mkdirs() + // based on // https://github.com/getsentry/sentry-native/blob/20d5d5f75f1f48228f2f47e2bb99b17f9996ebbf/ndk/lib/src/androidTest/java/io/sentry/ndk/SentryNdkTest.java#L131 - File(optionsRef!!.outboxPath, "14779dbf-b2f0-4c00-f4e5-4a287abc4267") + File(outboxDir, "14779dbf-b2f0-4c00-f4e5-4a287abc4267") .writeText( """ - {"dsn":"https://key@sentry.io/proj","event_id":"729ff878-5539-458d-f657-a1acf423a127","sent_at":"2025-04-02T10:02:04.732577Z"} - {"type":"transaction","length":1335} - {"event_id":"729ff878-5539-458d-f657-a1acf423a127","platform":"native","transaction":"little.teapot","start_timestamp":"2025-04-02T10:02:04.731697Z","spans":[{"op":"littlest.teapot","span_id":"00028ba394454124","status":"ok","trace_id":"7160e289fe4c4496f02c72bbc7edb392","parent_span_id":"b0dc1649a8ec4101","description":null,"start_timestamp":"2025-04-02T10:02:04.732127Z","timestamp":"2025-04-02T10:02:04.732133Z"},{"op":"littler.teapot","span_id":"b0dc1649a8ec4101","status":"ok","trace_id":"7160e289fe4c4496f02c72bbc7edb392","parent_span_id":"7ad2e40529af4650","description":null,"start_timestamp":"2025-04-02T10:02:04.732118Z","data":{"span_data_says":"hi!"},"timestamp":"2025-04-02T10:02:04.732137Z"}],"type":"transaction","timestamp":"2025-04-02T10:02:04.732142Z","level":"info","contexts":{"trace":{"trace_id":"7160e289fe4c4496f02c72bbc7edb392","span_id":"7ad2e40529af4650","op":"Short and stout here is my handle and here is my spout","status":"ok","data":{"url":"https://example.com"}},"os":{"build":"android14-4-00257-g7e35917775b8-ab9964412","name":"Linux","version":"6.1.23"}},"release":"1.0.0","dist":"dist","environment":"production","sdk":{"name":"io.sentry.ndk","version":"0.8.3","packages":[{"name":"github:getsentry/sentry-native","version":"0.8.3"}],"integrations":["inproc"]},"tags":{},"extra":{},"breadcrumbs":[]} - """ + {"dsn":"https://key@sentry.io/proj","event_id":"729ff878-5539-458d-f657-a1acf423a127","sent_at":"2025-04-02T10:02:04.732577Z"} + {"type":"transaction","length":1335} + {"event_id":"729ff878-5539-458d-f657-a1acf423a127","platform":"native","transaction":"little.teapot","start_timestamp":"2025-04-02T10:02:04.731697Z","spans":[{"op":"littlest.teapot","span_id":"00028ba394454124","status":"ok","trace_id":"7160e289fe4c4496f02c72bbc7edb392","parent_span_id":"b0dc1649a8ec4101","description":null,"start_timestamp":"2025-04-02T10:02:04.732127Z","timestamp":"2025-04-02T10:02:04.732133Z"},{"op":"littler.teapot","span_id":"b0dc1649a8ec4101","status":"ok","trace_id":"7160e289fe4c4496f02c72bbc7edb392","parent_span_id":"7ad2e40529af4650","description":null,"start_timestamp":"2025-04-02T10:02:04.732118Z","data":{"span_data_says":"hi!"},"timestamp":"2025-04-02T10:02:04.732137Z"}],"type":"transaction","timestamp":"2025-04-02T10:02:04.732142Z","level":"info","contexts":{"trace":{"trace_id":"7160e289fe4c4496f02c72bbc7edb392","span_id":"7ad2e40529af4650","op":"Short and stout here is my handle and here is my spout","status":"ok","data":{"url":"https://example.com"}},"os":{"build":"android14-4-00257-g7e35917775b8-ab9964412","name":"Linux","version":"6.1.23"}},"release":"1.0.0","dist":"dist","environment":"production","sdk":{"name":"io.sentry.ndk","version":"0.8.3","packages":[{"name":"github:getsentry/sentry-native","version":"0.8.3"}],"integrations":["inproc"]},"tags":{},"extra":{},"breadcrumbs":[]} + """ .trimIndent() ) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt index 5ea12ddbbc8..9c53f0a022d 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt @@ -21,6 +21,9 @@ class ReplayTest : BaseUiTest() { // we can't run on GH actions emulator, because they don't allow capturing screenshots properly @Suppress("KotlinConstantConditions") assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true)) + // crash on swallowed Compose masking errors (e.g. broken obfuscated internals) so regressions + // fail this on-device test instead of silently under-masking (see SentryReplayDebug) + System.setProperty("io.sentry.replay.compose.fail-fast", "true") } @Test @@ -67,11 +70,10 @@ class ReplayTest : BaseUiTest() { initSentry { it.sessionReplay.sessionSampleRate = 1.0 - it.beforeSendReplay = - SentryOptions.BeforeSendReplayCallback { event, _ -> - sent.set(true) - event - } + it.beforeSendReplay = SentryOptions.BeforeSendReplayCallback { event, _ -> + sent.set(true) + event + } } // wait until first segment is being sent diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt index 0f84101738b..bfcaf2845b3 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt @@ -1,9 +1,9 @@ package io.sentry.uitest.android import android.graphics.Color -import android.util.TypedValue import android.view.View import android.widget.EditText +import android.widget.FrameLayout import android.widget.LinearLayout import androidx.test.core.app.launchActivity import androidx.test.espresso.Espresso.onView @@ -28,7 +28,7 @@ import io.sentry.SentryOptions import io.sentry.android.core.AndroidLogger import io.sentry.android.core.R import io.sentry.android.core.SentryUserFeedbackButton -import io.sentry.android.core.SentryUserFeedbackDialog +import io.sentry.android.core.SentryUserFeedbackForm import io.sentry.assertEnvelopeFeedback import io.sentry.protocol.SentryId import io.sentry.protocol.User @@ -49,21 +49,21 @@ class UserFeedbackUiTest : BaseUiTest() { @Test fun userFeedbackNotShownWhenSdkDisabled() { launchActivity().onActivity { - SentryUserFeedbackDialog.Builder(it).create().show() + SentryUserFeedbackForm.Builder(it).create().show() } onView(withId(R.id.sentry_dialog_user_feedback_layout)).check(doesNotExist()) } @Test fun userFeedbackNotShownWhenSdkDisabledViaApi() { - launchActivity().onActivity { Sentry.showUserFeedbackDialog() } + launchActivity().onActivity { Sentry.feedback().show() } onView(withId(R.id.sentry_dialog_user_feedback_layout)).check(doesNotExist()) } @Test fun userFeedbackShownViaApi() { initSentry() - launchActivity().onActivity { Sentry.showUserFeedbackDialog() } + launchActivity().onActivity { Sentry.feedback().show() } onView(withId(R.id.sentry_dialog_user_feedback_layout)) .inRoot(isDialog()) @@ -550,10 +550,6 @@ class UserFeedbackUiTest : BaseUiTest() { assertEquals((densityScale * 12).toInt(), widget.paddingTop) assertEquals((densityScale * 12).toInt(), widget.paddingBottom) - val typedValue = TypedValue() - widget.context.theme.resolveAttribute(android.R.attr.colorForeground, typedValue, true) - assertEquals(typedValue.data, widget.currentTextColor) - assertEquals("Report a Bug", widget.text) } @@ -643,12 +639,12 @@ class UserFeedbackUiTest : BaseUiTest() { private fun showDialogAndCheck( associatedEventId: SentryId? = null, - checker: (dialog: SentryUserFeedbackDialog) -> Unit = {}, + checker: (dialog: SentryUserFeedbackForm) -> Unit = {}, ) { - lateinit var dialog: SentryUserFeedbackDialog + lateinit var dialog: SentryUserFeedbackForm val feedbackScenario = launchActivity() feedbackScenario.onActivity { - dialog = SentryUserFeedbackDialog.Builder(it).associatedEventId(associatedEventId).create() + dialog = SentryUserFeedbackForm.Builder(it).associatedEventId(associatedEventId).create() dialog.show() } @@ -666,14 +662,19 @@ class UserFeedbackUiTest : BaseUiTest() { val buttonId = Int.MAX_VALUE - 1 val feedbackScenario = launchActivity() feedbackScenario.onActivity { + val layoutParams = + FrameLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT, + ) val view = - LinearLayout(it).apply { - orientation = LinearLayout.VERTICAL + FrameLayout(it).apply { addView( SentryUserFeedbackButton(it).apply { id = buttonId widgetConfig?.invoke(this) - } + }, + layoutParams, ) } it.setContentView(view) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt index b76017aeb0b..2ea1905b761 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt @@ -89,11 +89,10 @@ class UserInteractionTests : BaseUiTest() { options.profilesSampleRate = 1.0 options.isEnableUserInteractionTracing = true options.isEnableUserInteractionBreadcrumbs = true - options.beforeBreadcrumb = - SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> - breadcrumbs.add(breadcrumb) - breadcrumb - } + options.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + breadcrumbs.add(breadcrumb) + breadcrumb + } } } } diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/EnvelopeAsserter.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/EnvelopeAsserter.kt index aa903004a3c..9b0106775ef 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/EnvelopeAsserter.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/EnvelopeAsserter.kt @@ -2,7 +2,9 @@ package io.sentry.uitest.android.mockservers import io.sentry.ProfilingTraceData import io.sentry.SentryEnvelope +import io.sentry.SentryEvent import io.sentry.android.core.AndroidLogger +import io.sentry.assertEnvelopeEvent import io.sentry.assertEnvelopeItem import io.sentry.assertEnvelopeProfile import io.sentry.assertEnvelopeTransaction @@ -27,6 +29,16 @@ class EnvelopeAsserter(val envelope: SentryEnvelope, val response: MockResponse) return item } + /** + * Asserts a transaction exists and returns the first one. It is then removed from internal list + * of unasserted items. + */ + fun assertEvent(): SentryEvent = + assertEnvelopeEvent(unassertedItems, AndroidLogger()) { index, item -> + unassertedItems.removeAt(index) + return item + } + /** * Asserts a transaction exists and returns the first one. It is then removed from internal list * of unasserted items. diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt new file mode 100644 index 00000000000..6d45b2d1f9c --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -0,0 +1,74 @@ +package io.sentry.uitest.android + +import android.graphics.Bitmap +import android.os.Environment +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.launchActivity +import io.sentry.SentryReplayOptions +import io.sentry.TypeCheckHint +import java.io.File +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertTrue +import org.hamcrest.CoreMatchers.`is` +import org.junit.Assume.assumeThat +import org.junit.Before + +class ReplaySnapshotTest : BaseUiTest() { + + @Before + fun setup() { + // GH Actions emulators don't support capturing screenshots for replay + @Suppress("KotlinConstantConditions") + assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true)) + // crash on swallowed Compose masking errors (e.g. broken obfuscated internals) so regressions + // fail this on-device test instead of silently under-masking (see SentryReplayDebug) + System.setProperty("io.sentry.replay.compose.fail-fast", "true") + } + + @Test + fun captureComposeReplayFrameSnapshots() { + val snapshotsDir = + File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "sauce_labs_custom_screenshots", + ) + .apply { + deleteRecursively() + mkdirs() + } + val frameReceived = CountDownLatch(1) + val capturedScreens = CopyOnWriteArraySet() + + val activityScenario = launchActivity() + activityScenario.moveToState(Lifecycle.State.RESUMED) + + initSentry { + it.sessionReplay.sessionSampleRate = 1.0 + it.sessionReplay.frameObserver = + SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName -> + val bitmap = + hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) + ?: return@ReplayFrameObserver + val name = screenName ?: "unknown" + if (capturedScreens.add(name)) { + val file = File(snapshotsDir, "${name}_$frameTimestamp.png") + file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } + } + bitmap.recycle() + frameReceived.countDown() + } + } + + assertTrue(frameReceived.await(10, TimeUnit.SECONDS), "Expected at least one replay frame") + assertTrue(capturedScreens.isNotEmpty(), "Expected at least one screen captured") + + val files = snapshotsDir.listFiles()?.filter { it.extension == "png" } ?: emptyList() + assertTrue(files.isNotEmpty(), "Expected snapshot PNG files on disk") + assertTrue(files.all { it.length() > 0 }, "Snapshot files should not be empty") + + activityScenario.moveToState(Lifecycle.State.DESTROYED) + } +} diff --git a/sentry-android-integration-tests/test-app-plain/build.gradle.kts b/sentry-android-integration-tests/test-app-plain/build.gradle.kts index 4d6655132c3..9778363ede8 100644 --- a/sentry-android-integration-tests/test-app-plain/build.gradle.kts +++ b/sentry-android-integration-tests/test-app-plain/build.gradle.kts @@ -45,7 +45,7 @@ android { dependencies { implementation("androidx.appcompat:appcompat:1.3.0") implementation("com.google.android.material:material:1.4.0") - implementation("androidx.constraintlayout:constraintlayout:2.0.4") + implementation("androidx.constraintlayout:constraintlayout:2.2.1") implementation("androidx.navigation:navigation-fragment:2.3.5") implementation("androidx.navigation:navigation-ui:2.3.5") } diff --git a/sentry-android-integration-tests/test-app-sentry/build.gradle.kts b/sentry-android-integration-tests/test-app-sentry/build.gradle.kts index cd340b9a4a6..db0cb4a46ab 100644 --- a/sentry-android-integration-tests/test-app-sentry/build.gradle.kts +++ b/sentry-android-integration-tests/test-app-sentry/build.gradle.kts @@ -45,7 +45,7 @@ android { dependencies { implementation("androidx.appcompat:appcompat:1.3.0") implementation("com.google.android.material:material:1.4.0") - implementation("androidx.constraintlayout:constraintlayout:2.0.4") + implementation("androidx.constraintlayout:constraintlayout:2.2.1") implementation("androidx.navigation:navigation-fragment:2.3.5") implementation("androidx.navigation:navigation-ui:2.3.5") implementation(projects.sentryAndroid) diff --git a/sentry-android-integration-tests/test-app-size/.gitignore b/sentry-android-integration-tests/test-app-size/.gitignore new file mode 100644 index 00000000000..796b96d1c40 --- /dev/null +++ b/sentry-android-integration-tests/test-app-size/.gitignore @@ -0,0 +1 @@ +/build diff --git a/sentry-android-integration-tests/test-app-size/build.gradle.kts b/sentry-android-integration-tests/test-app-size/build.gradle.kts new file mode 100644 index 00000000000..20af6dad6ae --- /dev/null +++ b/sentry-android-integration-tests/test-app-size/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + id("com.android.application") + id("io.sentry.android.gradle") +} + +android { + namespace = "io.sentry.tests.size" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "io.sentry.tests.size" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = project.version.toString() + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + ndk { + abiFilters.clear() + abiFilters.add("arm64-v8a") + } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + androidComponents.beforeVariants { + it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + } +} + +configurations.configureEach { + exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib-jdk8") + exclude(group = "androidx.core") + exclude(group = "androidx.lifecycle") +} + +dependencies { + implementation(projects.sentryAndroid) + implementation(projects.sentryAndroidTimber) + implementation(projects.sentryAndroidSqlite) + implementation(projects.sentryOkhttp) + implementation(projects.sentryCompose) + implementation(projects.sentryAndroidFragment) +} + +sentry { + org.set("sentry-sdks") + projectName.set("sentry-android") + authToken.set(System.getenv("SENTRY_AUTH_TOKEN")) + includeProguardMapping.set(false) + tracingInstrumentation.enabled.set(false) + includeDependenciesReport.set(false) + telemetry.set(false) + autoInstallation.enabled.set(false) + val authTokenPresent = + providers.environmentVariable("SENTRY_AUTH_TOKEN").map { it.isNotBlank() }.getOrElse(false) + distribution.enabled.set(authTokenPresent) + sizeAnalysis.enabled.set(authTokenPresent) +} diff --git a/sentry-android-integration-tests/test-app-size/proguard-rules.pro b/sentry-android-integration-tests/test-app-size/proguard-rules.pro new file mode 100644 index 00000000000..4db0031ab00 --- /dev/null +++ b/sentry-android-integration-tests/test-app-size/proguard-rules.pro @@ -0,0 +1,51 @@ +# Rules to not warn about missing classes. We use them, but we only want to measure the SDK size overhead and not run the app, so we keep it lean +# This is generated automatically by the Android Gradle plugin. +-dontwarn kotlin.Lazy +-dontwarn kotlin.LazyKt +-dontwarn kotlin.LazyThreadSafetyMode +-dontwarn kotlin.Metadata +-dontwarn kotlin.NoWhenBranchMatchedException +-dontwarn kotlin.Pair +-dontwarn kotlin.TuplesKt +-dontwarn kotlin.Unit +-dontwarn kotlin.collections.CollectionsKt +-dontwarn kotlin.comparisons.ComparisonsKt +-dontwarn kotlin.enums.EnumEntries +-dontwarn kotlin.enums.EnumEntriesKt +-dontwarn kotlin.io.CloseableKt +-dontwarn kotlin.io.FilesKt +-dontwarn kotlin.io.TextStreamsKt +-dontwarn kotlin.jdk7.AutoCloseableKt +-dontwarn kotlin.jvm.functions.Function0 +-dontwarn kotlin.jvm.functions.Function1 +-dontwarn kotlin.jvm.internal.DefaultConstructorMarker +-dontwarn kotlin.jvm.internal.Intrinsics +-dontwarn kotlin.jvm.internal.Lambda +-dontwarn kotlin.jvm.internal.MutablePropertyReference1 +-dontwarn kotlin.jvm.internal.MutablePropertyReference1Impl +-dontwarn kotlin.jvm.internal.Ref$ObjectRef +-dontwarn kotlin.jvm.internal.Reflection +-dontwarn kotlin.jvm.internal.SourceDebugExtension +-dontwarn kotlin.jvm.internal.TypeIntrinsics +-dontwarn kotlin.properties.ReadWriteProperty +-dontwarn kotlin.ranges.LongProgression +-dontwarn kotlin.ranges.LongRange +-dontwarn kotlin.ranges.RangesKt +-dontwarn kotlin.reflect.KMutableProperty1 +-dontwarn kotlin.reflect.KProperty +-dontwarn kotlin.sequences.Sequence +-dontwarn kotlin.text.Charsets +-dontwarn kotlin.text.Regex +-dontwarn kotlin.text.StringsKt +-dontwarn timber.log.Timber$Tree +-dontwarn kotlin.Deprecated +-dontwarn kotlin.Function +-dontwarn kotlin.jvm.JvmStatic +-dontwarn kotlin.jvm.functions.Function2 +-dontwarn kotlin.jvm.functions.Function3 +-dontwarn kotlin.jvm.internal.Ref$BooleanRef +-dontwarn kotlin.math.MathKt +-dontwarn okhttp3.EventListener +-dontwarn okhttp3.Interceptor +# Assume all classes are used to not strip them out, e.g. integrations like Compose or Sqlite +-keep class io.sentry.** diff --git a/sentry-android-integration-tests/test-app-size/src/main/AndroidManifest.xml b/sentry-android-integration-tests/test-app-size/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..acdb622cbff --- /dev/null +++ b/sentry-android-integration-tests/test-app-size/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts index 7f5d1017ec3..6c1aa62a57d 100644 --- a/sentry-android-navigation/build.gradle.kts +++ b/sentry-android-navigation/build.gradle.kts @@ -1,10 +1,10 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } @@ -25,10 +25,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-ndk/api/sentry-android-ndk.api b/sentry-android-ndk/api/sentry-android-ndk.api index 44c153a71fe..a7c5571d0bb 100644 --- a/sentry-android-ndk/api/sentry-android-ndk.api +++ b/sentry-android-ndk/api/sentry-android-ndk.api @@ -15,7 +15,9 @@ public final class io/sentry/android/ndk/DebugImagesLoader : io/sentry/android/c public final class io/sentry/android/ndk/NdkScopeObserver : io/sentry/ScopeObserverAdapter { public fun (Lio/sentry/SentryOptions;)V + public fun addAttachment (Lio/sentry/Attachment;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V + public fun clearAttachments ()V public fun removeExtra (Ljava/lang/String;)V public fun removeTag (Ljava/lang/String;)V public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index 413fd3a7b77..6867d964124 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -3,8 +3,6 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) } @@ -28,6 +26,10 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } testOptions { diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java index 023ce965f51..a1474bb69c8 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java @@ -1,5 +1,6 @@ package io.sentry.android.ndk; +import io.sentry.Attachment; import io.sentry.Breadcrumb; import io.sentry.DateUtils; import io.sentry.IScope; @@ -145,4 +146,41 @@ public void setTrace(@Nullable SpanContext spanContext, @NotNull IScope scope) { options.getLogger().log(SentryLevel.ERROR, e, "Scope sync setTrace failed."); } } + + @Override + public void addAttachment(final @NotNull Attachment attachment) { + final String pathname = attachment.getPathname(); + if (pathname != null) { + try { + options.getExecutorService().submit(() -> nativeScope.addAttachment(pathname)); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, e, "Scope sync addAttachment has an error."); + } + return; + } + + final byte[] bytes = attachment.getBytes(); + if (bytes != null) { + final String filename = attachment.getFilename(); + try { + options.getExecutorService().submit(() -> nativeScope.addAttachmentBytes(bytes, filename)); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, e, "Scope sync addAttachment has an error."); + } + return; + } + + options + .getLogger() + .log(SentryLevel.DEBUG, "Scope sync addAttachment skips attachment without path or bytes."); + } + + @Override + public void clearAttachments() { + try { + options.getExecutorService().submit(() -> nativeScope.clearAttachments()); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, e, "Scope sync clearAttachments has an error."); + } + } } diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java index 9d6d64a1236..fae652f7a64 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java @@ -73,6 +73,9 @@ public static void init(@NotNull final SentryAndroidOptions options) { ndkOptions.setTracesSampleRate(tracesSampleRate.floatValue()); } + ndkOptions.setEnableAppHangTracking(options.isEnableNdkAppHangTracking()); + ndkOptions.setAppHangTimeoutMillis(options.getNdkAppHangTimeoutIntervalMillis()); + //noinspection UnstableApiUsage io.sentry.ndk.SentryNdk.init(ndkOptions); diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt index 696bb69a8d5..a8b5318bfab 100644 --- a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt @@ -1,5 +1,6 @@ package io.sentry.android.ndk +import io.sentry.Attachment import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.JsonSerializer @@ -153,4 +154,34 @@ class NdkScopeObserverTest { verify(fixture.nativeScope) .addBreadcrumb(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } + + @Test + fun `add file-path attachment syncs to native scope`() { + val sut = fixture.getSut() + + val attachment = Attachment("/data/data/com.example/files/log.txt") + sut.addAttachment(attachment) + + verify(fixture.nativeScope).addAttachment("/data/data/com.example/files/log.txt") + } + + @Test + fun `add byte attachment syncs bytes to native scope`() { + val sut = fixture.getSut() + + val bytes = byteArrayOf(1, 2, 3) + val attachment = Attachment(bytes, "data.bin") + sut.addAttachment(attachment) + + verify(fixture.nativeScope).addAttachmentBytes(bytes, "data.bin") + } + + @Test + fun `clear attachments forwards call to native scope`() { + val sut = fixture.getSut() + + sut.clearAttachments() + + verify(fixture.nativeScope).clearAttachments() + } } diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt index c9f540f0afa..d9b332ac185 100644 --- a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt @@ -3,7 +3,9 @@ package io.sentry.android.ndk import io.sentry.android.core.SentryAndroidOptions import io.sentry.ndk.NdkOptions import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertTrue import org.junit.Test import org.mockito.Mockito import org.mockito.kotlin.any @@ -68,4 +70,30 @@ class SentryNdkTest { assertEquals(0.75f, fixture.capturedOptions!!.tracesSampleRate, 0.0001f) } } + + @Test + fun `SentryNdk does not enable app hang tracking by default`() { + fixture.getSut { + assertNotNull(fixture.capturedOptions) + assertFalse(fixture.capturedOptions!!.isEnableAppHangTracking) + assertEquals(5000L, fixture.capturedOptions!!.appHangTimeoutMillis) + } + } + + @Test + fun `SentryNdk propagates app hang tracking options`() { + fixture.getSut( + options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + cacheDirPath = "/cache" + isEnableNdkAppHangTracking = true + ndkAppHangTimeoutIntervalMillis = 2000 + } + ) { + assertNotNull(fixture.capturedOptions) + assertTrue(fixture.capturedOptions!!.isEnableAppHangTracking) + assertEquals(2000L, fixture.capturedOptions!!.appHangTimeoutMillis) + } + } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 5d6df28f7b3..3efee26e37d 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -9,6 +9,7 @@ public final class io/sentry/android/replay/BuildConfig { public class io/sentry/android/replay/DefaultReplayBreadcrumbConverter : io/sentry/ReplayBreadcrumbConverter { public static final field $stable I public fun ()V + public fun (Lio/sentry/SentryOptions;)V public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; } @@ -75,6 +76,8 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun onWindowSizeChanged (II)V public fun pause ()V public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V + public fun registerSegmentName (Ljava/lang/String;)V + public fun registerTraceId (Lio/sentry/protocol/SentryId;)V public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()V diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 60d38c0ae0a..6d03ba771b0 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -1,12 +1,12 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) // TODO: enable it later // alias(libs.plugins.detekt) @@ -27,20 +27,21 @@ android { buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() - useLiveLiterals = false - } + composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } buildTypes { getByName("debug") { consumerProguardFiles("proguard-rules.pro") } getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { @@ -82,6 +83,7 @@ dependencies { testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.test.runner) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.androidx.compose.ui) diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro index 378c0964f8c..6ce45c1ef5d 100644 --- a/sentry-android-replay/proguard-rules.pro +++ b/sentry-android-replay/proguard-rules.pro @@ -26,3 +26,12 @@ -keepnames class com.google.android.exoplayer2.ui.PlayerView -dontwarn com.google.android.exoplayer2.ui.StyledPlayerView -keepnames class com.google.android.exoplayer2.ui.StyledPlayerView +# Rules to detect a PreviewView view to later mask it +-dontwarn androidx.camera.view.PreviewView +-keepnames class androidx.camera.view.PreviewView +# Rules to walk the Compose Node tree. +-keep class androidx.compose.ui.node.LayoutNode { + *** getChildren*(...); + *** getOuterCoordinator*(...); + *** getCollapsedSemantics*(...); +} \ No newline at end of file diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt index 058417ed2a1..5405b33eeac 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt @@ -1,17 +1,33 @@ package io.sentry.android.replay import io.sentry.Breadcrumb +import io.sentry.Hint import io.sentry.ReplayBreadcrumbConverter import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.SentryOptions.BeforeBreadcrumbCallback import io.sentry.SpanDataConvention +import io.sentry.TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebEvent import io.sentry.rrweb.RRWebSpanEvent +import io.sentry.util.network.NetworkRequestData +import java.util.Collections import kotlin.LazyThreadSafetyMode.NONE -public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { +public open class DefaultReplayBreadcrumbConverter() : ReplayBreadcrumbConverter { + private var options: SentryOptions? = null + + public constructor(options: SentryOptions) : this() { + // We modify options, so keep it around to make that explicit. + this.options = options + this.options?.beforeBreadcrumb = ReplayBeforeBreadcrumbCallback(options.beforeBreadcrumb) + } + internal companion object { + private const val MAX_HTTP_NETWORK_DETAILS = 32 private val snakecasePattern by lazy(NONE) { "_[a-z]".toRegex() } + private val supportedNetworkData = HashSet().apply { add("status_code") @@ -23,16 +39,68 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { } } + /** + * Intercept the breadcrumb to process any Network Details data on the hint. Delegate to any + * user-provided callback to provide the actual breadcrumb to process. + */ + private inner class ReplayBeforeBreadcrumbCallback( + private val delegate: BeforeBreadcrumbCallback? + ) : BeforeBreadcrumbCallback { + override fun execute(breadcrumb: Breadcrumb, hint: Hint): Breadcrumb? { + val resultBreadcrumb = + if (delegate != null) { + delegate.execute(breadcrumb, hint) + } else { + breadcrumb + } + + resultBreadcrumb?.let { finalBreadcrumb -> + extractNetworkRequestDataFromHint(finalBreadcrumb, hint)?.let { networkData -> + httpNetworkDetails[finalBreadcrumb] = networkData + } + } + + return resultBreadcrumb + } + + private fun extractNetworkRequestDataFromHint( + breadcrumb: Breadcrumb, + breadcrumbHint: Hint, + ): NetworkRequestData? { + if (breadcrumb.type != "http" && breadcrumb.category != "http") { + return null + } + + return breadcrumbHint.get(SENTRY_REPLAY_NETWORK_DETAILS) as? NetworkRequestData + } + } + private var lastConnectivityState: String? = null + private val httpNetworkDetails = + Collections.synchronizedMap( + object : LinkedHashMap() { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry? + ): Boolean { + return size > MAX_HTTP_NETWORK_DETAILS + } + } + ) + override fun convert(breadcrumb: Breadcrumb): RRWebEvent? { var breadcrumbMessage: String? = null - var breadcrumbCategory: String? = null + val breadcrumbCategory: String? var breadcrumbLevel: SentryLevel? = null val breadcrumbData = mutableMapOf() + when { breadcrumb.category == "http" -> { - return if (breadcrumb.isValidForRRWebSpan()) breadcrumb.toRRWebSpanEvent() else null + return if (breadcrumb.isValidForRRWebSpan()) { + breadcrumb.toRRWebSpanEvent() + } else { + null + } } breadcrumb.type == "navigation" && breadcrumb.category == "app.lifecycle" -> { @@ -42,6 +110,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { breadcrumb.type == "navigation" && breadcrumb.category == "device.orientation" -> { breadcrumbCategory = breadcrumb.category!! val position = breadcrumb.data["position"] + if (position == "landscape" || position == "portrait") { breadcrumbData["position"] = position } else { @@ -53,8 +122,9 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { breadcrumbCategory = "navigation" breadcrumbData["to"] = when { - breadcrumb.data["state"] == "resumed" -> + breadcrumb.data["state"] == "resumed" -> { (breadcrumb.data["screen"] as? String)?.substringAfterLast('.') + } "to" in breadcrumb.data -> breadcrumb.data["to"] as? String else -> null } ?: return null @@ -67,6 +137,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { ?: breadcrumb.data["view.tag"] ?: breadcrumb.data["view.class"]) as? String ?: return null + breadcrumbData.putAll(breadcrumb.data) } @@ -75,18 +146,18 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { breadcrumbData["state"] = when { breadcrumb.data["action"] == "NETWORK_LOST" -> "offline" - "network_type" in breadcrumb.data -> + "network_type" in breadcrumb.data -> { if (!(breadcrumb.data["network_type"] as? String).isNullOrEmpty()) { breadcrumb.data["network_type"] } else { return null } - + } else -> return null } if (lastConnectivityState == breadcrumbData["state"]) { - // debounce same state + // Debounce same state return null } @@ -105,6 +176,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { breadcrumbData.putAll(breadcrumb.data) } } + return if (!breadcrumbCategory.isNullOrEmpty()) { RRWebBreadcrumbEvent().apply { timestamp = breadcrumb.timestamp.time @@ -120,29 +192,34 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { } } - private fun Breadcrumb.isValidForRRWebSpan(): Boolean = - !(data["url"] as? String).isNullOrEmpty() && + private fun Breadcrumb.isValidForRRWebSpan(): Boolean { + return !(data["url"] as? String).isNullOrEmpty() && SpanDataConvention.HTTP_START_TIMESTAMP in data && SpanDataConvention.HTTP_END_TIMESTAMP in data + } - private fun String.snakeToCamelCase(): String = - replace(snakecasePattern) { it.value.last().toString().uppercase() } + private fun String.snakeToCamelCase(): String { + return replace(snakecasePattern) { it.value.last().toString().uppercase() } + } private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent { val breadcrumb = this val httpStartTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] val httpEndTimestamp = breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] + return RRWebSpanEvent().apply { timestamp = breadcrumb.timestamp.time op = "resource.http" description = breadcrumb.data["url"] as String - // can be double if it was serialized to disk + + // Can be double if it was serialized to disk startTimestamp = if (httpStartTimestamp is Double) { httpStartTimestamp / 1000.0 } else { (httpStartTimestamp as Long) / 1000.0 } + endTimestamp = if (httpEndTimestamp is Double) { httpEndTimestamp / 1000.0 @@ -151,13 +228,64 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { } val breadcrumbData = mutableMapOf() + + val networkDetailData = httpNetworkDetails.remove(breadcrumb) + + // Add Network Details data when available + networkDetailData?.let { networkData -> + networkData.method?.let { breadcrumbData["method"] = it } + networkData.statusCode?.let { breadcrumbData["statusCode"] = it } + networkData.requestBodySize?.let { breadcrumbData["requestBodySize"] = it } + networkData.responseBodySize?.let { breadcrumbData["responseBodySize"] = it } + + networkData.request?.let { request -> + val requestData = mutableMapOf() + request.size?.let { requestData["size"] = it } + request.body?.let { + requestData["body"] = it.body + it.warnings?.let { warnings -> + requestData["warnings"] = warnings.map { warning -> warning.value } + } + } + + if (request.headers.isNotEmpty()) { + requestData["headers"] = request.headers + } + + if (requestData.isNotEmpty()) { + breadcrumbData["request"] = requestData + } + } + + networkData.response?.let { response -> + val responseData = mutableMapOf() + response.size?.let { responseData["size"] = it } + response.body?.let { + responseData["body"] = it.body + it.warnings?.let { warnings -> + responseData["warnings"] = warnings.map { warning -> warning.value } + } + } + + if (response.headers.isNotEmpty()) { + responseData["headers"] = response.headers + } + + if (responseData.isNotEmpty()) { + breadcrumbData["response"] = responseData + } + } + } + + // Original breadcrumb http data for ((key, value) in breadcrumb.data) { if (key in supportedNetworkData) { - breadcrumbData[ - key.replace("content_length", "body_size").substringAfter(".").snakeToCamelCase(), - ] = value + val formattedKey = + key.replace("content_length", "body_size").substringAfter(".").snakeToCamelCase() + breadcrumbData[formattedKey] = value } } + data = breadcrumbData } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 72d8b7f29fb..92d4a0c4018 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -22,6 +22,7 @@ import java.io.File import java.io.StringReader import java.util.Date import java.util.LinkedList +import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean /** @@ -79,12 +80,16 @@ public class ReplayCache(private val options: SentryOptions, private val replayI replayCacheDir?.mkdirs() val screenshot = File(replayCacheDir, "$frameTimestamp.jpg").also { it.createNewFile() } - screenshot.outputStream().use { - bitmap.compress(JPEG, options.sessionReplay.quality.screenshotQuality, it) - it.flush() + synchronized(bitmap) { + if (bitmap.isRecycled) { + return + } + screenshot.outputStream().use { + bitmap.compress(JPEG, options.sessionReplay.quality.screenshotQuality, it) + it.flush() + } + addFrame(screenshot, frameTimestamp, screen) } - - addFrame(screenshot, frameTimestamp, screen) } /** @@ -158,7 +163,16 @@ public class ReplayCache(private val options: SentryOptions, private val replayI bitRate = bitRate, ), ) - .also { it.start() } + .apply { + // the constructor already opened the MediaMuxer, so release it if start() fails, + // otherwise the encoder is never assigned and its resources leak (CloseGuard warning) + try { + start() + } catch (t: Throwable) { + release() + throw t + } + } } val step = 1000 / frameRate.toLong() @@ -195,6 +209,10 @@ public class ReplayCache(private val options: SentryOptions, private val replayI if (frameCount == 0) { options.logger.log(DEBUG, "Generated a video with no frames, not capturing a replay segment") + encoderLock.acquire().use { + encoder?.release() + encoder = null + } deleteFile(videoFile) return null } @@ -263,11 +281,29 @@ public class ReplayCache(private val options: SentryOptions, private val replayI } override fun close() { - encoderLock.acquire().use { - encoder?.release() - encoder = null + // close() is called inline from the lifecycle path (ReplayIntegration.stop/close), which holds + // its own lock, so blocking here can freeze the main thread. If the encoder is wedged in a + // native MediaCodec call we'd never get the lock, so we give up instead: the already-dead codec + // is not released (leaking a native handle), which beats an ANR. + try { + val token = encoderLock.tryAcquire(ENCODER_RELEASE_TIMEOUT_MS, MILLISECONDS) + if (token == null) { + options.logger.log( + WARNING, + "Timed out waiting for the video encoder, skipping its release to not block the caller", + ) + } else { + token.use { + encoder?.release() + encoder = null + } + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } finally { + // has to happen on all paths, callers rely on it to stop persisting segment values + isClosed.set(true) } - isClosed.set(true) } // TODO: it's awful, choose a better serialization format @@ -297,6 +333,13 @@ public class ReplayCache(private val options: SentryOptions, private val replayI } internal companion object { + /** + * How long [close] waits for the video encoder to become available. Below Android's ~5s ANR + * budget, and above the encoder's own bail-out (see MAX_EOS_STALL_ITERATIONS), so an encoder + * that's merely slow is still awaited rather than abandoned. + */ + private const val ENCODER_RELEASE_TIMEOUT_MS = 2000L + internal const val ONGOING_SEGMENT = ".ongoing_segment" internal const val SEGMENT_KEY_HEIGHT = "config.height" @@ -309,6 +352,7 @@ public class ReplayCache(private val options: SentryOptions, private val replayI internal const val SEGMENT_KEY_REPLAY_SCREEN_AT_START = "replay.screen-at-start" internal const val SEGMENT_KEY_REPLAY_RECORDING = "replay.recording" internal const val SEGMENT_KEY_ID = "segment.id" + internal const val SEGMENT_KEY_FLUSHED = "replay.flushed" fun makeReplayCacheDir(options: SentryOptions, replayId: SentryId): File? = if (options.cacheDirPath.isNullOrEmpty()) { @@ -407,8 +451,11 @@ public class ReplayCache(private val options: SentryOptions, private val replayI } cache.frames.sortBy { it.timestamp } - // TODO: this should be removed when we start sending buffered segments on next launch - val normalizedSegmentId = if (replayType == SESSION) segmentId else 0 + val wasFlushed = lastSegment[SEGMENT_KEY_FLUSHED]?.toBooleanStrictOrNull() == true + // In buffer mode, if the buffer was never flushed (no error triggered captureReplay), + // no segments were ever sent, so we normalize to 0. After a flush + conversion to + // session mode, the persisted segmentId is the real sequence number. + val normalizedSegmentId = if (replayType == SESSION || wasFlushed) segmentId else 0 val normalizedTimestamp = if (replayType == SESSION) { segmentTimestamp diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 8165385cf20..98333260c7d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -2,11 +2,14 @@ package io.sentry.android.replay import android.content.Context import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Build +import android.os.Looper import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DataCategory.All import io.sentry.DataCategory.Replay +import io.sentry.Hint import io.sentry.IConnectionStatusProvider.ConnectionStatus import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED import io.sentry.IConnectionStatusProvider.IConnectionStatusObserver @@ -17,8 +20,10 @@ import io.sentry.ReplayBreadcrumbConverter import io.sentry.ReplayController import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions +import io.sentry.TypeCheckHint import io.sentry.android.replay.ReplayState.CLOSED import io.sentry.android.replay.ReplayState.PAUSED import io.sentry.android.replay.ReplayState.RESUMED @@ -31,8 +36,8 @@ import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.android.replay.gestures.TouchRecorderCallback import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.ReplayExecutorService import io.sentry.android.replay.util.appContext -import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME @@ -95,6 +100,7 @@ public class ReplayIntegration( this.gestureRecorderProvider = gestureRecorderProvider } + @Volatile private var lastKnownConnectionStatus: ConnectionStatus = ConnectionStatus.UNKNOWN private var debugMaskingEnabled: Boolean = false private lateinit var options: SentryOptions private var scopes: IScopes? = null @@ -102,9 +108,17 @@ public class ReplayIntegration( private var gestureRecorder: GestureRecorder? = null private val random by lazy { Random() } internal val rootViewsSpy by lazy { RootViewsSpy.install() } - private val replayExecutor by lazy { - Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) + internal val lazyReplayExecutor = lazy { + val delegate = Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) + ReplayExecutorService(delegate, options) } + internal val replayExecutor by lazyReplayExecutor + internal val lazyPersistingExecutor = lazy { + val delegate = + Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) + ReplayExecutorService(delegate, options) + } + internal val persistingExecutor by lazyPersistingExecutor internal val isEnabled = AtomicBoolean(false) internal val isManualPause = AtomicBoolean(false) @@ -117,7 +131,7 @@ public class ReplayIntegration( private var replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null private var mainLooperHandler: MainLooperHandler = MainLooperHandler() private var gestureRecorderProvider: (() -> GestureRecorder)? = null - private val lifecycleLock = AutoClosableReentrantLock() + internal val lifecycleLock = AutoClosableReentrantLock() private val lifecycle = ReplayLifecycle() override fun register(scopes: IScopes, options: SentryOptions) { @@ -186,6 +200,7 @@ public class ReplayIntegration( scopes, dateProvider, replayExecutor, + persistingExecutor, replayCacheProvider, ) } else { @@ -195,6 +210,7 @@ public class ReplayIntegration( dateProvider, random, replayExecutor, + persistingExecutor, replayCacheProvider, ) } @@ -218,7 +234,7 @@ public class ReplayIntegration( if ( isManualPause.get() || - options.connectionStatusProvider.connectionStatus == DISCONNECTED || + lastKnownConnectionStatus == DISCONNECTED || scopes?.rateLimiter?.isActiveForCategory(All) == true || scopes?.rateLimiter?.isActiveForCategory(Replay) == true ) { @@ -246,6 +262,7 @@ public class ReplayIntegration( onSegmentSent = { newTimestamp -> captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1 captureStrategy?.segmentTimestamp = newTimestamp + captureStrategy?.isFlushed = true }, ) captureStrategy = captureStrategy?.convert() @@ -274,6 +291,20 @@ public class ReplayIntegration( override fun isDebugMaskingOverlayEnabled(): Boolean = debugMaskingEnabled + override fun registerTraceId(traceId: SentryId) { + if (!isEnabled.get() || !isRecording()) { + return + } + captureStrategy?.registerTraceId(traceId) + } + + override fun registerSegmentName(segmentName: String) { + if (!isEnabled.get() || !isRecording()) { + return + } + captureStrategy?.registerSegmentName(segmentName) + } + private fun pauseInternal() { lifecycleLock.acquire().use { if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) { @@ -306,14 +337,46 @@ public class ReplayIntegration( var screen: String? = null scopes?.configureScope { screen = it.screen?.substringAfterLast('.') } captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> + val observer = options.sessionReplay.frameObserver + if (observer != null) { + val copy = bitmap.copy(bitmap.config!!, false) + if (copy != null) { + try { + val hint = Hint() + hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, copy) + observer.onMaskedFrameCaptured(hint, frameTimeStamp, screen) + } catch (e: Throwable) { + options.logger.log(ERROR, "Error in ReplayFrameObserver", e) + copy.recycle() + } + } + } addFrame(bitmap, frameTimeStamp, screen) } - checkCanRecord() + postOnMainThread { checkCanRecord() } } override fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) { - captureStrategy?.onScreenshotRecorded { _ -> addFrame(screenshot, frameTimestamp) } - checkCanRecord() + var screen: String? = null + scopes?.configureScope { screen = it.screen?.substringAfterLast('.') } + captureStrategy?.onScreenshotRecorded { _ -> + val observer = options.sessionReplay.frameObserver + if (observer != null) { + val bitmap = BitmapFactory.decodeFile(screenshot.absolutePath) + if (bitmap != null) { + try { + val hint = Hint() + hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, bitmap) + observer.onMaskedFrameCaptured(hint, frameTimestamp, screen) + } catch (e: Throwable) { + options.logger.log(ERROR, "Error in ReplayFrameObserver", e) + bitmap.recycle() + } + } + } + addFrame(screenshot, frameTimestamp, screen) + } + postOnMainThread { checkCanRecord() } } override fun close() { @@ -328,12 +391,29 @@ public class ReplayIntegration( recorder?.close() recorder = null rootViewsSpy.close() - replayExecutor.gracefullyShutdown(options) lifecycle.currentState = CLOSED } + // shutdown outside lock — awaiting termination while holding lifecycleLock deadlocks + // if any executor task tries to acquire the same lock + if (lazyReplayExecutor.isInitialized()) { + if (options.threadChecker.isMainThread) { + replayExecutor.gracefulShutdown() + } else { + replayExecutor.shutdown() + } + } + if (lazyPersistingExecutor.isInitialized()) { + if (options.threadChecker.isMainThread) { + persistingExecutor.gracefulShutdown() + } else { + persistingExecutor.shutdown() + } + } } override fun onConnectionStatusChanged(status: ConnectionStatus) { + lastKnownConnectionStatus = status + if (captureStrategy !is SessionCaptureStrategy) { // we only want to stop recording when offline for session mode return @@ -367,6 +447,17 @@ public class ReplayIntegration( captureStrategy?.onTouchEvent(event) } + // Runs [block] on the main thread. If already there, executes inline; otherwise posts via + // the main looper handler. Prevents deadlocks when lifecycle-lock-acquiring code (e.g. + // checkCanRecord -> pauseInternal) is called from the replay executor thread. + private inline fun postOnMainThread(crossinline block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainLooperHandler.post { block() } + } + } + /** * Check if we're offline or rate-limited and pause for session mode to not overflow the envelope * cache. @@ -374,7 +465,7 @@ public class ReplayIntegration( private fun checkCanRecord() { if ( captureStrategy is SessionCaptureStrategy && - (options.connectionStatusProvider.connectionStatus == DISCONNECTED || + (lastKnownConnectionStatus == DISCONNECTED || scopes?.rateLimiter?.isActiveForCategory(All) == true || scopes?.rateLimiter?.isActiveForCategory(Replay) == true) ) { @@ -507,4 +598,14 @@ public class ReplayIntegration( return ret } } + + private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayPersister-" + cnt++) + ret.setDaemon(true) + return ret + } + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 2d866e6a6db..ce987c24ce8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -4,60 +4,52 @@ import android.annotation.SuppressLint import android.annotation.TargetApi import android.content.Context import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Matrix -import android.graphics.Paint -import android.graphics.Rect -import android.graphics.RectF -import android.view.PixelCopy import android.view.View import android.view.ViewTreeObserver +import io.sentry.ScreenshotStrategyType import io.sentry.SentryLevel.DEBUG -import io.sentry.SentryLevel.INFO import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.SentryReplayOptions +import io.sentry.android.replay.screenshot.CanvasStrategy +import io.sentry.android.replay.screenshot.PixelCopyStrategy +import io.sentry.android.replay.screenshot.ScreenshotStrategy import io.sentry.android.replay.util.DebugOverlayDrawable -import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.addOnDrawListenerSafe -import io.sentry.android.replay.util.getVisibleRects import io.sentry.android.replay.util.removeOnDrawListenerSafe -import io.sentry.android.replay.util.submitSafely -import io.sentry.android.replay.util.traverse -import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode -import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode -import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import java.io.File import java.lang.ref.WeakReference -import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.atomic.AtomicBoolean -import kotlin.LazyThreadSafetyMode.NONE import kotlin.math.roundToInt -@SuppressLint("UseKtx") +@SuppressLint("UseKtx", "UseRequiresApi") @TargetApi(26) internal class ScreenshotRecorder( val config: ScreenshotRecorderConfig, val options: SentryOptions, - private val mainLooperHandler: MainLooperHandler, - private val recorder: ScheduledExecutorService, - private val screenshotRecorderCallback: ScreenshotRecorderCallback?, + val executorProvider: ExecutorProvider, + screenshotRecorderCallback: ScreenshotRecorderCallback?, ) : ViewTreeObserver.OnDrawListener { private var rootView: WeakReference? = null - private val maskingPaint by lazy(NONE) { Paint() } - private val singlePixelBitmap: Bitmap by - lazy(NONE) { Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) } - private val screenshot = - Bitmap.createBitmap(config.recordingWidth, config.recordingHeight, Bitmap.Config.ARGB_8888) - private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) } - private val prescaledMatrix by - lazy(NONE) { Matrix().apply { preScale(config.scaleFactorX, config.scaleFactorY) } } - private val contentChanged = AtomicBoolean(false) private val isCapturing = AtomicBoolean(true) - private val lastCaptureSuccessful = AtomicBoolean(false) private val debugOverlayDrawable = DebugOverlayDrawable() + private val contentChanged = AtomicBoolean(false) + + private val screenshotStrategy: ScreenshotStrategy = + when (options.sessionReplay.screenshotStrategy) { + ScreenshotStrategyType.CANVAS -> + CanvasStrategy(executorProvider, screenshotRecorderCallback, options, config) + ScreenshotStrategyType.PIXEL_COPY -> + PixelCopyStrategy( + executorProvider, + screenshotRecorderCallback, + options, + config, + debugOverlayDrawable, + markContentChanged = { contentChanged.set(true) }, + ) + } fun capture() { if (options.sessionReplay.isDebug) { @@ -75,12 +67,12 @@ internal class ScreenshotRecorder( DEBUG, "Capturing screenshot, contentChanged: %s, lastCaptureSuccessful: %s", contentChanged.get(), - lastCaptureSuccessful.get(), + screenshotStrategy.lastCaptureSuccessful(), ) } - if (!contentChanged.get() && lastCaptureSuccessful.get()) { - screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + if (!contentChanged.get()) { + screenshotStrategy.emitLastScreenshot() return } @@ -98,93 +90,9 @@ internal class ScreenshotRecorder( try { contentChanged.set(false) - PixelCopy.request( - window, - screenshot, - { copyResult: Int -> - if (copyResult != PixelCopy.SUCCESS) { - options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) - lastCaptureSuccessful.set(false) - return@request - } - - // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times - // in a row, we should capture) - if (contentChanged.get()) { - options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") - lastCaptureSuccessful.set(false) - return@request - } - - // TODO: disableAllMasking here and dont traverse? - val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) - root.traverse(viewHierarchy, options) - - recorder.submitSafely(options, "screenshot_recorder.mask") { - val debugMasks = mutableListOf() - - val canvas = Canvas(screenshot) - canvas.setMatrix(prescaledMatrix) - viewHierarchy.traverse { node -> - if (node.shouldMask && (node.width > 0 && node.height > 0)) { - node.visibleRect ?: return@traverse false - - // TODO: investigate why it returns true on RN when it shouldn't - // if (viewHierarchy.isObscured(node)) { - // return@traverse true - // } - - val (visibleRects, color) = - when (node) { - is ImageViewHierarchyNode -> { - listOf(node.visibleRect) to screenshot.dominantColorForRect(node.visibleRect) - } - - is TextViewHierarchyNode -> { - val textColor = - node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK - node.layout.getVisibleRects( - node.visibleRect, - node.paddingLeft, - node.paddingTop, - ) to textColor - } - - else -> { - listOf(node.visibleRect) to Color.BLACK - } - } - - maskingPaint.setColor(color) - visibleRects.forEach { rect -> - canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint) - } - if (options.replayController.isDebugMaskingOverlayEnabled()) { - debugMasks.addAll(visibleRects) - } - } - return@traverse true - } - - if (options.replayController.isDebugMaskingOverlayEnabled()) { - mainLooperHandler.post { - if (debugOverlayDrawable.callback == null) { - root.overlay.add(debugOverlayDrawable) - } - debugOverlayDrawable.updateMasks(debugMasks) - root.postInvalidate() - } - } - screenshotRecorderCallback?.onScreenshotRecorded(screenshot) - lastCaptureSuccessful.set(true) - contentChanged.set(false) - } - }, - mainLooperHandler.handler, - ) + screenshotStrategy.capture(root) } catch (e: Throwable) { options.logger.log(WARNING, "Failed to capture replay recording", e) - lastCaptureSuccessful.set(false) } } @@ -199,6 +107,7 @@ internal class ScreenshotRecorder( } contentChanged.set(true) + screenshotStrategy.onContentChanged() } fun bind(root: View) { @@ -212,6 +121,7 @@ internal class ScreenshotRecorder( // invalidate the flag to capture the first frame after new window is attached contentChanged.set(true) + screenshotStrategy.onContentChanged() } fun unbind(root: View?) { @@ -233,30 +143,10 @@ internal class ScreenshotRecorder( } fun close() { + isCapturing.set(false) unbind(rootView?.get()) rootView?.clear() - if (!screenshot.isRecycled) { - screenshot.recycle() - } - isCapturing.set(false) - } - - private fun Bitmap.dominantColorForRect(rect: Rect): Int { - // TODO: maybe this ceremony can be just simplified to - // TODO: multiplying the visibleRect by the prescaledMatrix - val visibleRect = Rect(rect) - val visibleRectF = RectF(visibleRect) - - // since we take screenshot with lower scale, we also - // have to apply the same scale to the visibleRect to get the - // correct screenshot part to determine the dominant color - prescaledMatrix.mapRect(visibleRectF) - // round it back to integer values, because drawBitmap below accepts Rect only - visibleRectF.round(visibleRect) - // draw part of the screenshot (visibleRect) to a single pixel bitmap - singlePixelBitmapCanvas.drawBitmap(this, visibleRect, Rect(0, 0, 1, 1), null) - // get the pixel color (= dominant color) - return singlePixelBitmap.getPixel(0, 0) + screenshotStrategy.close() } } @@ -289,7 +179,7 @@ public data class ScreenshotRecorderConfig( private fun Int.adjustToBlockSize(): Int { val remainder = this % 16 return if (remainder <= 8) { - this - remainder + maxOf(16, this - remainder) } else { this + (16 - remainder) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 5731c4e4f56..19c61900889 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,7 +1,10 @@ package io.sentry.android.replay +import android.annotation.SuppressLint import android.annotation.TargetApi import android.graphics.Point +import android.os.Handler +import android.os.HandlerThread import android.view.View import android.view.ViewTreeObserver import io.sentry.SentryLevel.DEBUG @@ -14,9 +17,11 @@ import io.sentry.android.replay.util.hasSize import io.sentry.android.replay.util.removeOnPreDrawListenerSafe import io.sentry.util.AutoClosableReentrantLock import java.lang.ref.WeakReference +import java.util.WeakHashMap import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.atomic.AtomicBoolean +@SuppressLint("UseRequiresApi") @TargetApi(26) internal class WindowRecorder( private val options: SentryOptions, @@ -24,18 +29,21 @@ internal class WindowRecorder( private val windowCallback: WindowCallback, private val mainLooperHandler: MainLooperHandler, private val replayExecutor: ScheduledExecutorService, -) : Recorder, OnRootViewsChangedListener { - internal companion object { - private const val TAG = "WindowRecorder" - } +) : Recorder, OnRootViewsChangedListener, ExecutorProvider { private val isRecording = AtomicBoolean(false) private val rootViews = ArrayList>() private var lastKnownWindowSize: Point = Point() + private val rootLayoutListeners = WeakHashMap() private val rootViewsLock = AutoClosableReentrantLock() private val capturerLock = AutoClosableReentrantLock() + private val backgroundProcessingHandlerLock = AutoClosableReentrantLock() + @Volatile private var capturer: Capturer? = null + @Volatile private var backgroundProcessingHandlerThread: HandlerThread? = null + @Volatile private var backgroundProcessingHandler: Handler? = null + private class Capturer( private val options: SentryOptions, private val mainLooperHandler: MainLooperHandler, @@ -110,10 +118,17 @@ internal class WindowRecorder( override fun onRootViewsChanged(root: View, added: Boolean) { rootViewsLock.acquire().use { if (added) { + if (root.phoneWindow == null) { + options.logger.log(WARNING, "Root view does not have a phone window, skipping.") + return + } + rootViews.add(WeakReference(root)) capturer?.recorder?.bind(root) determineWindowSize(root) + attachLayoutListener(root) } else { + detachLayoutListener(root) capturer?.recorder?.unbind(root) rootViews.removeAll { it.get() == root } @@ -121,6 +136,7 @@ internal class WindowRecorder( if (newRoot != null && root != newRoot) { capturer?.recorder?.bind(newRoot) determineWindowSize(newRoot) + attachLayoutListener(newRoot) } else { Unit // synchronized block wants us to return something lol } @@ -128,9 +144,45 @@ internal class WindowRecorder( } } + /** + * Activities that handle their own configuration changes (e.g. Unity, video players via + * `android:configChanges="orientation|screenSize|..."`) keep the same root view across rotations, + * so [onRootViewsChanged] never fires and [determineWindowSize] would never re-detect the new + * dimensions. Watch the root for layout-time size changes to catch these cases. + */ + private fun attachLayoutListener(root: View) { + if (rootLayoutListeners.containsKey(root)) return + val listener = + View.OnLayoutChangeListener { + v, + left, + top, + right, + bottom, + oldLeft, + oldTop, + oldRight, + oldBottom -> + val width = right - left + val height = bottom - top + val oldWidth = oldRight - oldLeft + val oldHeight = oldBottom - oldTop + if (width == oldWidth && height == oldHeight) return@OnLayoutChangeListener + // ignore non-latest roots so a dialog stays sized for itself, not its background activity. + if (v != rootViews.lastOrNull()?.get()) return@OnLayoutChangeListener + determineWindowSize(v) + } + rootLayoutListeners[root] = listener + root.addOnLayoutChangeListener(listener) + } + + private fun detachLayoutListener(root: View) { + rootLayoutListeners.remove(root)?.let { root.removeOnLayoutChangeListener(it) } + } + fun determineWindowSize(root: View) { if (root.hasSize()) { - if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { + if (root.width != lastKnownWindowSize.x || root.height != lastKnownWindowSize.y) { lastKnownWindowSize.set(root.width, root.height) windowCallback.onWindowSizeChanged(root.width, root.height) } @@ -146,7 +198,7 @@ internal class WindowRecorder( } if (root.hasSize()) { root.removeOnPreDrawListenerSafe(this) - if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { + if (root.width != lastKnownWindowSize.x || root.height != lastKnownWindowSize.y) { lastKnownWindowSize.set(root.width, root.height) windowCallback.onWindowSizeChanged(root.width, root.height) } @@ -177,14 +229,7 @@ internal class WindowRecorder( } capturer?.config = config - capturer?.recorder = - ScreenshotRecorder( - config, - options, - mainLooperHandler, - replayExecutor, - screenshotRecorderCallback, - ) + capturer?.recorder = ScreenshotRecorder(config, options, this, screenshotRecorderCallback) val newRoot = rootViews.lastOrNull()?.get() if (newRoot != null) { @@ -218,7 +263,13 @@ internal class WindowRecorder( override fun reset() { lastKnownWindowSize.set(0, 0) rootViewsLock.acquire().use { - rootViews.forEach { capturer?.recorder?.unbind(it.get()) } + rootViews.forEach { + val root = it.get() + if (root != null) { + detachLayoutListener(root) + capturer?.recorder?.unbind(root) + } + } rootViews.clear() } } @@ -232,6 +283,40 @@ internal class WindowRecorder( override fun close() { reset() mainLooperHandler.removeCallbacks(capturer) + backgroundProcessingHandlerLock.acquire().use { + backgroundProcessingHandler?.removeCallbacksAndMessages(null) + backgroundProcessingHandlerThread?.quitSafely() + } stop() } + + override fun getExecutor(): ScheduledExecutorService = replayExecutor + + override fun getMainLooperHandler(): MainLooperHandler = mainLooperHandler + + override fun getBackgroundHandler(): Handler { + // only start the background thread if it's actually needed, as it's only used by Canvas Capture + // Strategy + if (backgroundProcessingHandler == null) { + backgroundProcessingHandlerLock.acquire().use { + if (backgroundProcessingHandler == null) { + backgroundProcessingHandlerThread = HandlerThread("SentryReplayBackgroundProcessing") + backgroundProcessingHandlerThread?.start() + backgroundProcessingHandler = Handler(backgroundProcessingHandlerThread!!.looper) + } + } + } + return backgroundProcessingHandler!! + } +} + +internal interface ExecutorProvider { + /** Returns an executor suitable for background tasks. */ + fun getExecutor(): ScheduledExecutorService + + /** Returns a handler associated with the main thread looper. */ + fun getMainLooperHandler(): MainLooperHandler + + /** Returns a handler associated with a background thread looper. */ + fun getBackgroundHandler(): Handler } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 9b9d8f0157b..e81c815dc11 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -83,14 +83,13 @@ internal object WindowSpy { } } - fun pullWindow(maybeDecorView: View): Window? = - decorViewClass?.let { decorViewClass -> - if (decorViewClass.isInstance(maybeDecorView)) { - windowField?.let { windowField -> windowField[maybeDecorView] as Window } - } else { - null - } + fun pullWindow(maybeDecorView: View): Window? = decorViewClass?.let { decorViewClass -> + if (decorViewClass.isInstance(maybeDecorView)) { + windowField?.let { windowField -> windowField[maybeDecorView] as Window } + } else { + null } + } } /** diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 7e76f92aa7e..f505d21a151 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -1,5 +1,6 @@ package io.sentry.android.replay.capture +import android.annotation.SuppressLint import android.annotation.TargetApi import android.view.MotionEvent import io.sentry.Breadcrumb @@ -12,6 +13,7 @@ import io.sentry.SentryReplayEvent.ReplayType.BUFFER import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FLUSHED import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID @@ -24,7 +26,7 @@ import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.gestures.ReplayGestureConverter -import io.sentry.android.replay.util.submitSafely +import io.sentry.android.replay.util.ReplayRunnable import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebEvent import io.sentry.transport.ICurrentDateProvider @@ -32,30 +34,29 @@ import java.io.File import java.util.Date import java.util.Deque import java.util.concurrent.ConcurrentLinkedDeque -import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.ThreadFactory import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty +@SuppressLint("UseRequiresApi") @TargetApi(26) internal abstract class BaseCaptureStrategy( private val options: SentryOptions, private val scopes: IScopes?, private val dateProvider: ICurrentDateProvider, protected val replayExecutor: ScheduledExecutorService, + protected val persistingExecutor: ScheduledExecutorService, private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null, ) : CaptureStrategy { internal companion object { private const val TAG = "CaptureStrategy" + // https://github.com/getsentry/sentry-javascript/blob/30eb68fff5077211c30c61ba74625e66ab514870/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts#L41 + private const val MAX_CONTEXT_VALUES = 100 } - private val persistingExecutor: ScheduledExecutorService by lazy { - Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) - } private val gestureConverter = ReplayGestureConverter(dateProvider) protected val isTerminating = AtomicBoolean(false) @@ -89,8 +90,16 @@ internal abstract class BaseCaptureStrategy( get() = cache?.replayCacheDir override var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) + // Tracks whether the buffer was flushed (segments sent to server). Used by fromDisk() + // to decide whether to normalize the segment ID to 0 on crash recovery: if never flushed, + // no segments reached the server, so the recovered segment must be 0. + override var isFlushed: Boolean by + persistableAtomic(initialValue = false, propertyName = SEGMENT_KEY_FLUSHED) protected val currentEvents: Deque = ConcurrentLinkedDeque() + private val replayContextLock = Any() + private val currentTraceIds: MutableSet = linkedSetOf() + private val currentSegmentNames: MutableSet = linkedSetOf() override fun start(segmentId: Int, replayId: SentryId, replayType: ReplayType?) { cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId) @@ -130,8 +139,15 @@ internal abstract class BaseCaptureStrategy( screenAtStart: String? = this.screenAtStart, breadcrumbs: List? = null, events: Deque = this.currentEvents, - ): ReplaySegment = - createSegment( + ): ReplaySegment { + val (traceIds, segmentNames) = + synchronized(replayContextLock) { + val context = currentTraceIds.toList() to currentSegmentNames.toList() + currentTraceIds.clear() + currentSegmentNames.clear() + context + } + return createSegment( scopes, options, duration, @@ -147,7 +163,10 @@ internal abstract class BaseCaptureStrategy( screenAtStart, breadcrumbs, events, + traceIds, + segmentNames, ) + } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { this.recorderConfig = recorderConfig @@ -162,13 +181,23 @@ internal abstract class BaseCaptureStrategy( } } - private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { - private var cnt = 0 + override fun registerTraceId(traceId: SentryId) { + if (traceId != SentryId.EMPTY_ID) { + synchronized(replayContextLock) { + if (currentTraceIds.size < MAX_CONTEXT_VALUES) { + currentTraceIds.add(traceId.toString()) + } + } + } + } - override fun newThread(r: Runnable): Thread { - val ret = Thread(r, "SentryReplayPersister-" + cnt++) - ret.setDaemon(true) - return ret + override fun registerSegmentName(segmentName: String) { + if (segmentName.isNotEmpty()) { + synchronized(replayContextLock) { + if (currentSegmentNames.size < MAX_CONTEXT_VALUES) { + currentSegmentNames.add(segmentName) + } + } } } @@ -185,7 +214,7 @@ internal abstract class BaseCaptureStrategy( private fun runInBackground(task: () -> Unit) { if (options.threadChecker.isMainThread) { - persistingExecutor.submitSafely(options, "$TAG.runInBackground") { task() } + persistingExecutor.submit(ReplayRunnable("$TAG.runInBackground") { task() }) } else { try { task() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 706a958f3f8..4d7bcd64cf4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -1,8 +1,11 @@ package io.sentry.android.replay.capture +import android.annotation.SuppressLint import android.annotation.TargetApi import android.graphics.Bitmap import android.view.MotionEvent +import io.sentry.DataCategory.All +import io.sentry.DataCategory.Replay import io.sentry.DateUtils import io.sentry.IScopes import io.sentry.SentryLevel.DEBUG @@ -14,8 +17,9 @@ import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.CaptureStrategy.Companion.rotateEvents import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment +import io.sentry.android.replay.util.ReplayRunnable import io.sentry.android.replay.util.sample -import io.sentry.android.replay.util.submitSafely +import io.sentry.clientreport.DiscardReason.RATELIMIT_BACKOFF import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils @@ -24,6 +28,19 @@ import java.io.File import java.util.Date import java.util.concurrent.ScheduledExecutorService +/** + * Records a rolling `errorReplayDuration` window: segments are encoded but held in memory, and + * frames and segments older than the window are dropped on every screenshot. Used when the session + * is not sampled by `sessionSampleRate` but `onErrorSampleRate` is set. + * + * Nothing is sent until [captureReplay] flushes the buffer for an error — sampled per error against + * `onErrorSampleRate`, unlike session mode which samples once at start. After a successful flush + * [convert] hands over to a [SessionCaptureStrategy] so the rest of the session is recorded live. + * + * Since nothing is in flight, `ReplayIntegration` deliberately keeps this strategy recording while + * rate-limited, so the buffer stays warm for when the limit expires. + */ +@SuppressLint("UseRequiresApi") @TargetApi(26) internal class BufferCaptureStrategy( private val options: SentryOptions, @@ -31,6 +48,7 @@ internal class BufferCaptureStrategy( private val dateProvider: ICurrentDateProvider, private val random: Random, executor: ScheduledExecutorService, + persistingExecutor: ScheduledExecutorService, replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null, ) : BaseCaptureStrategy( @@ -38,6 +56,7 @@ internal class BufferCaptureStrategy( scopes, dateProvider, executor, + persistingExecutor, replayCacheProvider = replayCacheProvider, ) { // TODO: capture envelopes for buffered segments instead, but don't send them until buffer is @@ -62,10 +81,12 @@ internal class BufferCaptureStrategy( override fun stop() { val replayCacheDir = cache?.replayCacheDir - replayExecutor.submitSafely(options, "$TAG.stop") { - FileUtils.deleteRecursively(replayCacheDir) - currentSegment = -1 - } + replayExecutor.submit( + ReplayRunnable("$TAG.stop") { + FileUtils.deleteRecursively(replayCacheDir) + currentSegment = -1 + } + ) super.stop() } @@ -94,12 +115,23 @@ internal class BufferCaptureStrategy( return } + if (isReplayRateLimited()) { + // the segment envelopes would be dropped by the transport anyway, so don't waste resources + // encoding videos that will only be discarded + options.logger.log(INFO, "Replay is rate-limited, not capturing for event") + // one lost event per flush, not per segment: the transport would have counted the current + // segment plus every buffered one, but a flush only ever loses a single replay from the + // user's perspective. Under-reporting here is preferable to making replay look like it + // dropped data it never held. + options.clientReportRecorder.recordLostEvent(RATELIMIT_BACKOFF, Replay) + return + } + createCurrentSegment("capture_replay") { segment -> bufferedSegments.capture() if (segment is ReplaySegment.Created) { segment.capture(scopes) - // we only want to increment segment_id in the case of success, but currentSegment // might be irrelevant since we changed strategies, so in the callback we increment // it on the new strategy already @@ -115,14 +147,16 @@ internal class BufferCaptureStrategy( // have to do it before submitting, otherwise if the queue is busy, the timestamp won't be // reflecting the exact time of when it was captured val frameTimestamp = dateProvider.currentTimeMillis - replayExecutor.submitSafely(options, "$TAG.add_frame") { - cache?.store(frameTimestamp) + replayExecutor.submit( + ReplayRunnable("$TAG.add_frame") { + cache?.store(frameTimestamp) - val now = dateProvider.currentTimeMillis - val bufferLimit = now - options.sessionReplay.errorReplayDuration - screenAtStart = cache?.rotate(bufferLimit) - bufferedSegments.rotate(bufferLimit) - } + val now = dateProvider.currentTimeMillis + val bufferLimit = now - options.sessionReplay.errorReplayDuration + screenAtStart = cache?.rotate(bufferLimit) + bufferedSegments.rotate(bufferLimit) + } + ) } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { @@ -144,8 +178,17 @@ internal class BufferCaptureStrategy( ) return this } - // we hand over replayExecutor to the new strategy to preserve order of execution - val captureStrategy = SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor) + if (isReplayRateLimited()) { + // captureReplay skipped the flush, so there is nothing to continue in session mode. Staying + // in buffer mode keeps the rolling buffer warm, so the next error after the rate limit + // expires can send a complete replay starting at segment 0. + options.logger.log(DEBUG, "Not converting to session mode, because replay is rate-limited") + return this + } + // we hand over replayExecutor and persistingExecutor to the new strategy to preserve order of + // execution + val captureStrategy = + SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor, persistingExecutor) captureStrategy.recorderConfig = recorderConfig captureStrategy.start( segmentId = currentSegment, @@ -161,6 +204,11 @@ internal class BufferCaptureStrategy( rotateEvents(currentEvents, bufferLimit) } + private fun isReplayRateLimited(): Boolean = + scopes?.rateLimiter?.let { + it.isActiveForCategory(All) || it.isActiveForCategory(Replay) + } == true + private fun deleteFile(file: File?) { if (file == null) { return @@ -225,19 +273,21 @@ internal class BufferCaptureStrategy( val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId - replayExecutor.submitSafely(options, "$TAG.$taskName") { - val segment = - createSegmentInternal( - duration, - currentSegmentTimestamp, - replayId, - currentSegment, - currentConfig.recordingHeight, - currentConfig.recordingWidth, - currentConfig.frameRate, - currentConfig.bitRate, - ) - onSegmentCreated(segment) - } + replayExecutor.submit( + ReplayRunnable("$TAG.$taskName") { + val segment = + createSegmentInternal( + duration, + currentSegmentTimestamp, + replayId, + currentSegment, + currentConfig.recordingHeight, + currentConfig.recordingWidth, + currentConfig.frameRate, + currentConfig.bitRate, + ) + onSegmentCreated(segment) + } + ) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 8e078161c15..780cdd92481 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -29,6 +29,7 @@ internal interface CaptureStrategy { val replayCacheDir: File? var replayType: ReplayType var segmentTimestamp: Date? + var isFlushed: Boolean fun start(segmentId: Int = 0, replayId: SentryId = SentryId(), replayType: ReplayType? = null) @@ -53,6 +54,10 @@ internal interface CaptureStrategy { fun convert(): CaptureStrategy + fun registerTraceId(traceId: SentryId) + + fun registerSegmentName(segmentName: String) + companion object { private fun Breadcrumb?.isNetworkAvailable(): Boolean = this != null && @@ -84,6 +89,8 @@ internal interface CaptureStrategy { screenAtStart: String?, breadcrumbs: List?, events: Deque, + traceIds: List = emptyList(), + segmentNames: List = emptyList(), ): ReplaySegment { val generatedVideo = cache?.createVideoOf( @@ -122,6 +129,8 @@ internal interface CaptureStrategy { screenAtStart, replayBreadcrumbs, events, + traceIds, + segmentNames, ) } @@ -141,6 +150,8 @@ internal interface CaptureStrategy { screenAtStart: String?, breadcrumbs: List, events: Deque, + traceIds: List, + segmentNames: List, ): ReplaySegment { val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration) val replay = @@ -152,6 +163,8 @@ internal interface CaptureStrategy { this.replayStartTimestamp = segmentTimestamp this.replayType = replayType this.videoFile = video + this.traceIds = traceIds + this.segmentNames = segmentNames } val recordingPayload = mutableListOf() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index cc007d07067..df6e09b5358 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -9,20 +9,40 @@ import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment -import io.sentry.android.replay.util.submitSafely +import io.sentry.android.replay.util.ReplayRunnable import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils import java.util.Date import java.util.concurrent.ScheduledExecutorService +/** + * Records a full session: segments are encoded and sent continuously, one per + * `sessionSegmentDuration`, until the 1h `sessionDuration` deadline. Used when the session is + * sampled by `sessionSampleRate`. + * + * [captureReplay] is a no-op here — there is no buffer to flush, the segment covering the error is + * sent like any other. Because envelopes are in flight the whole time, `ReplayIntegration` pauses + * this strategy while offline or rate-limited so the envelope cache doesn't overflow. + * + * See [BufferCaptureStrategy] for the on-error counterpart. + */ internal class SessionCaptureStrategy( private val options: SentryOptions, private val scopes: IScopes?, private val dateProvider: ICurrentDateProvider, executor: ScheduledExecutorService, + persistingExecutor: ScheduledExecutorService, replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null, -) : BaseCaptureStrategy(options, scopes, dateProvider, executor, replayCacheProvider) { +) : + BaseCaptureStrategy( + options, + scopes, + dateProvider, + executor, + persistingExecutor, + replayCacheProvider, + ) { internal companion object { private const val TAG = "SessionCaptureStrategy" } @@ -79,55 +99,57 @@ internal class SessionCaptureStrategy( // reflecting the exact time of when it was captured val currentConfig = recorderConfig val frameTimestamp = dateProvider.currentTimeMillis - replayExecutor.submitSafely(options, "$TAG.add_frame") { - cache?.store(frameTimestamp) - - val currentSegmentTimestamp = segmentTimestamp - currentSegmentTimestamp - ?: run { - options.logger.log(DEBUG, "Segment timestamp is not set, not recording frame") - return@submitSafely + replayExecutor.submit( + ReplayRunnable("$TAG.add_frame") { + cache?.store(frameTimestamp) + + val currentSegmentTimestamp = segmentTimestamp + currentSegmentTimestamp + ?: run { + options.logger.log(DEBUG, "Segment timestamp is not set, not recording frame") + return@ReplayRunnable + } + + if (isTerminating.get()) { + options.logger.log( + DEBUG, + "Not capturing segment, because the app is terminating, will be captured on next launch", + ) + return@ReplayRunnable } - if (isTerminating.get()) { - options.logger.log( - DEBUG, - "Not capturing segment, because the app is terminating, will be captured on next launch", - ) - return@submitSafely - } - - if (currentConfig == null) { - options.logger.log(DEBUG, "Recorder config is not set, not capturing a segment") - return@submitSafely - } + if (currentConfig == null) { + options.logger.log(DEBUG, "Recorder config is not set, not capturing a segment") + return@ReplayRunnable + } - val now = dateProvider.currentTimeMillis - if ((now - currentSegmentTimestamp.time >= options.sessionReplay.sessionSegmentDuration)) { - val segment = - createSegmentInternal( - options.sessionReplay.sessionSegmentDuration, - currentSegmentTimestamp, - currentReplayId, - currentSegment, - currentConfig.recordingHeight, - currentConfig.recordingWidth, - currentConfig.frameRate, - currentConfig.bitRate, - ) - if (segment is ReplaySegment.Created) { - segment.capture(scopes) - currentSegment++ - // set next segment timestamp as close to the previous one as possible to avoid gaps - segmentTimestamp = segment.replay.timestamp + val now = dateProvider.currentTimeMillis + if ((now - currentSegmentTimestamp.time >= options.sessionReplay.sessionSegmentDuration)) { + val segment = + createSegmentInternal( + options.sessionReplay.sessionSegmentDuration, + currentSegmentTimestamp, + currentReplayId, + currentSegment, + currentConfig.recordingHeight, + currentConfig.recordingWidth, + currentConfig.frameRate, + currentConfig.bitRate, + ) + if (segment is ReplaySegment.Created) { + segment.capture(scopes) + currentSegment++ + // set next segment timestamp as close to the previous one as possible to avoid gaps + segmentTimestamp = segment.replay.timestamp + } } - } - if ((now - replayStartTimestamp.get() >= options.sessionReplay.sessionDuration)) { - options.replayController.stop() - options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") + if ((now - replayStartTimestamp.get() >= options.sessionReplay.sessionDuration)) { + options.replayController.stop() + options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") + } } - } + ) } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { @@ -161,19 +183,21 @@ internal class SessionCaptureStrategy( val currentSegmentTimestamp = segmentTimestamp ?: return val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId - replayExecutor.submitSafely(options, "$TAG.$taskName") { - val segment = - createSegmentInternal( - duration, - currentSegmentTimestamp, - replayId, - currentSegment, - currentConfig.recordingHeight, - currentConfig.recordingWidth, - currentConfig.frameRate, - currentConfig.bitRate, - ) - onSegmentCreated(segment) - } + replayExecutor.submit( + ReplayRunnable("$TAG.$taskName") { + val segment = + createSegmentInternal( + duration, + currentSegmentTimestamp, + replayId, + currentSegment, + currentConfig.recordingHeight, + currentConfig.recordingWidth, + currentConfig.frameRate, + currentConfig.bitRate, + ) + onSegmentCreated(segment) + } + ) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt index 945a0be5156..cee75fb06c0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt @@ -11,6 +11,7 @@ import io.sentry.android.replay.phoneWindow import io.sentry.android.replay.util.FixedWindowCallback import io.sentry.util.AutoClosableReentrantLock import java.lang.ref.WeakReference +import java.util.WeakHashMap internal class GestureRecorder( private val options: SentryOptions, @@ -19,6 +20,11 @@ internal class GestureRecorder( private val rootViews = ArrayList>() private val rootViewsLock = AutoClosableReentrantLock() + // WeakReference value, because the callback chain strongly references the wrapper — a strong + // value would prevent the window from ever being GC'd. + private val wrappedWindows = WeakHashMap>() + private val wrappedWindowsLock = AutoClosableReentrantLock() + override fun onRootViewsChanged(root: View, added: Boolean) { rootViewsLock.acquire().use { if (added) { @@ -45,10 +51,16 @@ internal class GestureRecorder( return } - val delegate = window.callback - if (delegate !is SentryReplayGestureRecorder) { - window.callback = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate) + wrappedWindowsLock.acquire().use { + if (wrappedWindows[window]?.get() != null) { + return + } } + + val delegate = window.callback + val wrapper = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate) + window.callback = wrapper + wrappedWindowsLock.acquire().use { wrappedWindows[window] = WeakReference(wrapper) } } private fun View.stopGestureTracking() { @@ -60,14 +72,25 @@ internal class GestureRecorder( val callback = window.callback if (callback is SentryReplayGestureRecorder) { - val delegate = callback.delegate - window.callback = delegate + window.callback = callback.delegate + wrappedWindowsLock.acquire().use { wrappedWindows.remove(window) } + return + } + + // Another wrapper (e.g. UserInteractionIntegration) sits on top of ours — cutting it out of + // the chain would break its instrumentation, so we inert our buried wrapper instead. The + // next replay session will then wrap on top with a fresh active instance. + val ours: SentryReplayGestureRecorder? + wrappedWindowsLock.acquire().use { + ours = wrappedWindows[window]?.get() + wrappedWindows.remove(window) } + ours?.inert() } internal class SentryReplayGestureRecorder( private val options: SentryOptions, - private val touchRecorderCallback: TouchRecorderCallback?, + @Volatile private var touchRecorderCallback: TouchRecorderCallback?, delegate: Window.Callback?, ) : FixedWindowCallback(delegate) { override fun dispatchTouchEvent(event: MotionEvent?): Boolean { @@ -83,6 +106,14 @@ internal class GestureRecorder( } return super.dispatchTouchEvent(event) } + + /** + * Turns this wrapper into a passthrough when it can't be removed from the chain (another + * wrapper sits on top). Subsequent dispatches only delegate, skipping the recorder callback. + */ + fun inert() { + touchRecorderCallback = null + } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt index 70d0988d3b0..cbedfc24cc2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt @@ -65,11 +65,10 @@ internal class ReplayGestureConverter(private val dateProvider: ICurrentDateProv moveEvents += RRWebInteractionMoveEvent().apply { this.timestamp = now - this.positions = - positions.map { pos -> - pos.timeOffset -= totalOffset - pos - } + this.positions = positions.map { pos -> + pos.timeOffset -= totalOffset + pos + } this.pointerId = pointerId } currentPositions[pointerId]!!.clear() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt new file mode 100644 index 00000000000..c3deefbf71b --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt @@ -0,0 +1,1014 @@ +@file:Suppress("DEPRECATION") + +package io.sentry.android.replay.screenshot + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.BitmapShader +import android.graphics.BlendMode +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.DrawFilter +import android.graphics.Matrix +import android.graphics.Mesh +import android.graphics.NinePatch +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Picture +import android.graphics.PorterDuff +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Region +import android.graphics.RenderNode +import android.graphics.SurfaceTexture +import android.graphics.fonts.Font +import android.graphics.text.MeasuredText +import android.os.Build +import android.os.Handler +import android.view.PixelCopy +import android.view.Surface +import android.view.View +import androidx.annotation.RequiresApi +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.android.replay.ExecutorProvider +import io.sentry.android.replay.ScreenshotRecorderCallback +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.ReplayRunnable +import io.sentry.util.AutoClosableReentrantLock +import io.sentry.util.IntegrationUtils +import java.util.WeakHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.LazyThreadSafetyMode.NONE + +@SuppressLint("NewApi", "UseKtx") +internal class CanvasStrategy( + private val executor: ExecutorProvider, + private val screenshotRecorderCallback: ScreenshotRecorderCallback?, + private val options: SentryOptions, + private val config: ScreenshotRecorderConfig, +) : ScreenshotStrategy { + + @Volatile private var screenshot: Bitmap? = null + private var unprocessedPictureRef = AtomicReference(null) + private val screenshotLock = AutoClosableReentrantLock() + private val prescaledMatrix by + lazy(NONE) { Matrix().apply { preScale(config.scaleFactorX, config.scaleFactorY) } } + private val lastCaptureSuccessful = AtomicBoolean(false) + private val textIgnoringCanvas = TextIgnoringDelegateCanvas() + private val isClosed = AtomicBoolean(false) + + private val surfaceTexture = + SurfaceTexture(false).apply { + setDefaultBufferSize(config.recordingWidth, config.recordingHeight) + } + private val surface = Surface(surfaceTexture) + + init { + IntegrationUtils.addIntegrationToSdkVersion("ReplayCanvasStrategy") + } + + @SuppressLint("NewApi") + private val pictureRenderTask = Runnable { + if (isClosed.get()) { + options.logger.log( + SentryLevel.DEBUG, + "Canvas Strategy already closed, skipping picture render", + ) + return@Runnable + } + val picture = unprocessedPictureRef.getAndSet(null) ?: return@Runnable + try { + // It's safe to access the surface because the + // surface release within close() is executed on the same background handler + val surfaceCanvas = surface.lockHardwareCanvas() + try { + surfaceCanvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR) + picture.draw(surfaceCanvas) + } finally { + surface.unlockCanvasAndPost(surfaceCanvas) + } + + if (screenshot == null) { + screenshotLock.acquire().use { + if (screenshot == null) { + screenshot = + Bitmap.createBitmap( + config.recordingWidth, + config.recordingHeight, + Bitmap.Config.RGB_565, + ) + } + } + } + + if (isClosed.get()) { + options.logger.log( + SentryLevel.DEBUG, + "Canvas Strategy already closed, skipping pixel copy request", + ) + return@Runnable + } + PixelCopy.request( + surface, + screenshot!!, + { result -> + if (isClosed.get()) { + options.logger.log( + SentryLevel.DEBUG, + "CanvasStrategy is closed, ignoring capture result", + ) + return@request + } + if (result == PixelCopy.SUCCESS) { + lastCaptureSuccessful.set(true) + val bitmap = screenshot + if (bitmap != null && !bitmap.isRecycled) { + screenshotRecorderCallback?.onScreenshotRecorded(bitmap) + } + } else { + options.logger.log( + SentryLevel.ERROR, + "Canvas Strategy: PixelCopy failed with code $result", + ) + lastCaptureSuccessful.set(false) + } + }, + executor.getBackgroundHandler(), + ) + } catch (t: Throwable) { + options.logger.log(SentryLevel.ERROR, "Canvas Strategy: picture render failed", t) + lastCaptureSuccessful.set(false) + } + } + + @SuppressLint("NewApi") + override fun capture(root: View) { + if (isClosed.get()) { + return + } + + val picture = Picture() + val canvas = picture.beginRecording(config.recordingWidth, config.recordingHeight) + textIgnoringCanvas.delegate = canvas + textIgnoringCanvas.setMatrix(prescaledMatrix) + root.draw(textIgnoringCanvas) + picture.endRecording() + + if (!isClosed.get()) { + unprocessedPictureRef.set(picture) + executor + .getBackgroundHandler() + .postSafely(ReplayRunnable("screenshot_recorder.canvas", pictureRenderTask)) + } + } + + override fun onContentChanged() { + // ignored + } + + override fun close() { + isClosed.set(true) + executor + .getBackgroundHandler() + .postSafely( + ReplayRunnable("CanvasStrategy.close") { + screenshot?.let { synchronized(it) { if (!it.isRecycled) it.recycle() } } + surface.release() + surfaceTexture.release() + } + ) + unprocessedPictureRef.getAndSet(null) + } + + override fun lastCaptureSuccessful(): Boolean = lastCaptureSuccessful.get() + + override fun emitLastScreenshot() { + if (lastCaptureSuccessful()) { + val bitmap = screenshot + if (bitmap != null && !bitmap.isRecycled) { + screenshotRecorderCallback?.onScreenshotRecorded(bitmap) + } + } + } + + fun Handler.postSafely(runnable: ReplayRunnable) { + try { + post(runnable) + } catch (t: Throwable) { + options.logger.log( + SentryLevel.ERROR, + "Canvas Strategy: failed to post runnable ${runnable.taskName}", + t, + ) + } + } +} + +@SuppressLint("UseKtx") +private class TextIgnoringDelegateCanvas : Canvas() { + + lateinit var delegate: Canvas + private val solidPaint = Paint() + private val textPaint = Paint() + private val tmpRect = Rect() + + val singlePixelBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + val singlePixelCanvas = Canvas(singlePixelBitmap) + + val singlePixelBitmapBounds = Rect(0, 0, 1, 1) + + private val bitmapColorCache = WeakHashMap>() + + override fun isHardwareAccelerated(): Boolean { + return false + } + + override fun setBitmap(bitmap: Bitmap?) { + delegate.setBitmap(bitmap) + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun enableZ() { + delegate.enableZ() + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun disableZ() { + delegate.disableZ() + } + + override fun isOpaque(): Boolean { + return delegate.isOpaque() + } + + override fun getWidth(): Int { + return delegate.width + } + + override fun getHeight(): Int { + return delegate.height + } + + override fun getDensity(): Int { + return delegate.density + } + + override fun setDensity(density: Int) { + delegate.setDensity(density) + } + + override fun getMaximumBitmapWidth(): Int { + return delegate.maximumBitmapWidth + } + + override fun getMaximumBitmapHeight(): Int { + return delegate.maximumBitmapHeight + } + + override fun save(): Int { + val result = delegate.save() + return result + } + + @Suppress("unused") + fun save(saveFlags: Int): Int { + return save() + } + + @Deprecated("Deprecated in Java") + override fun saveLayer(bounds: RectF?, paint: Paint?, saveFlags: Int): Int { + val shader = removeBitmapShader(paint) + val result = delegate.saveLayer(bounds, paint, saveFlags) + shader.let { paint?.shader = it } + return result + } + + override fun saveLayer(bounds: RectF?, paint: Paint?): Int { + val shader = removeBitmapShader(paint) + val result = delegate.saveLayer(bounds, paint) + shader.let { paint?.shader = it } + return result + } + + @Deprecated("Deprecated in Java") + override fun saveLayer( + left: Float, + top: Float, + right: Float, + bottom: Float, + paint: Paint?, + saveFlags: Int, + ): Int { + val shader = removeBitmapShader(paint) + val result = delegate.saveLayer(left, top, right, bottom, paint, saveFlags) + shader.let { paint?.shader = it } + return result + } + + override fun saveLayer(left: Float, top: Float, right: Float, bottom: Float, paint: Paint?): Int { + val shader = removeBitmapShader(paint) + val result = delegate.saveLayer(left, top, right, bottom, paint) + shader.let { paint?.shader = it } + return result + } + + @Deprecated("Deprecated in Java") + override fun saveLayerAlpha(bounds: RectF?, alpha: Int, saveFlags: Int): Int { + return delegate.saveLayerAlpha(bounds, alpha, saveFlags) + } + + override fun saveLayerAlpha(bounds: RectF?, alpha: Int): Int { + return delegate.saveLayerAlpha(bounds, alpha) + } + + @Deprecated("Deprecated in Java") + override fun saveLayerAlpha( + left: Float, + top: Float, + right: Float, + bottom: Float, + alpha: Int, + saveFlags: Int, + ): Int { + return delegate.saveLayerAlpha(left, top, right, bottom, alpha, saveFlags) + } + + override fun saveLayerAlpha( + left: Float, + top: Float, + right: Float, + bottom: Float, + alpha: Int, + ): Int { + return delegate.saveLayerAlpha(left, top, right, bottom, alpha) + } + + override fun restore() { + delegate.restore() + } + + override fun getSaveCount(): Int { + return delegate.saveCount + } + + override fun restoreToCount(saveCount: Int) { + delegate.restoreToCount(saveCount) + } + + override fun translate(dx: Float, dy: Float) { + delegate.translate(dx, dy) + } + + override fun scale(sx: Float, sy: Float) { + delegate.scale(sx, sy) + } + + override fun rotate(degrees: Float) { + delegate.rotate(degrees) + } + + override fun skew(sx: Float, sy: Float) { + delegate.skew(sx, sy) + } + + override fun concat(matrix: Matrix?) { + delegate.concat(matrix) + } + + override fun setMatrix(matrix: Matrix?) { + delegate.setMatrix(matrix) + } + + @Deprecated("Deprecated in Java") + override fun getMatrix(ctm: Matrix) { + delegate.getMatrix(ctm) + } + + @Deprecated("Deprecated in Java") + override fun clipRect(rect: RectF, op: Region.Op): Boolean { + return delegate.clipRect(rect, op) + } + + @Deprecated("Deprecated in Java") + override fun clipRect(rect: Rect, op: Region.Op): Boolean { + return delegate.clipRect(rect, op) + } + + override fun clipRect(rect: RectF): Boolean { + return delegate.clipRect(rect) + } + + override fun clipRect(rect: Rect): Boolean { + return delegate.clipRect(rect) + } + + @Deprecated("Deprecated in Java") + override fun clipRect( + left: Float, + top: Float, + right: Float, + bottom: Float, + op: Region.Op, + ): Boolean { + return delegate.clipRect(left, top, right, bottom, op) + } + + override fun clipRect(left: Float, top: Float, right: Float, bottom: Float): Boolean { + return delegate.clipRect(left, top, right, bottom) + } + + override fun clipRect(left: Int, top: Int, right: Int, bottom: Int): Boolean { + return delegate.clipRect(left, top, right, bottom) + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun clipOutRect(rect: RectF): Boolean { + return delegate.clipOutRect(rect) + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun clipOutRect(rect: Rect): Boolean { + return delegate.clipOutRect(rect) + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun clipOutRect(left: Float, top: Float, right: Float, bottom: Float): Boolean { + return delegate.clipOutRect(left, top, right, bottom) + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun clipOutRect(left: Int, top: Int, right: Int, bottom: Int): Boolean { + return delegate.clipOutRect(left, top, right, bottom) + } + + @Deprecated("Deprecated in Java") + override fun clipPath(path: Path, op: Region.Op): Boolean { + return delegate.clipPath(path, op) + } + + override fun clipPath(path: Path): Boolean { + return delegate.clipPath(path) + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun clipOutPath(path: Path): Boolean { + return delegate.clipOutPath(path) + } + + override fun getDrawFilter(): DrawFilter? { + return delegate.drawFilter + } + + override fun setDrawFilter(filter: DrawFilter?) { + delegate.setDrawFilter(filter) + } + + @Deprecated("Deprecated in Java") + override fun quickReject(rect: RectF, type: EdgeType): Boolean { + return delegate.quickReject(rect, type) + } + + @RequiresApi(Build.VERSION_CODES.R) + override fun quickReject(rect: RectF): Boolean { + return delegate.quickReject(rect) + } + + @Deprecated("Deprecated in Java") + override fun quickReject(path: Path, type: EdgeType): Boolean { + return delegate.quickReject(path, type) + } + + @RequiresApi(Build.VERSION_CODES.R) + override fun quickReject(path: Path): Boolean { + return delegate.quickReject(path) + } + + @Deprecated("Deprecated in Java") + override fun quickReject( + left: Float, + top: Float, + right: Float, + bottom: Float, + type: EdgeType, + ): Boolean { + return delegate.quickReject(left, top, right, bottom, type) + } + + @RequiresApi(Build.VERSION_CODES.R) + override fun quickReject(left: Float, top: Float, right: Float, bottom: Float): Boolean { + return delegate.quickReject(left, top, right, bottom) + } + + override fun getClipBounds(bounds: Rect): Boolean { + return delegate.getClipBounds(bounds) + } + + override fun drawPicture(picture: Picture) { + solidPaint.colorFilter = null + solidPaint.color = Color.BLACK + delegate.drawRect(0f, 0f, picture.width.toFloat(), picture.height.toFloat(), solidPaint) + } + + override fun drawPicture(picture: Picture, dst: RectF) { + solidPaint.colorFilter = null + solidPaint.color = Color.BLACK + delegate.drawRect(dst, solidPaint) + } + + override fun drawPicture(picture: Picture, dst: Rect) { + solidPaint.colorFilter = null + solidPaint.color = Color.BLACK + delegate.drawRect(dst, solidPaint) + } + + override fun drawArc( + oval: RectF, + startAngle: Float, + sweepAngle: Float, + useCenter: Boolean, + paint: Paint, + ) { + val shader = removeBitmapShader(paint) + delegate.drawArc(oval, startAngle, sweepAngle, useCenter, paint) + shader.let { paint.shader = it } + } + + override fun drawArc( + left: Float, + top: Float, + right: Float, + bottom: Float, + startAngle: Float, + sweepAngle: Float, + useCenter: Boolean, + paint: Paint, + ) { + val shader = removeBitmapShader(paint) + delegate.drawArc(left, top, right, bottom, startAngle, sweepAngle, useCenter, paint) + shader.let { paint.shader = it } + } + + override fun drawARGB(a: Int, r: Int, g: Int, b: Int) { + delegate.drawARGB(a, r, g, b) + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun drawBitmap(bitmap: Bitmap, left: Float, top: Float, paint: Paint?) { + val sampledColor = sampleBitmapColor(bitmap, paint, null) + solidPaint.setColor(sampledColor) + solidPaint.colorFilter = null + delegate.drawRect(left, top, left + bitmap.width, top + bitmap.height, solidPaint) + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun drawBitmap(bitmap: Bitmap, src: Rect?, dst: RectF, paint: Paint?) { + val sampledColor = sampleBitmapColor(bitmap, paint, src) + solidPaint.setColor(sampledColor) + solidPaint.colorFilter = null + delegate.drawRect(dst, solidPaint) + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun drawBitmap(bitmap: Bitmap, src: Rect?, dst: Rect, paint: Paint?) { + val sampledColor = sampleBitmapColor(bitmap, paint, src) + solidPaint.setColor(sampledColor) + solidPaint.colorFilter = null + delegate.drawRect(dst, solidPaint) + } + + @Deprecated("Deprecated in Java") + override fun drawBitmap( + colors: IntArray, + offset: Int, + stride: Int, + x: Float, + y: Float, + width: Int, + height: Int, + hasAlpha: Boolean, + paint: Paint?, + ) { + // not supported + } + + @Deprecated("Deprecated in Java") + override fun drawBitmap( + colors: IntArray, + offset: Int, + stride: Int, + x: Int, + y: Int, + width: Int, + height: Int, + hasAlpha: Boolean, + paint: Paint?, + ) { + // not supported + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun drawBitmap(bitmap: Bitmap, matrix: Matrix, paint: Paint?) { + val sampledColor = sampleBitmapColor(bitmap, paint, null) + solidPaint.setColor(sampledColor) + solidPaint.colorFilter = null + + val count = delegate.save() + delegate.setMatrix(matrix) + delegate.drawRect(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat(), solidPaint) + delegate.restoreToCount(count) + } + + override fun drawBitmapMesh( + bitmap: Bitmap, + meshWidth: Int, + meshHeight: Int, + verts: FloatArray, + vertOffset: Int, + colors: IntArray?, + colorOffset: Int, + paint: Paint?, + ) { + // not supported + } + + override fun drawCircle(cx: Float, cy: Float, radius: Float, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawCircle(cx, cy, radius, paint) + shader.let { paint.shader = it } + } + + override fun drawColor(color: Int) { + delegate.drawColor(color) + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun drawColor(color: Long) { + delegate.drawColor(color) + } + + override fun drawColor(color: Int, mode: PorterDuff.Mode) { + delegate.drawColor(color, mode) + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun drawColor(color: Int, mode: BlendMode) { + delegate.drawColor(color, mode) + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun drawColor(color: Long, mode: BlendMode) { + delegate.drawColor(color, mode) + } + + override fun drawLine(startX: Float, startY: Float, stopX: Float, stopY: Float, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawLine(startX, startY, stopX, stopY, paint) + shader.let { paint.shader = it } + } + + override fun drawLines(pts: FloatArray, offset: Int, count: Int, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawLines(pts, offset, count, paint) + shader.let { paint.shader = it } + } + + override fun drawLines(pts: FloatArray, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawLines(pts, paint) + shader.let { paint.shader = it } + } + + override fun drawOval(oval: RectF, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawOval(oval, paint) + shader.let { paint.shader = it } + } + + override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawOval(left, top, right, bottom, paint) + shader.let { paint.shader = it } + } + + override fun drawPaint(paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawPaint(paint) + shader.let { paint.shader = it } + } + + @RequiresApi(Build.VERSION_CODES.S) + override fun drawPatch(patch: NinePatch, dst: Rect, paint: Paint?) { + val shader = removeBitmapShader(paint) + delegate.drawPatch(patch, dst, paint) + shader.let { paint?.shader = it } + } + + @RequiresApi(Build.VERSION_CODES.S) + override fun drawPatch(patch: NinePatch, dst: RectF, paint: Paint?) { + val shader = removeBitmapShader(paint) + delegate.drawPatch(patch, dst, paint) + shader.let { paint?.shader = it } + } + + override fun drawPath(path: Path, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawPath(path, paint) + shader.let { paint.shader = it } + } + + override fun drawPoint(x: Float, y: Float, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawPoint(x, y, paint) + shader.let { paint.shader = it } + } + + override fun drawPoints(pts: FloatArray?, offset: Int, count: Int, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawPoints(pts, offset, count, paint) + shader.let { paint.shader = it } + } + + override fun drawPoints(pts: FloatArray, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawPoints(pts, paint) + shader.let { paint.shader = it } + } + + override fun drawRect(rect: RectF, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawRect(rect, paint) + shader.let { paint.shader = it } + } + + override fun drawRect(r: Rect, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawRect(r, paint) + shader.let { paint.shader = it } + } + + override fun drawRect(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawRect(left, top, right, bottom, paint) + shader.let { paint.shader = it } + } + + override fun drawRGB(r: Int, g: Int, b: Int) { + delegate.drawRGB(r, g, b) + } + + override fun drawRoundRect(rect: RectF, rx: Float, ry: Float, paint: Paint) { + val shader = removeBitmapShader(paint) + delegate.drawRoundRect(rect, rx, ry, paint) + shader.let { paint.shader = it } + } + + override fun drawRoundRect( + left: Float, + top: Float, + right: Float, + bottom: Float, + rx: Float, + ry: Float, + paint: Paint, + ) { + val shader = removeBitmapShader(paint) + delegate.drawRoundRect(left, top, right, bottom, rx, ry, paint) + shader.let { paint.shader = it } + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun drawDoubleRoundRect( + outer: RectF, + outerRx: Float, + outerRy: Float, + inner: RectF, + innerRx: Float, + innerRy: Float, + paint: Paint, + ) { + val shader = removeBitmapShader(paint) + delegate.drawDoubleRoundRect(outer, outerRx, outerRy, inner, innerRx, innerRy, paint) + shader.let { paint.shader = it } + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun drawDoubleRoundRect( + outer: RectF, + outerRadii: FloatArray, + inner: RectF, + innerRadii: FloatArray, + paint: Paint, + ) { + val shader = removeBitmapShader(paint) + delegate.drawDoubleRoundRect(outer, outerRadii, inner, innerRadii, paint) + shader.let { paint.shader = it } + } + + override fun drawGlyphs( + glyphIds: IntArray, + glyphIdOffset: Int, + positions: FloatArray, + positionOffset: Int, + glyphCount: Int, + font: Font, + paint: Paint, + ) { + // not supported + } + + override fun drawVertices( + mode: VertexMode, + vertexCount: Int, + verts: FloatArray, + vertOffset: Int, + texs: FloatArray?, + texOffset: Int, + colors: IntArray?, + colorOffset: Int, + indices: ShortArray?, + indexOffset: Int, + indexCount: Int, + paint: Paint, + ) { + // not supported + } + + override fun drawRenderNode(renderNode: RenderNode) { + // not supported + } + + override fun drawMesh(mesh: Mesh, blendMode: BlendMode?, paint: Paint) { + // not supported + } + + @Deprecated("Deprecated in Java") + override fun drawPosText(text: CharArray, index: Int, count: Int, pos: FloatArray, paint: Paint) { + // not supported + } + + @Deprecated("Deprecated in Java") + override fun drawPosText(text: String, pos: FloatArray, paint: Paint) { + // not supported + } + + override fun drawText(text: CharArray, index: Int, count: Int, x: Float, y: Float, paint: Paint) { + paint.getTextBounds(text, index, count, tmpRect) + drawMaskedText(paint, x, y) + } + + override fun drawText(text: String, x: Float, y: Float, paint: Paint) { + paint.getTextBounds(text, 0, text.length, tmpRect) + drawMaskedText(paint, x, y) + } + + override fun drawText(text: String, start: Int, end: Int, x: Float, y: Float, paint: Paint) { + paint.getTextBounds(text, start, end, tmpRect) + drawMaskedText(paint, x, y) + } + + override fun drawText( + text: CharSequence, + start: Int, + end: Int, + x: Float, + y: Float, + paint: Paint, + ) { + paint.getTextBounds(text.toString(), 0, text.length, tmpRect) + drawMaskedText(paint, x, y) + } + + override fun drawTextOnPath( + text: CharArray, + index: Int, + count: Int, + path: Path, + hOffset: Float, + vOffset: Float, + paint: Paint, + ) { + // not supported + } + + override fun drawTextOnPath( + text: String, + path: Path, + hOffset: Float, + vOffset: Float, + paint: Paint, + ) { + // not supported + } + + override fun drawTextRun( + text: CharArray, + index: Int, + count: Int, + contextIndex: Int, + contextCount: Int, + x: Float, + y: Float, + isRtl: Boolean, + paint: Paint, + ) { + paint.getTextBounds(text, 0, index + count, tmpRect) + drawMaskedText(paint, x, y) + } + + override fun drawTextRun( + text: CharSequence, + start: Int, + end: Int, + contextStart: Int, + contextEnd: Int, + x: Float, + y: Float, + isRtl: Boolean, + paint: Paint, + ) { + paint.getTextBounds(text.toString(), start, end, tmpRect) + drawMaskedText(paint, x, y) + } + + override fun drawTextRun( + text: MeasuredText, + start: Int, + end: Int, + contextStart: Int, + contextEnd: Int, + x: Float, + y: Float, + isRtl: Boolean, + paint: Paint, + ) { + paint.getTextBounds(text.toString(), start, end, tmpRect) + drawMaskedText(paint, x, y) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun sampleBitmapColor(bitmap: Bitmap, paint: Paint?, src: Rect?): Int { + if (bitmap.isRecycled) { + return Color.BLACK + } + + val cache = bitmapColorCache[bitmap] + if (cache != null && cache.first == bitmap.generationId) { + return cache.second + } else { + val color = + if ( + bitmap.config == Bitmap.Config.HARDWARE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + ) { + // bitmap.asShared() ensures that the bitmap, even if it is hardware bitmap, + // can be drawn onto the single pixel software canvas + val shader = removeBitmapShader(paint) + singlePixelCanvas.drawBitmap(bitmap.asShared(), src, singlePixelBitmapBounds, paint) + shader?.let { paint?.shader = it } + singlePixelBitmap.getPixel(0, 0) + } else if (bitmap.config != Bitmap.Config.HARDWARE) { + // fallback for older android versions + val shader = removeBitmapShader(paint) + singlePixelCanvas.drawBitmap(bitmap, src, singlePixelBitmapBounds, paint) + shader?.let { paint?.shader = it } + singlePixelBitmap.getPixel(0, 0) + } else { + // fallback for older android versions + Color.BLACK + } + bitmapColorCache[bitmap] = Pair(bitmap.generationId, color) + return color + } + } + + private fun drawMaskedText(paint: Paint, x: Float, y: Float) { + textPaint.colorFilter = paint.colorFilter + val color = paint.color + textPaint.color = Color.argb(100, Color.red(color), Color.green(color), Color.blue(color)) + drawRoundRect( + tmpRect.left.toFloat() + x, + tmpRect.top.toFloat() + y, + tmpRect.right.toFloat() + x, + tmpRect.bottom.toFloat() + y, + 10f, + 10f, + textPaint, + ) + } + + /** Removes the bitmap shader from a paint, returning it so it can be restored later. */ + private fun removeBitmapShader(paint: Paint?): BitmapShader? { + return if (paint == null) { + null + } else { + val shader = paint.shader + if (shader is BitmapShader) { + paint.shader = null + shader + } else { + null + } + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt new file mode 100644 index 00000000000..06be58e19de --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -0,0 +1,478 @@ +package io.sentry.android.replay.screenshot + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import android.graphics.RectF +import android.view.PixelCopy +import android.view.View +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO +import io.sentry.SentryLevel.WARNING +import io.sentry.SentryOptions +import io.sentry.android.replay.ExecutorProvider +import io.sentry.android.replay.ScreenshotRecorderCallback +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.phoneWindow +import io.sentry.android.replay.util.DebugOverlayDrawable +import io.sentry.android.replay.util.MaskRenderer +import io.sentry.android.replay.util.ReplayRunnable +import io.sentry.android.replay.util.traverse +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.LazyThreadSafetyMode.NONE + +@SuppressLint("UseKtx") +internal class PixelCopyStrategy( + executorProvider: ExecutorProvider, + private val screenshotRecorderCallback: ScreenshotRecorderCallback?, + private val options: SentryOptions, + private val config: ScreenshotRecorderConfig, + private val debugOverlayDrawable: DebugOverlayDrawable, + // Lets the strategy re-arm the recorder's contentChanged gate so frames keep being captured + // when SurfaceViews are present (their redraws don't trigger ViewTreeObserver.OnDrawListener). + private val markContentChanged: () -> Unit = {}, +) : ScreenshotStrategy { + + private companion object { + /** + * An unstable capture means the view hierarchy changed while PixelCopy was in flight. Cap + * skipped unstable captures so continuous animations don't stop replay recording. + */ + const val MAX_UNSTABLE_CAPTURES_TO_SKIP = 1 + } + + private val executor = executorProvider.getExecutor() + private val mainLooperHandler = executorProvider.getMainLooperHandler() + private val screenshot = + Bitmap.createBitmap( + config.recordingWidth, + config.recordingHeight, + if (options.sessionReplay.isCaptureSurfaceViews) { + Bitmap.Config.ARGB_8888 + } else { + Bitmap.Config.RGB_565 + }, + ) + private val prescaledMatrix by + lazy(NONE) { Matrix().apply { preScale(config.scaleFactorX, config.scaleFactorY) } } + private val lastCaptureSuccessful = AtomicBoolean(false) + private val maskRenderer = MaskRenderer() + private val contentChanged = AtomicBoolean(false) + private val unstableCaptures = AtomicInteger(0) + private val isClosed = AtomicBoolean(false) + private val frameInFlight = AtomicBoolean(false) + private val dstOverPaint by + lazy(NONE) { Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } } + private val screenshotCanvas by lazy(NONE) { Canvas(screenshot) } + private val tmpSrcRect = Rect() + private val tmpDstRect = RectF() + private val windowLocation = IntArray(2) + private val svLocation = IntArray(2) + + private class SurfaceViewCapture(val bitmap: Bitmap, val x: Int, val y: Int) + + @SuppressLint("NewApi") + override fun capture(root: View) { + val window = root.phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window is invalid, not capturing screenshot") + return + } + + if (!frameInFlight.compareAndSet(false, true)) { + options.logger.log(DEBUG, "PixelCopyStrategy capture is already in flight, skipping") + markContentChanged() + return + } + + if (isClosed.get()) { + options.logger.log(DEBUG, "PixelCopyStrategy is closed, not capturing screenshot") + finishFrame() + return + } + + try { + contentChanged.set(false) + PixelCopy.request( + window, + screenshot, + { copyResult: Int -> + if (isClosed.get()) { + options.logger.log(DEBUG, "PixelCopyStrategy is closed, ignoring capture result") + finishFrame() + return@request + } + + if (copyResult != PixelCopy.SUCCESS) { + options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) + unstableCaptures.set(0) + lastCaptureSuccessful.set(false) + finishFrame() + return@request + } + + val changedDuringCapture = contentChanged.get() + if (changedDuringCapture && shouldSkipUnstableCapture()) { + finishFrame() + return@request + } + + // Release the frame gate if anything below throws before we hand work off to the + // executor — otherwise a single failure wedges captures forever. + try { + // TODO: disableAllMasking here and dont traverse? + val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay) + val surfaceViewNodes = + if (options.sessionReplay.isCaptureSurfaceViews) { + mutableListOf() + } else { + null + } + root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes) + + if (surfaceViewNodes.isNullOrEmpty()) { + val submitted = + executor.submit( + ReplayRunnable("screenshot_recorder.mask") { + try { + applyMaskingAndNotify( + root, + viewHierarchy, + resetUnstableCaptures = !changedDuringCapture, + ) + } finally { + finishFrame() + } + } + ) + if (submitted == null) { + finishFrame() + } + } else { + // Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger + // ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever. + markContentChanged() + captureSurfaceViews( + root, + surfaceViewNodes, + viewHierarchy, + resetUnstableCaptures = !changedDuringCapture, + ) + } + } catch (e: RuntimeException) { + // OEM View subclasses have been observed throwing during hierarchy traversal + // (e.g. Redmi's TextView NPE). Release the frame gate so a single bad frame + // doesn't wedge the recorder. Errors (OOM, LinkageError) intentionally propagate. + options.logger.log(WARNING, "Failed to process replay frame", e) + finishFrame() + } + }, + mainLooperHandler.handler, + ) + } catch (e: Throwable) { + options.logger.log(WARNING, "Failed to capture replay recording", e) + unstableCaptures.set(0) + lastCaptureSuccessful.set(false) + finishFrame() + } + } + + private fun shouldSkipUnstableCapture(): Boolean { + if (unstableCaptures.incrementAndGet() <= MAX_UNSTABLE_CAPTURES_TO_SKIP) { + options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") + lastCaptureSuccessful.set(false) + return true + } + return false + } + + private fun applyMaskingAndNotify( + root: View, + viewHierarchy: ViewHierarchyNode, + resetUnstableCaptures: Boolean, + ) { + if (isClosed.get() || screenshot.isRecycled) { + options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping masking") + return + } + + val debugMasks = maskRenderer.renderMasks(screenshot, viewHierarchy, prescaledMatrix) + + if (options.replayController.isDebugMaskingOverlayEnabled()) { + mainLooperHandler.post { + if (debugOverlayDrawable.callback == null) { + root.overlay.add(debugOverlayDrawable) + } + debugOverlayDrawable.updateMasks(debugMasks) + root.postInvalidate() + } + } + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + lastCaptureSuccessful.set(true) + contentChanged.set(false) + if (resetUnstableCaptures) { + unstableCaptures.set(0) + } + } + + @SuppressLint("NewApi") + private fun captureSurfaceViews( + root: View, + surfaceViewNodes: List, + viewHierarchy: ViewHierarchyNode, + resetUnstableCaptures: Boolean, + ) { + // Snapshot the window location into locals so the executor-side compositor reads stable + // values even if a new capture cycle starts and overwrites the field. + root.getLocationOnScreen(windowLocation) + val windowX = windowLocation[0] + val windowY = windowLocation[1] + + val captures = arrayOfNulls(surfaceViewNodes.size) + val remaining = AtomicInteger(surfaceViewNodes.size) + + fun onCaptureComplete() { + if (remaining.decrementAndGet() == 0) { + compositeSurfaceViewsAndMask( + root, + captures, + viewHierarchy, + windowX, + windowY, + resetUnstableCaptures, + ) + } + } + + for ((index, node) in surfaceViewNodes.withIndex()) { + val surfaceView = node.surfaceViewRef.get() + // holder.surface can be null before the surface is created — guard against NPE. + val surface = surfaceView?.holder?.surface + if (surfaceView == null || surface == null || !surface.isValid) { + onCaptureComplete() + continue + } + + var svBitmap: Bitmap? = null + try { + svBitmap = + Bitmap.createBitmap(surfaceView.width, surfaceView.height, Bitmap.Config.ARGB_8888) + val bitmapToCapture = svBitmap + + surfaceView.getLocationOnScreen(svLocation) + val capturedX = svLocation[0] + val capturedY = svLocation[1] + + PixelCopy.request( + surfaceView, + bitmapToCapture, + { copyResult: Int -> + if (isClosed.get()) { + bitmapToCapture.recycle() + // still drive the completion latch so any prior captures get recycled by the + // composite step's early-return path. + onCaptureComplete() + return@request + } + if (copyResult == PixelCopy.SUCCESS) { + captures[index] = SurfaceViewCapture(bitmapToCapture, capturedX, capturedY) + } else { + bitmapToCapture.recycle() + options.logger.log(INFO, "Failed to capture SurfaceView: %d", copyResult) + } + onCaptureComplete() + }, + mainLooperHandler.handler, + ) + // Ownership transferred to the PixelCopy callback — clear local so catch doesn't + // double-recycle if the recycle paths above already ran. + svBitmap = null + } catch (e: Throwable) { + options.logger.log(WARNING, "Failed to capture SurfaceView", e) + svBitmap?.recycle() + onCaptureComplete() + } + } + } + + private fun compositeSurfaceViewsAndMask( + root: View, + captures: Array, + viewHierarchy: ViewHierarchyNode, + windowX: Int, + windowY: Int, + resetUnstableCaptures: Boolean, + ) { + val submitted = + executor.submit( + ReplayRunnable("screenshot_recorder.composite") { + try { + if (isClosed.get() || screenshot.isRecycled) { + options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping compositing") + recycleCaptures(captures) + return@ReplayRunnable + } + + for (capture in captures) { + if (capture == null) continue + if (capture.bitmap.isRecycled) continue + + compositeSurfaceViewInto( + screenshotCanvas, + dstOverPaint, + tmpSrcRect, + tmpDstRect, + capture.bitmap, + capture.x, + capture.y, + windowX, + windowY, + config.scaleFactorX, + config.scaleFactorY, + ) + capture.bitmap.recycle() + } + + applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures) + } finally { + finishFrame() + } + } + ) + if (submitted == null) { + recycleCaptures(captures) + finishFrame() + } + } + + private fun recycleCaptures(captures: Array) { + for (capture in captures) { + if (capture != null && !capture.bitmap.isRecycled) { + capture.bitmap.recycle() + } + } + } + + override fun onContentChanged() { + contentChanged.set(true) + } + + override fun lastCaptureSuccessful(): Boolean { + return lastCaptureSuccessful.get() + } + + override fun emitLastScreenshot() { + if (!frameInFlight.compareAndSet(false, true)) { + return + } + if (!lastCaptureSuccessful() || screenshot.isRecycled) { + finishFrame() + return + } + // Submit to the executor so the downstream consumer's bitmap read (JPEG compress) runs inline + // on the worker thread while the gate is held, same as the masked capture path. + val submitted = + executor.submit( + ReplayRunnable("PixelCopyStrategy.emit") { + try { + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + } finally { + finishFrame() + } + } + ) + if (submitted == null) { + finishFrame() + } + } + + override fun close() { + isClosed.set(true) + unstableCaptures.set(0) + cleanUpIfIdle() + } + + private fun finishFrame() { + frameInFlight.set(false) + if (isClosed.get()) { + cleanUpIfIdle() + } + } + + /** + * Schedules cleanup only for the caller that owns the gate. Whoever wins [frameInFlight]'s CAS + * (close when no frame is running, or the finishFrame of the last in-flight frame after close) + * runs cleanup exactly once; a racing capture that took the gate loses the CAS and backs off, so + * we never recycle the shared screenshot while that capture is still using it. + */ + private fun cleanUpIfIdle() { + if (frameInFlight.compareAndSet(false, true)) { + scheduleCleanup() + } + } + + private fun scheduleCleanup() { + val cleanup = + ReplayRunnable( + "PixelCopyStrategy.close", + { + if (!screenshot.isRecycled) { + synchronized(screenshot) { + if (!screenshot.isRecycled) { + screenshot.recycle() + } + } + } + maskRenderer.close() + }, + ) + // ReplayExecutorService.submit returns null only on genuine rejection (post-shutdown); + // inline execution on the worker thread returns a completed future. Fall back to running + // cleanup here so the bitmap + mask renderer are freed even when the executor is dead. + if (executor.submit(cleanup) == null) { + cleanup.run() + } + } +} + +/** + * Composites [sourceBitmap] (a SurfaceView capture) onto [destCanvas] (wrapping the recording + * screenshot) using [destPaint] (expected to have DST_OVER xfermode), so the SurfaceView content + * draws _behind_ existing Window content — filling the transparent holes the Window PixelCopy + * leaves where SurfaceViews are. + * + * Extracted for testability — the compositing is pure drawing logic that can be driven with + * hand-built bitmaps, while the surrounding [PixelCopyStrategy.captureSurfaceViews] flow depends on + * a real SurfaceView producer that Robolectric cannot provide. + */ +internal fun compositeSurfaceViewInto( + destCanvas: Canvas, + destPaint: Paint, + tmpSrc: Rect, + tmpDst: RectF, + sourceBitmap: Bitmap, + sourceX: Int, + sourceY: Int, + windowX: Int, + windowY: Int, + scaleFactorX: Float, + scaleFactorY: Float, +) { + val left = (sourceX - windowX) * scaleFactorX + val top = (sourceY - windowY) * scaleFactorY + tmpSrc.set(0, 0, sourceBitmap.width, sourceBitmap.height) + tmpDst.set( + left, + top, + left + sourceBitmap.width * scaleFactorX, + top + sourceBitmap.height * scaleFactorY, + ) + destCanvas.drawBitmap(sourceBitmap, tmpSrc, tmpDst, destPaint) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt new file mode 100644 index 00000000000..a7b2334ea77 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/ScreenshotStrategy.kt @@ -0,0 +1,15 @@ +package io.sentry.android.replay.screenshot + +import android.view.View + +internal interface ScreenshotStrategy { + fun capture(root: View) + + fun onContentChanged() + + fun close() + + fun lastCaptureSuccessful(): Boolean + + fun emitLastScreenshot() +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt index 504280eb2aa..a5dddc3bd4d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt @@ -1,31 +1,9 @@ package io.sentry.android.replay.util -import android.annotation.SuppressLint import io.sentry.ISentryExecutorService import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions -import java.util.concurrent.ExecutorService import java.util.concurrent.Future -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeUnit.MILLISECONDS - -internal fun ExecutorService.gracefullyShutdown(options: SentryOptions) { - synchronized(this) { - if (!isShutdown) { - shutdown() - } - try { - if (!awaitTermination(options.shutdownTimeoutMillis, MILLISECONDS)) { - shutdownNow() - } - } catch (e: InterruptedException) { - shutdownNow() - Thread.currentThread().interrupt() - } - } -} internal fun ISentryExecutorService.submitSafely( options: SentryOptions, @@ -44,54 +22,3 @@ internal fun ISentryExecutorService.submitSafely( options.logger.log(ERROR, "Failed to submit task $taskName to executor", e) null } - -internal fun ExecutorService.submitSafely( - options: SentryOptions, - taskName: String, - task: Runnable, -): Future<*>? { - if (Thread.currentThread().name.startsWith("SentryReplayIntegration")) { - // we're already on the worker thread, no need to submit - task.run() - return null - } - return try { - submit { - try { - task.run() - } catch (e: Throwable) { - options.logger.log(ERROR, "Failed to execute task $taskName", e) - } - } - } catch (e: Throwable) { - options.logger.log(ERROR, "Failed to submit task $taskName to executor", e) - null - } -} - -@SuppressLint("DiscouragedApi") -internal fun ScheduledExecutorService.scheduleAtFixedRateSafely( - options: SentryOptions, - taskName: String, - initialDelay: Long, - period: Long, - unit: TimeUnit, - task: Runnable, -): ScheduledFuture<*>? = - try { - scheduleAtFixedRate( - { - try { - task.run() - } catch (e: Throwable) { - options.logger.log(ERROR, "Failed to execute task $taskName", e) - } - }, - initialDelay, - period, - unit, - ) - } catch (e: Throwable) { - options.logger.log(ERROR, "Failed to submit task $taskName to executor", e) - null - } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MaskRenderer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MaskRenderer.kt new file mode 100644 index 00000000000..5e650c9f958 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MaskRenderer.kt @@ -0,0 +1,118 @@ +package io.sentry.android.replay.util + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import java.io.Closeable +import kotlin.LazyThreadSafetyMode.NONE + +/** + * Shared utility for rendering masks on bitmaps based on view hierarchy. Used by both Session + * Replay (PixelCopyStrategy) and Screenshot masking. + */ +@SuppressLint("UseKtx") +internal class MaskRenderer : Closeable { + + private companion object { + private const val MASK_CORNER_RADIUS = 10f + } + + // Single pixel bitmap for dominant color sampling (averaging the region) + private val lazySinglePixelBitmap: Lazy = + lazy(NONE) { Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) } + internal val singlePixelBitmap: Bitmap by lazySinglePixelBitmap + private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) } + private val maskingPaint by lazy(NONE) { Paint() } + + /** + * Renders masks onto the given bitmap based on the view hierarchy. + * + * @param bitmap The bitmap to render masks onto (must be mutable) + * @param viewHierarchy The root node of the view hierarchy + * @param scaleMatrix Optional matrix for scaling (used by replay for lower resolution) + * @return List of masked rectangles (for debug overlay) + */ + fun renderMasks( + bitmap: Bitmap, + viewHierarchy: ViewHierarchyNode, + scaleMatrix: Matrix? = null, + ): List { + if (bitmap.isRecycled) { + return emptyList() + } + + val maskedRects = mutableListOf() + val canvas = Canvas(bitmap) + + scaleMatrix?.let { canvas.setMatrix(it) } + + viewHierarchy.traverse { node -> + if (node.shouldMask && node.width > 0 && node.height > 0) { + node.visibleRect ?: return@traverse false + + val (visibleRects, color) = + when (node) { + is ImageViewHierarchyNode -> { + listOf(node.visibleRect) to + dominantColorForRect(bitmap, node.visibleRect, scaleMatrix) + } + is TextViewHierarchyNode -> { + val textColor = node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK + node.layout.getVisibleRects(node.visibleRect, node.paddingLeft, node.paddingTop) to + textColor + } + else -> { + listOf(node.visibleRect) to Color.BLACK + } + } + + maskingPaint.color = color + visibleRects.forEach { rect -> + canvas.drawRoundRect(RectF(rect), MASK_CORNER_RADIUS, MASK_CORNER_RADIUS, maskingPaint) + } + maskedRects.addAll(visibleRects) + } + return@traverse true + } + + return maskedRects + } + + /** + * Samples the dominant color from a region of the bitmap by scaling the region down to a single + * pixel (averaging all colors in the region). + */ + private fun dominantColorForRect(bitmap: Bitmap, rect: Rect, scaleMatrix: Matrix? = null): Int { + if (bitmap.isRecycled || singlePixelBitmap.isRecycled) { + return Color.BLACK + } + + val visibleRect = Rect(rect) + val visibleRectF = RectF(visibleRect) + + // Apply scale matrix if provided (for replay's lower resolution) + scaleMatrix?.mapRect(visibleRectF) + visibleRectF.round(visibleRect) + + // Draw the region scaled down to 1x1 pixel (averages the colors) + singlePixelBitmapCanvas.drawBitmap(bitmap, visibleRect, Rect(0, 0, 1, 1), null) + + // Return the averaged color + return singlePixelBitmap.getPixel(0, 0) + } + + /** Releases resources. Call when done with this renderer. */ + override fun close() { + if (lazySinglePixelBitmap.isInitialized() && !singlePixelBitmap.isRecycled) { + singlePixelBitmap.recycle() + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt index 1d779d389bf..704260cf311 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -1,9 +1,31 @@ +/* + * Portions of this file are adapted from AndroidX Compose UI: + * - the `boundsInWindow` extension is a faster copy of `LayoutCoordinates.boundsInWindow` + * - the `fastMinOf`, `fastMaxOf`, `fastCoerceIn`, `fastCoerceAtLeast` and `fastCoerceAtMost` + * helpers are copied from `androidx.compose.ui.util.MathHelpers` + * + * Adapted from: + * https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt + * https://github.com/androidx/androidx/blob/androidx-main/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/MathHelpers.kt + * + * Copyright (C) 2019, 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ @file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals and classes package io.sentry.android.replay.util -import android.graphics.Rect import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.graphics.painter.Painter @@ -11,40 +33,43 @@ import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.text.TextLayoutResult +import kotlin.math.ceil +import kotlin.math.floor import kotlin.math.roundToInt -internal class ComposeTextLayout( - internal val layout: TextLayoutResult, - private val hasFillModifier: Boolean, -) : TextLayout { +internal class ComposeTextLayout(internal val layout: TextLayoutResult) : TextLayout { override val lineCount: Int get() = layout.lineCount override val dominantTextColor: Int? get() = null - override fun getPrimaryHorizontal(line: Int, offset: Int): Float { - val horizontalPos = layout.getHorizontalPosition(offset, usePrimaryDirection = true) - // when there's no `fill` modifier on a Text composable, compose still thinks that there's - // one and wrongly calculates horizontal position relative to node's start, not text's start - // for some reason. This is only the case for single-line text (multiline works fien). - // So we subtract line's left to get the correct position - return if (!hasFillModifier && lineCount == 1) { - horizontalPos - layout.getLineLeft(line) - } else { - horizontalPos + /** + * The paragraph may be laid out with a wider width (constraint maxWidth) than the actual node + * (layout result size). When that happens, getLineLeft/getLineRight return positions in the + * paragraph coordinate system, which don't match the node's bounds. In that case, text alignment + * has no visible effect, so we fall back to using line width starting from x=0. + */ + private val paragraphWidthExceedsNode: Boolean + get() = layout.multiParagraph.width > layout.size.width + + override fun getLineLeft(line: Int): Float { + if (paragraphWidthExceedsNode) { + return 0f } + return layout.getLineLeft(line) } - override fun getEllipsisCount(line: Int): Int = if (layout.isLineEllipsized(line)) 1 else 0 - - override fun getLineVisibleEnd(line: Int): Int = layout.getLineEnd(line, visibleEnd = true) + override fun getLineRight(line: Int): Float { + if (paragraphWidthExceedsNode) { + return layout.multiParagraph.getLineWidth(line) + } + return layout.getLineRight(line) + } override fun getLineTop(line: Int): Int = layout.getLineTop(line).roundToInt() override fun getLineBottom(line: Int): Int = layout.getLineBottom(line).roundToInt() - - override fun getLineStart(line: Int): Int = layout.getLineStart(line) } // TODO: probably most of the below we can do via bytecode instrumentation and speed up at runtime @@ -92,8 +117,6 @@ internal fun Painter.isMaskable(): Boolean { !className.contains("Brush") } -internal data class TextAttributes(val color: Color?, val hasFillModifier: Boolean) - /** * This method is necessary to mask text in Compose. * @@ -101,37 +124,24 @@ internal data class TextAttributes(val color: Color?, val hasFillModifier: Boole * string in their name, e.g. TextStringSimpleElement or TextAnnotatedStringElement. We then get the * color from the modifier, to be able to mask it with the correct color. * - * We also look up for classes that have a [Fill] modifier, usually they all have a `Fill` string in - * their name, e.g. FillElement. This is necessary to workaround a Compose bug where single-line - * text composable without a `fill` modifier still thinks that there's one and wrongly calculates - * horizontal position. - * * We also add special proguard rules to keep the `Text` class names and their `color` member. */ -internal fun LayoutNode.findTextAttributes(): TextAttributes { +internal fun LayoutNode.findTextColor(): Color? { val modifierInfos = getModifierInfo() - var color: Color? = null - var hasFillModifier = false for (index in modifierInfos.indices) { val modifier = modifierInfos[index].modifier val modifierClassName = modifier::class.java.name if (modifierClassName.contains("Text")) { - color = - try { - (modifier::class - .java - .getDeclaredField("color") - .apply { isAccessible = true } - .get(modifier) as? ColorProducer) - ?.invoke() - } catch (e: Throwable) { - null - } - } else if (modifierClassName.contains("Fill")) { - hasFillModifier = true + return try { + (modifier::class.java.getDeclaredField("color").apply { isAccessible = true }.get(modifier) + as? ColorProducer) + ?.invoke() + } catch (e: Throwable) { + null + } } } - return TextAttributes(color, hasFillModifier) + return null } /** @@ -181,14 +191,16 @@ internal fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates val rootWidth = root.size.width.toFloat() val rootHeight = root.size.height.toFloat() - val bounds = root.localBoundingBoxOf(this) + // pass clipBounds explicitly to avoid the `localBoundingBoxOf$default` bridge that AGP 8.13's D8 + // desugars inconsistently on minSdk < 24 + val bounds = root.localBoundingBoxOf(this, true) val boundsLeft = bounds.left.fastCoerceIn(0f, rootWidth) val boundsTop = bounds.top.fastCoerceIn(0f, rootHeight) val boundsRight = bounds.right.fastCoerceIn(0f, rootWidth) val boundsBottom = bounds.bottom.fastCoerceIn(0f, rootHeight) if (boundsLeft == boundsRight || boundsTop == boundsBottom) { - return Rect() + return Rect(0.0f, 0.0f, 0.0f, 0.0f) } val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop)) @@ -212,5 +224,18 @@ internal fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates val top = fastMinOf(topLeftY, topRightY, bottomLeftY, bottomRightY) val bottom = fastMaxOf(topLeftY, topRightY, bottomLeftY, bottomRightY) - return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) + return Rect(left, top, right, bottom) +} + +internal fun Rect.toRect(): android.graphics.Rect { + // Round outward (floor min edges, ceil max edges) so that a sub-pixel but non-empty Rect doesn't + // collapse to a zero-width/height android.graphics.Rect. Otherwise a node could be marked visible + // and maskable based on the float bounds, while the integer rect the MaskRenderer draws has zero + // area, leaving sensitive content unmasked. Rounding outward also biases toward over-masking. + return android.graphics.Rect( + floor(left).toInt(), + floor(top).toInt(), + ceil(right).toInt(), + ceil(bottom).toInt(), + ) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt index c6c1e76473c..9de0bd3aab5 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt @@ -1,5 +1,6 @@ package io.sentry.android.replay.util +import android.annotation.SuppressLint import android.annotation.TargetApi import io.sentry.ReplayRecording import io.sentry.SentryOptions @@ -12,6 +13,7 @@ import java.util.concurrent.ScheduledExecutorService // TODO: enable this back after we are able to serialize individual touches to disk to not overload // cpu +@SuppressLint("UseRequiresApi") @Suppress("unused") @TargetApi(26) internal class PersistableLinkedList( diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt new file mode 100644 index 00000000000..5ba334f8029 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt @@ -0,0 +1,93 @@ +package io.sentry.android.replay.util + +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryOptions +import java.util.concurrent.Future +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.MILLISECONDS + +/** + * An ExecutorService which is safe in terms of submitting tasks - it won't crash but will swallow + * and log them. + */ +internal class ReplayExecutorService( + private val delegate: ScheduledExecutorService, + private val options: SentryOptions, +) : ScheduledExecutorService by delegate { + /** + * Submits [task] for execution and returns a [Future] describing what happened. The return value + * has three distinct outcomes callers can rely on: + * - [CompletedFuture] — the caller is already on the replay worker thread, so the task was run + * inline before this method returned. Skips the queue. + * - A regular [Future] from the underlying [ScheduledExecutorService] — the task was queued and + * will run asynchronously. + * - `null` — the underlying executor rejected the submission (typically because it has been shut + * down). The task did NOT run; callers that need cleanup must handle it themselves. + */ + override fun submit(task: Runnable): Future<*>? { + if (Thread.currentThread().name.startsWith("SentryReplayIntegration")) { + task.run() + return CompletedFuture + } + return try { + delegate.submit { + try { + task.run() + } catch (e: Throwable) { + options.logger.log( + ERROR, + "Failed to execute task ${if (task is ReplayRunnable) task.taskName else ""}", + e, + ) + } + } + } catch (e: Throwable) { + options.logger.log( + ERROR, + "Failed to submit task ${if (task is ReplayRunnable) task.taskName else ""} to executor", + e, + ) + null + } + } + + override fun shutdown() { + synchronized(this) { + if (!isShutdown) { + delegate.shutdown() + } + try { + if (!awaitTermination(options.shutdownTimeoutMillis, MILLISECONDS)) { + shutdownNow() + } + } catch (e: InterruptedException) { + shutdownNow() + Thread.currentThread().interrupt() + } + } + } + + fun gracefulShutdown() { + synchronized(this) { + if (!isShutdown) { + delegate.shutdown() + } + } + } +} + +internal class ReplayRunnable(val taskName: String, delegate: Runnable) : Runnable by delegate + +/** A Future that represents an already-completed inline execution — never used as null. */ +internal object CompletedFuture : Future { + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false + + override fun isCancelled(): Boolean = false + + override fun isDone(): Boolean = true + + override fun get() {} + + override fun get(timeout: Long, unit: TimeUnit) {} +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SentryReplayDebug.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SentryReplayDebug.kt new file mode 100644 index 00000000000..966428f84c2 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SentryReplayDebug.kt @@ -0,0 +1,26 @@ +package io.sentry.android.replay.util + +/** + * Internal, undocumented escape hatch used to make Session Replay fail fast instead of silently + * degrading masking when an exception is swallowed (e.g. unsupported/obfuscated Compose internals). + * + * It is intended to be enabled only in our own sample/UI-test apps that run on real devices in CI + * (which are release/obfuscated builds, so [io.sentry.android.replay.BuildConfig.DEBUG] can't be + * used), so that regressions surface as crashes rather than under-masked replays. Customers should + * never set this. + * + * Enable via: + * ``` + * System.setProperty("io.sentry.replay.compose.fail-fast", "true") + * ``` + */ +internal object SentryReplayDebug { + private const val FAIL_FAST_PROPERTY = "io.sentry.replay.compose.fail-fast" + + /** + * Read live (not cached) so it's only evaluated on the error path and unit tests can toggle it + * between cases. + */ + val failFast: Boolean + get() = "true".equals(System.getProperty(FAIL_FAST_PROPERTY), ignoreCase = true) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt index 9b974efceaa..e62584bda06 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt @@ -13,15 +13,11 @@ internal interface TextLayout { */ val dominantTextColor: Int? - fun getPrimaryHorizontal(line: Int, offset: Int): Float + fun getLineLeft(line: Int): Float - fun getEllipsisCount(line: Int): Int - - fun getLineVisibleEnd(line: Int): Int + fun getLineRight(line: Int): Float fun getLineTop(line: Int): Int fun getLineBottom(line: Int): Int - - fun getLineStart(line: Int): Int } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index 6dab0d25912..cacd2b1c217 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -19,7 +19,8 @@ import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver import android.widget.TextView -import io.sentry.SentryOptions +import io.sentry.ILogger +import io.sentry.SentryMaskingOptions import io.sentry.android.replay.viewhierarchy.ComposeViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.NullPointerException @@ -27,13 +28,23 @@ import java.lang.NullPointerException /** * Recursively traverses the view hierarchy and creates a [ViewHierarchyNode] for each view. * Supports Compose view hierarchy as well. + * + * @param parentNode The parent node in the view hierarchy + * @param options The masking configuration to use + * @param logger Logger for error reporting during Compose traversal */ -internal fun View.traverse(parentNode: ViewHierarchyNode, options: SentryOptions) { +@SuppressLint("UseKtx") +internal fun View.traverse( + parentNode: ViewHierarchyNode, + options: SentryMaskingOptions, + logger: ILogger, + surfaceViewNodes: MutableList? = null, +) { if (this !is ViewGroup) { return } - if (ComposeViewHierarchyNode.fromView(this, parentNode, options)) { + if (ComposeViewHierarchyNode.fromView(this, parentNode, options, logger)) { // if it's a compose view, we can skip the children as they are already traversed in // the ComposeViewHierarchyNode.fromView method return @@ -49,7 +60,14 @@ internal fun View.traverse(parentNode: ViewHierarchyNode, options: SentryOptions if (child != null) { val childNode = ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) childNodes.add(childNode) - child.traverse(childNode, options) + if ( + surfaceViewNodes != null && + childNode is ViewHierarchyNode.SurfaceViewHierarchyNode && + childNode.isVisible + ) { + surfaceViewNodes.add(childNode) + } + child.traverse(childNode, options, logger, surfaceViewNodes) } } parentNode.children = childNodes @@ -87,7 +105,7 @@ internal fun View.isVisibleToUser(): Pair { return false to null } -@SuppressLint("ObsoleteSdkInt") +@SuppressLint("ObsoleteSdkInt", "UseRequiresApi") @TargetApi(21) internal fun Drawable?.isMaskable(): Boolean { // TODO: maybe find a way how to check if the drawable is coming from the apk or loaded from @@ -118,21 +136,14 @@ internal fun TextLayout?.getVisibleRects( val rects = mutableListOf() for (i in 0 until lineCount) { - val lineStart = getPrimaryHorizontal(i, getLineStart(i)).toInt() - val ellipsisCount = getEllipsisCount(i) - val lineVisibleEnd = getLineVisibleEnd(i) - var lineEnd = - getPrimaryHorizontal(i, lineVisibleEnd - ellipsisCount + if (ellipsisCount > 0) 1 else 0) - .toInt() - if (lineEnd == 0 && lineVisibleEnd > 0) { - // looks like the case for when emojis are present in text - lineEnd = getPrimaryHorizontal(i, lineVisibleEnd - 1).toInt() + 1 - } + val lineLeft = getLineLeft(i).toInt() + val lineRight = getLineRight(i).toInt() val lineTop = getLineTop(i) val lineBottom = getLineBottom(i) val rect = Rect() - rect.left = globalRect.left + paddingLeft + lineStart - rect.right = rect.left + (lineEnd - lineStart) + + rect.left = globalRect.left + paddingLeft + lineLeft + rect.right = globalRect.left + paddingLeft + lineRight rect.top = globalRect.top + paddingTop + lineTop rect.bottom = rect.top + (lineBottom - lineTop) @@ -187,18 +198,30 @@ internal class AndroidTextLayout(private val layout: Layout) : TextLayout { return dominantColor?.toOpaque() } - override fun getPrimaryHorizontal(line: Int, offset: Int): Float = - layout.getPrimaryHorizontal(offset) - - override fun getEllipsisCount(line: Int): Int = layout.getEllipsisCount(line) + /** + * If text gets ellipsized, we return the left and right bounds of the ellipsized text instead of + * the width, as it's set to some obscure VERY_WIDE value. E.g. see + * https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/widget/TextView.java;l=468?q=VERY_WIDE + */ + override fun getLineLeft(line: Int): Float { + return if (layout.ellipsizedWidth > 0 && layout.ellipsizedWidth < layout.width) { + 0f + } else { + layout.getLineLeft(line) + } + } - override fun getLineVisibleEnd(line: Int): Int = layout.getLineVisibleEnd(line) + override fun getLineRight(line: Int): Float { + return if (layout.ellipsizedWidth > 0 && layout.ellipsizedWidth < layout.width) { + layout.ellipsizedWidth.toFloat() + } else { + layout.getLineRight(line) + } + } override fun getLineTop(line: Int): Int = layout.getLineTop(line) override fun getLineBottom(line: Int): Int = layout.getLineBottom(line) - - override fun getLineStart(line: Int): Int = layout.getLineStart(line) } internal fun View?.addOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawListener) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt index 36741686701..0063cf636e4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -67,7 +67,12 @@ internal class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer } override fun release() { - muxer.stop() + // stop() throws unless the muxer was started AND at least one sample was written, so we guard + // it + // to ensure muxer.release() is always reached and the underlying resources are freed + if (started && videoFrames > 0) { + muxer.stop() + } muxer.release() } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 1efaf133905..dd9af1c24d6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -1,6 +1,6 @@ /** * Adapted from - * https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleFrameMuxer.kt + * https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleVideoEncoder.kt * * Copyright (c) 2021 fzyzcjy * @@ -27,6 +27,7 @@ */ package io.sentry.android.replay.video +import android.annotation.SuppressLint import android.annotation.TargetApi import android.graphics.Bitmap import android.media.MediaCodec @@ -36,6 +37,7 @@ import android.media.MediaFormat import android.os.Build import android.view.Surface import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.android.replay.util.SystemProperties import java.io.File @@ -44,6 +46,17 @@ import kotlin.LazyThreadSafetyMode.NONE private const val TIMEOUT_USEC = 100_000L +/** + * How many consecutive [MediaCodec.dequeueOutputBuffer] calls may come back without producing + * anything before we give up on the encoder. At [TIMEOUT_USEC] per call that's ~1s. + * + * Some hardware encoders never emit [MediaCodec.BUFFER_FLAG_END_OF_STREAM] after + * [MediaCodec.signalEndOfInputStream], which used to spin the drain loop forever while holding the + * encoder lock, wedging the whole replay pipeline (and with it the app's lifecycle callbacks). + */ +private const val MAX_EOS_STALL_ITERATIONS = 10 + +@SuppressLint("UseRequiresApi") @TargetApi(26) internal class SimpleVideoEncoder( val options: SentryOptions, @@ -79,7 +92,7 @@ internal class SimpleVideoEncoder( val videoCapabilities = mediaCodec.codecInfo.getCapabilitiesForType(muxerConfig.mimeType).videoCapabilities - if (!videoCapabilities.bitrateRange.contains(bitRate)) { + if (videoCapabilities != null && !videoCapabilities.bitrateRange.contains(bitRate)) { options.logger.log( DEBUG, "Encoder doesn't support the provided bitRate: $bitRate, the value will be clamped to the closest one", @@ -212,19 +225,26 @@ internal class SimpleVideoEncoder( mediaCodec.signalEndOfInputStream() } var encoderOutputBuffers: Array? = mediaCodec.outputBuffers + // counts consecutive iterations that made no progress, so a codec that never signals EOS can't + // spin us forever, see MAX_EOS_STALL_ITERATIONS + var stalledIterations = 0 while (true) { val encoderStatus: Int = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_USEC) if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) { // no output available yet if (!endOfStream) { break // out of while - } else if (options.sessionReplay.isDebug) { + } + stalledIterations++ + if (options.sessionReplay.isDebug) { options.logger.log(DEBUG, "[Encoder]: no output available, spinning to await EOS") } } else if (encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + stalledIterations = 0 // not expected for an encoder encoderOutputBuffers = mediaCodec.outputBuffers } else if (encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + stalledIterations = 0 // should happen before receiving buffers, and should only happen once if (frameMuxer.isStarted()) { throw RuntimeException("format changed twice") @@ -243,8 +263,10 @@ internal class SimpleVideoEncoder( "[Encoder]: unexpected result from encoder.dequeueOutputBuffer: $encoderStatus", ) } - // let's ignore it + // let's ignore it, but still count it as no progress so we can't loop on it forever + stalledIterations++ } else { + stalledIterations = 0 val encodedData = encoderOutputBuffers?.get(encoderStatus) ?: throw RuntimeException("encoderOutputBuffer $encoderStatus was null") @@ -277,6 +299,14 @@ internal class SimpleVideoEncoder( break // out of while } } + + if (stalledIterations >= MAX_EOS_STALL_ITERATIONS) { + options.logger.log( + WARNING, + "[Encoder]: encoder made no progress for $stalledIterations iterations, dropping the remaining frames", + ) + break // out of while + } } } @@ -285,16 +315,30 @@ internal class SimpleVideoEncoder( onClose?.invoke() drainCodec(true) mediaCodec.stop() - mediaCodec.release() - surface?.release() - - frameMuxer.release() - } catch (e: Throwable) { + } catch (e: RuntimeException) { options.logger.log(DEBUG, "Failed to properly release video encoder", e) + } finally { + // always release the native resources, even if draining/stopping the codec above threw (e.g. + // when the encoder failed to fully start), otherwise they leak (CloseGuard warning). guard + // each + // call so failing to release one resource neither skips the others nor propagates to callers, + // which treat release() as safe cleanup + releaseQuietly("MediaCodec") { mediaCodec.release() } + releaseQuietly("Surface") { surface?.release() } + releaseQuietly("MediaMuxer") { frameMuxer.release() } + } + } + + private inline fun releaseQuietly(name: String, block: () -> Unit) { + try { + block() + } catch (e: RuntimeException) { + options.logger.log(DEBUG, "Failed to release $name", e) } } } +@SuppressLint("UseRequiresApi") @TargetApi(24) internal data class MuxerConfig( val file: File, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index 5ec54ef5129..2e40144e2de 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -2,6 +2,7 @@ package io.sentry.android.replay.viewhierarchy +import android.annotation.SuppressLint import android.annotation.TargetApi import android.view.View import androidx.compose.ui.graphics.isUnspecified @@ -16,50 +17,56 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.unit.TextUnit +import io.sentry.ILogger import io.sentry.SentryLevel -import io.sentry.SentryOptions -import io.sentry.SentryReplayOptions +import io.sentry.SentryMaskingOptions import io.sentry.android.replay.SentryReplayModifiers import io.sentry.android.replay.util.ComposeTextLayout +import io.sentry.android.replay.util.SentryReplayDebug import io.sentry.android.replay.util.boundsInWindow import io.sentry.android.replay.util.findPainter -import io.sentry.android.replay.util.findTextAttributes +import io.sentry.android.replay.util.findTextColor import io.sentry.android.replay.util.isMaskable import io.sentry.android.replay.util.toOpaque +import io.sentry.android.replay.util.toRect import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import java.lang.ref.WeakReference import java.lang.reflect.Method +@SuppressLint("UseRequiresApi") @TargetApi(26) internal object ComposeViewHierarchyNode { - private val getSemanticsConfigurationMethod: Method? by lazy { - try { - return@lazy LayoutNode::class.java.getDeclaredMethod("getSemanticsConfiguration").apply { - isAccessible = true + private val getCollapsedSemanticsMethod: Method? by + lazy(LazyThreadSafetyMode.NONE) { + try { + return@lazy LayoutNode::class + .java + .getDeclaredMethod("getCollapsedSemantics\$ui_release") + .apply { isAccessible = true } + } catch (_: Throwable) { + // ignore, as this method may not be available } - } catch (_: Throwable) { - // ignore, as this method may not be available + return@lazy null } - return@lazy null - } private var semanticsRetrievalErrorLogged: Boolean = false @JvmStatic internal fun retrieveSemanticsConfiguration(node: LayoutNode): SemanticsConfiguration? { - // Jetpack Compose 1.8 or newer provides SemanticsConfiguration via SemanticsInfo - // See - // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt - // and - // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt - getSemanticsConfigurationMethod?.let { - return it.invoke(node) as SemanticsConfiguration? + return try { + node.semanticsConfiguration + } catch (t: Throwable) { + // for backwards compatibility + // Jetpack Compose 1.8 or older + if (getCollapsedSemanticsMethod != null) { + getCollapsedSemanticsMethod!!.invoke(node) as SemanticsConfiguration? + } else { + // re-throw t if there's no way to retrieve semantics + throw t + } } - - // for backwards compatibility - return node.collapsedSemantics } /** @@ -68,34 +75,36 @@ internal object ComposeViewHierarchyNode { */ private fun getProxyClassName(isImage: Boolean, config: SemanticsConfiguration?): String = when { - isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME + isImage -> SentryMaskingOptions.IMAGE_VIEW_CLASS_NAME config != null && (config.contains(SemanticsProperties.Text) || config.contains(SemanticsActions.SetText) || config.contains(SemanticsProperties.EditableText)) -> - SentryReplayOptions.TEXT_VIEW_CLASS_NAME + SentryMaskingOptions.TEXT_VIEW_CLASS_NAME else -> "android.view.View" } private fun SemanticsConfiguration?.shouldMask( isImage: Boolean, - options: SentryOptions, + options: SentryMaskingOptions, ): Boolean { val sentryPrivacyModifier = this?.getOrNull(SentryReplayModifiers.SentryPrivacy) if (sentryPrivacyModifier == "unmask") { + options.trackCustomMasking() return false } if (sentryPrivacyModifier == "mask") { + options.trackCustomMasking() return true } val className = getProxyClassName(isImage, this) - if (options.sessionReplay.unmaskViewClasses.contains(className)) { + if (options.unmaskViewClasses.contains(className)) { return false } - return options.sessionReplay.maskViewClasses.contains(className) + return options.maskViewClasses.contains(className) } @Suppress("ktlint:standard:backing-property-naming") @@ -106,7 +115,8 @@ internal object ComposeViewHierarchyNode { parent: ViewHierarchyNode?, distance: Int, isComposeRoot: Boolean, - options: SentryOptions, + options: SentryMaskingOptions, + logger: ILogger, ): ViewHierarchyNode? { val isInTree = node.isPlaced && node.isAttached if (!isInTree) { @@ -125,25 +135,31 @@ internal object ComposeViewHierarchyNode { } catch (t: Throwable) { if (!semanticsRetrievalErrorLogged) { semanticsRetrievalErrorLogged = true - options.logger.log( + logger.log( SentryLevel.ERROR, t, """ - Error retrieving semantics information from Compose tree. Most likely you're using - an unsupported version of androidx.compose.ui:ui. The supported - version range is 1.5.0 - 1.8.0. - If you're using a newer version, please open a github issue with the version - you're using, so we can add support for it. - """ + Error retrieving semantics information from Compose tree. Most likely you're using + an unsupported version of androidx.compose.ui:ui. The supported + version range is 1.5.0 - 1.10.2. + If you're using a newer version, please open a github issue with the version + you're using, so we can add support for it. + """ .trimIndent(), ) } + // fail fast in our own sample/UI-test apps (see SentryReplayDebug), so regressions surface + // as crashes instead of silently degrading masking + if (SentryReplayDebug.failFast) { + throw t + } + // If we're unable to retrieve the semantics configuration // we should play safe and mask the whole node. return GenericViewHierarchyNode( - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -152,18 +168,18 @@ internal object ComposeViewHierarchyNode { shouldMask = true, isImportantForContentCapture = false, // will be set by children isVisible = - !node.outerCoordinator.isTransparent() && - visibleRect.height() > 0 && - visibleRect.width() > 0, - visibleRect = visibleRect, + !SentryLayoutNodeHelper.isTransparent(node) && + visibleRect.height > 0 && + visibleRect.width > 0, + visibleRect = visibleRect.toRect(), ) } val isVisible = - !node.outerCoordinator.isTransparent() && + !SentryLayoutNodeHelper.isTransparent(node) && (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && - visibleRect.height() > 0 && - visibleRect.width() > 0 + visibleRect.height > 0 && + visibleRect.width > 0 val isEditable = semantics?.contains(SemanticsActions.SetText) == true || semantics?.contains(SemanticsProperties.EditableText) == true @@ -181,11 +197,10 @@ internal object ComposeViewHierarchyNode { ?.action ?.invoke(textLayoutResults) - val (color, hasFillModifier) = node.findTextAttributes() val textLayoutResult = textLayoutResults.firstOrNull() var textColor = textLayoutResult?.layoutInput?.style?.color if (textColor?.isUnspecified == true) { - textColor = color + textColor = node.findTextColor() } val isLaidOut = textLayoutResult?.layoutInput?.style?.fontSize != TextUnit.Unspecified // TODO: support editable text (currently there's a way to get @Composable's padding only @@ -194,13 +209,13 @@ internal object ComposeViewHierarchyNode { TextViewHierarchyNode( layout = if (textLayoutResult != null && !isEditable && isLaidOut) { - ComposeTextLayout(textLayoutResult, hasFillModifier) + ComposeTextLayout(textLayoutResult) } else { null }, dominantColor = textColor?.toArgb()?.toOpaque(), - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -209,7 +224,7 @@ internal object ComposeViewHierarchyNode { shouldMask = shouldMask, isImportantForContentCapture = true, isVisible = isVisible, - visibleRect = visibleRect, + visibleRect = visibleRect.toRect(), ) } else -> { @@ -219,8 +234,8 @@ internal object ComposeViewHierarchyNode { parent?.setImportantForCaptureToAncestors(true) ImageViewHierarchyNode( - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -229,7 +244,7 @@ internal object ComposeViewHierarchyNode { isVisible = isVisible, isImportantForContentCapture = true, shouldMask = shouldMask && painter.isMaskable(), - visibleRect = visibleRect, + visibleRect = visibleRect.toRect(), ) } else { val shouldMask = isVisible && semantics.shouldMask(isImage = false, options) @@ -238,8 +253,8 @@ internal object ComposeViewHierarchyNode { // TODO: traverse the ViewHierarchyNode here again. For now we can recommend // TODO: using custom modifiers to obscure the entire node if it's sensitive GenericViewHierarchyNode( - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -248,14 +263,19 @@ internal object ComposeViewHierarchyNode { shouldMask = shouldMask, isImportantForContentCapture = false, // will be set by children isVisible = isVisible, - visibleRect = visibleRect, + visibleRect = visibleRect.toRect(), ) } } } } - fun fromView(view: View, parent: ViewHierarchyNode?, options: SentryOptions): Boolean { + fun fromView( + view: View, + parent: ViewHierarchyNode?, + options: SentryMaskingOptions, + logger: ILogger, + ): Boolean { if (!view::class.java.name.contains("AndroidComposeView")) { return false } @@ -266,19 +286,24 @@ internal object ComposeViewHierarchyNode { try { val rootNode = (view as? Owner)?.root ?: return false - rootNode.traverse(parent, isComposeRoot = true, options) + rootNode.traverse(parent, isComposeRoot = true, options, logger) } catch (e: Throwable) { - options.logger.log( + logger.log( SentryLevel.ERROR, e, """ - Error traversing Compose tree. Most likely you're using an unsupported version of - androidx.compose.ui:ui. The minimum supported version is 1.5.0. If it's a newer - version, please open a github issue with the version you're using, so we can add - support for it. - """ + Error traversing Compose tree. Most likely you're using an unsupported version of + androidx.compose.ui:ui. The minimum supported version is 1.5.0. If it's a newer + version, please open a github issue with the version you're using, so we can add + support for it. + """ .trimIndent(), ) + // fail fast in our own sample/UI-test apps (see SentryReplayDebug), so regressions surface + // as crashes instead of silently skipping the whole Compose subtree (i.e. not masking it) + if (SentryReplayDebug.failFast) { + throw e + } return false } @@ -288,9 +313,10 @@ internal object ComposeViewHierarchyNode { private fun LayoutNode.traverse( parentNode: ViewHierarchyNode, isComposeRoot: Boolean, - options: SentryOptions, + options: SentryMaskingOptions, + logger: ILogger, ) { - val children = this.children + val children = SentryLayoutNodeHelper.getChildren(this) if (children.isEmpty()) { return } @@ -298,10 +324,10 @@ internal object ComposeViewHierarchyNode { val childNodes = ArrayList(children.size) for (index in children.indices) { val child = children[index] - val childNode = fromComposeNode(child, parentNode, index, isComposeRoot, options) + val childNode = fromComposeNode(child, parentNode, index, isComposeRoot, options, logger) if (childNode != null) { childNodes.add(childNode) - child.traverse(childNode, isComposeRoot = false, options) + child.traverse(childNode, isComposeRoot = false, options, logger) } } parentNode.children = childNodes diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/SentryLayoutNodeHelper.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/SentryLayoutNodeHelper.kt new file mode 100644 index 00000000000..6cfe5ec6fc0 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/SentryLayoutNodeHelper.kt @@ -0,0 +1,90 @@ +@file:Suppress( + "INVISIBLE_MEMBER", + "INVISIBLE_REFERENCE", + "EXPOSED_PARAMETER_TYPE", + "EXPOSED_RETURN_TYPE", + "EXPOSED_FUNCTION_RETURN_TYPE", +) + +package io.sentry.android.replay.viewhierarchy + +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.NodeCoordinator +import java.lang.reflect.Method + +/** + * Provides access to internal LayoutNode members that are subject to Kotlin name-mangling. + * + * This class is not thread-safe, as Compose UI operations are expected to be performed on the main + * thread. + * + * Compiled against Compose >= 1.10 where the mangled names use the "ui" module suffix (e.g. + * getChildren$ui()). For apps still on Compose < 1.10 (where the suffix is "$ui_release"), the + * direct call will throw [NoSuchMethodError] and we fall back to reflection-based accessors that + * are resolved and cached on first use. + */ +internal object SentryLayoutNodeHelper { + private class Fallback(val getChildren: Method?, val getOuterCoordinator: Method?) + + private var useFallback: Boolean? = null + private var fallback: Fallback? = null + + private fun tryResolve(clazz: Class<*>, name: String): Method? { + return try { + clazz.getDeclaredMethod(name).apply { isAccessible = true } + } catch (_: NoSuchMethodException) { + null + } + } + + @Suppress("UNCHECKED_CAST") + fun getChildren(node: LayoutNode): List { + when (useFallback) { + false -> return node.children + true -> { + return getFallback().getChildren!!.invoke(node) as List + } + null -> { + try { + return node.children.also { useFallback = false } + } catch (_: NoSuchMethodError) { + useFallback = true + return getFallback().getChildren!!.invoke(node) as List + } + } + } + } + + fun isTransparent(node: LayoutNode): Boolean { + when (useFallback) { + false -> return node.outerCoordinator.isTransparent() + true -> { + val fb = getFallback() + val coordinator = fb.getOuterCoordinator!!.invoke(node) as NodeCoordinator + return coordinator.isTransparent() + } + null -> { + try { + return node.outerCoordinator.isTransparent().also { useFallback = false } + } catch (_: NoSuchMethodError) { + useFallback = true + val fb = getFallback() + val coordinator = fb.getOuterCoordinator!!.invoke(node) as NodeCoordinator + return coordinator.isTransparent() + } + } + } + } + + private fun getFallback(): Fallback { + fallback?.let { + return it + } + + val layoutNodeClass = LayoutNode::class.java + val getChildren = tryResolve(layoutNodeClass, "getChildren\$ui_release") + val getOuterCoordinator = tryResolve(layoutNodeClass, "getOuterCoordinator\$ui_release") + + return Fallback(getChildren, getOuterCoordinator).also { fallback = it } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 55ac74b59da..e55ba659a8e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -1,12 +1,14 @@ package io.sentry.android.replay.viewhierarchy +import android.annotation.SuppressLint import android.annotation.TargetApi import android.graphics.Rect +import android.view.SurfaceView import android.view.View import android.view.ViewParent import android.widget.ImageView import android.widget.TextView -import io.sentry.SentryOptions +import io.sentry.SentryMaskingOptions import io.sentry.android.replay.R import io.sentry.android.replay.util.AndroidTextLayout import io.sentry.android.replay.util.TextLayout @@ -14,7 +16,9 @@ import io.sentry.android.replay.util.isMaskable import io.sentry.android.replay.util.isVisibleToUser import io.sentry.android.replay.util.toOpaque import io.sentry.android.replay.util.totalPaddingTopSafe +import java.lang.ref.WeakReference +@SuppressLint("UseRequiresApi") @TargetApi(26) internal sealed class ViewHierarchyNode( val x: Float, @@ -119,6 +123,34 @@ internal sealed class ViewHierarchyNode( visibleRect, ) + class SurfaceViewHierarchyNode( + val surfaceViewRef: WeakReference, + x: Float, + y: Float, + width: Int, + height: Int, + elevation: Float, + distance: Int, + parent: ViewHierarchyNode? = null, + shouldMask: Boolean = false, + isImportantForContentCapture: Boolean = false, + isVisible: Boolean = false, + visibleRect: Rect? = null, + ) : + ViewHierarchyNode( + x, + y, + width, + height, + elevation, + distance, + parent, + shouldMask, + isImportantForContentCapture, + isVisible, + visibleRect, + ) + /** * Basically replicating this: * https://developer.android.com/reference/android/view/View#isImportantForContentCapture() but @@ -284,11 +316,12 @@ internal sealed class ViewHierarchyNode( return false } - private fun View.shouldMask(options: SentryOptions): Boolean { + private fun View.shouldMask(options: SentryMaskingOptions): Boolean { if ( (tag as? String)?.lowercase()?.contains(SENTRY_UNMASK_TAG) == true || getTag(R.id.sentry_privacy) == "unmask" ) { + options.trackCustomMasking() return false } @@ -296,6 +329,7 @@ internal sealed class ViewHierarchyNode( (tag as? String)?.lowercase()?.contains(SENTRY_MASK_TAG) == true || getTag(R.id.sentry_privacy) == "mask" ) { + options.trackCustomMasking() return true } @@ -307,28 +341,33 @@ internal sealed class ViewHierarchyNode( return false } - if (this.javaClass.isAssignableFrom(options.sessionReplay.unmaskViewClasses)) { + if (this.javaClass.isAssignableFrom(options.unmaskViewClasses)) { return false } - return this.javaClass.isAssignableFrom(options.sessionReplay.maskViewClasses) + return this.javaClass.isAssignableFrom(options.maskViewClasses) } - private fun ViewParent.isUnmaskContainer(options: SentryOptions): Boolean { - val unmaskContainer = options.sessionReplay.unmaskViewContainerClass ?: return false + private fun ViewParent.isUnmaskContainer(options: SentryMaskingOptions): Boolean { + val unmaskContainer = options.unmaskViewContainerClass ?: return false return this.javaClass.name == unmaskContainer } - private fun View.isMaskContainer(options: SentryOptions): Boolean { - val maskContainer = options.sessionReplay.maskViewContainerClass ?: return false + private fun View.isMaskContainer(options: SentryMaskingOptions): Boolean { + val maskContainer = options.maskViewContainerClass ?: return false return this.javaClass.name == maskContainer } + /** + * Creates a ViewHierarchyNode from a View using SentryMaskingOptions directly. This allows for + * reuse with both session replay and screenshot masking. + */ + @JvmStatic fun fromView( view: View, parent: ViewHierarchyNode?, distance: Int, - options: SentryOptions, + options: SentryMaskingOptions, ): ViewHierarchyNode { val (isVisible, visibleRect) = view.isVisibleToUser() val shouldMask = isVisible && view.shouldMask(options) @@ -370,6 +409,24 @@ internal sealed class ViewHierarchyNode( visibleRect = visibleRect, ) } + + is SurfaceView -> { + parent?.setImportantForCaptureToAncestors(true) + return SurfaceViewHierarchyNode( + surfaceViewRef = WeakReference(view), + x = view.x, + y = view.y, + width = view.width, + height = view.height, + elevation = (parent?.elevation ?: 0f) + view.elevation, + distance = distance, + parent = parent, + shouldMask = shouldMask, + isImportantForContentCapture = true, + isVisible = isVisible, + visibleRect = visibleRect, + ) + } } return GenericViewHierarchyNode( diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt index f3d03fd5bc5..3df08b2af24 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt @@ -80,35 +80,35 @@ class AnrWithReplayIntegrationTest { whenever(mock.traceInputStream) .thenReturn( """ -"main" prio=5 tid=1 Blocked - | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 - | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 - | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 - | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB - | held mutexes= - at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) - - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 - at android.os.Handler.handleCallback(Handler.java:942) - at android.os.Handler.dispatchMessage(Handler.java:99) - at android.os.Looper.loopOnce(Looper.java:201) - at android.os.Looper.loop(Looper.java:288) - at android.app.ActivityThread.main(ActivityThread.java:7872) - at java.lang.reflect.Method.invoke(Native method) - at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) - at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) - -"perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) - | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 - | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 - | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 - | stack=0x7b20124000-0x7b20126000 stackSize=991KB - | held mutexes= - native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) - native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) - (no managed stack frames) - """ + "main" prio=5 tid=1 Blocked + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) + - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 + at android.os.Handler.handleCallback(Handler.java:942) + at android.os.Handler.dispatchMessage(Handler.java:99) + at android.os.Looper.loopOnce(Looper.java:201) + at android.os.Looper.loop(Looper.java:288) + at android.app.ActivityThread.main(ActivityThread.java:7872) + at java.lang.reflect.Method.invoke(Native method) + at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) + at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) + + "perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) + | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 + | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 + | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 + | stack=0x7b20124000-0x7b20126000 stackSize=991KB + | held mutexes= + native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) + native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + (no managed stack frames) + """ .trimIndent() .byteInputStream() ) @@ -155,11 +155,10 @@ class AnrWithReplayIntegrationTest { it.sessionReplay.onErrorSampleRate = 1.0 // beforeSend is called after event processors are applied, so we can assert here // against the enriched ANR event - it.beforeSend = - SentryOptions.BeforeSendCallback { event, _ -> - assertEquals(replayId2.toString(), event.contexts[Contexts.REPLAY_ID]) - event - } + it.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> + assertEquals(replayId2.toString(), event.contexts[Contexts.REPLAY_ID]) + event + } it.addEventProcessor( object : EventProcessor { override fun process(event: SentryReplayEvent, hint: Hint): SentryReplayEvent { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt index a12ae043154..749d3496698 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt @@ -1,18 +1,32 @@ package io.sentry.android.replay import io.sentry.Breadcrumb +import io.sentry.Hint import io.sentry.SentryLevel +import io.sentry.SentryOptions import io.sentry.SpanDataConvention +import io.sentry.TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebSpanEvent +import io.sentry.util.network.NetworkBody +import io.sentry.util.network.NetworkRequestData +import io.sentry.util.network.ReplayNetworkRequestOrResponse import java.util.Date import junit.framework.TestCase.assertEquals import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame import kotlin.test.assertNull +import kotlin.test.assertSame class DefaultReplayBreadcrumbConverterTest { class Fixture { - fun getSut(): DefaultReplayBreadcrumbConverter = DefaultReplayBreadcrumbConverter() + fun getSut(options: SentryOptions? = null): DefaultReplayBreadcrumbConverter = + if (options != null) { + DefaultReplayBreadcrumbConverter(options) + } else { + DefaultReplayBreadcrumbConverter() + } } private val fixture = Fixture() @@ -318,4 +332,247 @@ class DefaultReplayBreadcrumbConverterTest { assertEquals(SentryLevel.ERROR, rrwebEvent.level) assertEquals("shiet", rrwebEvent.data!!["stuff"]) } + + // BeforeBreadcrumbCallback delegation tests + + @Test + fun `ReplayBeforeBreadcrumb does not modify breadcrumb__no user-provided BeforeBreadcrumbCallback`() { + // Create options with no beforeBreadcrumb callback + val options = SentryOptions.empty() + options.beforeBreadcrumb = null + DefaultReplayBreadcrumbConverter(options) + + val breadcrumb = + Breadcrumb(Date()).apply { + message = "test message" + category = "test.category" + } + val hint = Hint() + + val result = options.beforeBreadcrumb?.execute(breadcrumb, hint) + + assertSame(breadcrumb, result) + } + + @Test + fun `ReplayBeforeBreadcrumb delegates to user-provided BeforeBreadcrumbCallback`() { + val originalBreadcrumb = + Breadcrumb(Date()).apply { + message = "original message" + category = "original.category" + } + val userModifiedBreadcrumb = + Breadcrumb(Date()).apply { + message = "modified message" + category = "modified.category" + } + + // Set up options with a user callback that returns modified breadcrumb + val userBeforeBreadcrumbCallback = SentryOptions.BeforeBreadcrumbCallback { _, _ -> + userModifiedBreadcrumb + } + val options = SentryOptions.empty() + options.beforeBreadcrumb = userBeforeBreadcrumbCallback + + DefaultReplayBreadcrumbConverter(options) + + // user-provided SentryOptions beforeBreadcrumb is replaced. + assertNotSame(userBeforeBreadcrumbCallback, options.beforeBreadcrumb) + + // SentryOptions#beforeBreadcrumb still respects user-provided beforeBreadcrumb + val result = options.beforeBreadcrumb?.execute(originalBreadcrumb, Hint()) + assertSame(userModifiedBreadcrumb, result) + } + + @Test + fun `ReplayBeforeBreadcrumb handles user-provided BeforeBreadcrumbCallback returning null`() { + val breadcrumb = + Breadcrumb(Date()).apply { + message = "test message" + category = "test.category" + } + + val options = SentryOptions.empty() + val userCallback = SentryOptions.BeforeBreadcrumbCallback { _, _ -> null } + options.beforeBreadcrumb = userCallback + fixture.getSut(options) + + // user-provided SentryOptions beforeBreadcrumb is replaced. + assertNotSame(userCallback, options.beforeBreadcrumb) + + val result = options.beforeBreadcrumb?.execute(breadcrumb, Hint()) + assertNull(result) + } + + @Test + fun `converts network details data__with user-provided BeforeBreadcrumbCallback`() { + val options = SentryOptions.empty() + val userCallback = SentryOptions.BeforeBreadcrumbCallback { b, _ -> b } + options.beforeBreadcrumb = userCallback + val converter = fixture.getSut(options) + + val httpBreadcrumb = + Breadcrumb(Date(123L)).apply { + type = "http" + category = "http" + data["url"] = "https://example.com" + data[SpanDataConvention.HTTP_START_TIMESTAMP] = 1000L + data[SpanDataConvention.HTTP_END_TIMESTAMP] = 2000L + } + + val fakeOkHttpNetworkDetails = NetworkRequestData("POST") + fakeOkHttpNetworkDetails.setRequestDetails( + ReplayNetworkRequestOrResponse( + 100L, + NetworkBody("request body content"), + mapOf("Content-Type" to "application/json"), + ) + ) + fakeOkHttpNetworkDetails.setResponseDetails( + 200, + ReplayNetworkRequestOrResponse( + 500L, + NetworkBody(mapOf("status" to "success", "message" to "OK")), + mapOf("Content-Type" to "text/plain"), + ), + ) + val hintWithFakeOKHttpNetworkDetails = Hint() + hintWithFakeOKHttpNetworkDetails.set(SENTRY_REPLAY_NETWORK_DETAILS, fakeOkHttpNetworkDetails) + + options.beforeBreadcrumb?.execute(httpBreadcrumb, hintWithFakeOKHttpNetworkDetails) + + // Verify NetworkDetails is properly extracted + val rrwebEvent = converter.convert(httpBreadcrumb) + check(rrwebEvent is RRWebSpanEvent) + + // Meta data + assertEquals("POST", rrwebEvent.data!!["method"]) + assertEquals(200, rrwebEvent.data!!["statusCode"]) + assertEquals(100L, rrwebEvent.data!!["requestBodySize"]) + assertEquals(500L, rrwebEvent.data!!["responseBodySize"]) + + // Request data + val requestData = rrwebEvent.data!!["request"] as? Map<*, *> + assertNotNull(requestData) + assertEquals(100L, requestData["size"]) + assertEquals("request body content", requestData["body"]) + assertEquals(mapOf("Content-Type" to "application/json"), requestData["headers"]) + + // Response data + val responseData = rrwebEvent.data!!["response"] as? Map<*, *> + assertNotNull(responseData) + assertEquals(500L, responseData["size"]) + assertEquals(mapOf("status" to "success", "message" to "OK"), responseData["body"]) + assertEquals(mapOf("Content-Type" to "text/plain"), responseData["headers"]) + } + + @Test + fun `converts network details data__no user-provided BeforeBreadcrumbCallback`() { + val options = SentryOptions.empty() + val userCallback = null + options.beforeBreadcrumb = userCallback + val converter = fixture.getSut(options) + + val httpBreadcrumb = + Breadcrumb(Date(123L)).apply { + type = "http" + category = "http" + data["url"] = "https://example.com" + data[SpanDataConvention.HTTP_START_TIMESTAMP] = 1000L + data[SpanDataConvention.HTTP_END_TIMESTAMP] = 2000L + } + + val fakeOkHttpNetworkDetails = NetworkRequestData("POST") + fakeOkHttpNetworkDetails.setRequestDetails( + ReplayNetworkRequestOrResponse( + 150L, + NetworkBody(listOf("item1", "item2", "item3")), + mapOf("Content-Type" to "application/json"), + ) + ) + fakeOkHttpNetworkDetails.setResponseDetails( + 404, + ReplayNetworkRequestOrResponse( + 550L, + NetworkBody(mapOf("status" to "success", "message" to "OK")), + mapOf("Content-Type" to "text/plain"), + ), + ) + val hintWithFakeOKHttpNetworkDetails = Hint() + hintWithFakeOKHttpNetworkDetails.set(SENTRY_REPLAY_NETWORK_DETAILS, fakeOkHttpNetworkDetails) + + options.beforeBreadcrumb?.execute(httpBreadcrumb, hintWithFakeOKHttpNetworkDetails) + + // Verify NetworkDetails is properly extracted + val rrwebEvent = converter.convert(httpBreadcrumb) + check(rrwebEvent is RRWebSpanEvent) + + // Meta data + assertEquals("POST", rrwebEvent.data!!["method"]) + assertEquals(404, rrwebEvent.data!!["statusCode"]) + assertEquals(150L, rrwebEvent.data!!["requestBodySize"]) + assertEquals(550L, rrwebEvent.data!!["responseBodySize"]) + + // Request data + val requestData = rrwebEvent.data!!["request"] as? Map<*, *> + assertNotNull(requestData) + assertEquals(150L, requestData["size"]) + assertEquals(listOf("item1", "item2", "item3"), requestData["body"]) + assertEquals(mapOf("Content-Type" to "application/json"), requestData["headers"]) + + // Response data + val responseData = rrwebEvent.data!!["response"] as? Map<*, *> + assertNotNull(responseData) + assertEquals(550L, responseData["size"]) + assertEquals(mapOf("status" to "success", "message" to "OK"), responseData["body"]) + assertEquals(mapOf("Content-Type" to "text/plain"), responseData["headers"]) + } + + @Test + fun `does not convert network details data for non-http breadcrumbs`() { + val navigationBreadcrumb = + Breadcrumb(Date()).apply { + type = "navigation" + category = "navigation" + data["to"] = "/home" + } + val hint = Hint() + val networkRequestData = NetworkRequestData("GET") + networkRequestData.setRequestDetails( + ReplayNetworkRequestOrResponse( + 100L, + NetworkBody("request body content"), + mapOf("Content-Type" to "application/json"), + ) + ) + networkRequestData.setResponseDetails( + 200, + ReplayNetworkRequestOrResponse( + 100L, + NetworkBody("response body content"), + mapOf("Content-Type" to "application/json"), + ), + ) + hint.set(SENTRY_REPLAY_NETWORK_DETAILS, networkRequestData) + + val options = SentryOptions.empty() + options.beforeBreadcrumb = null + val converter = fixture.getSut(options) + + assertSame(navigationBreadcrumb, options.beforeBreadcrumb?.execute(navigationBreadcrumb, hint)) + + // Verify converter also doesn't include network details for non-http breadcrumbs + val rrwebEvent = converter.convert(navigationBreadcrumb) + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("navigation", rrwebEvent.category) + assertEquals("/home", rrwebEvent.data!!["to"]) + + // Verify no network-related data is present + assertNull(rrwebEvent.data!!["method"]) + assertNull(rrwebEvent.data!!["statusCode"]) + assertNull(rrwebEvent.data!!["requestBodySize"]) + assertNull(rrwebEvent.data!!["responseBodySize"]) + assertNull(rrwebEvent.data!!["request"]) + assertNull(rrwebEvent.data!!["response"]) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 257941a9114..96e5a926af4 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -4,11 +4,14 @@ import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat.JPEG import android.graphics.Bitmap.Config.ARGB_8888 import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import io.sentry.DateUtils import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FLUSHED import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID @@ -23,10 +26,13 @@ import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchEnd import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart import java.io.File import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit.SECONDS import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -35,6 +41,7 @@ import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowBitmapFactory +import org.robolectric.shadows.ShadowCloseGuard @RunWith(AndroidJUnit4::class) @Config(sdk = [26], shadows = [ReplayShadowMediaCodec::class]) @@ -55,6 +62,11 @@ class ReplayCacheTest { @BeforeTest fun `set up`() { ReplayShadowMediaCodec.framesToEncode = 5 + ReplayShadowMediaCodec.throwOnStart = false + ReplayShadowMediaCodec.neverSignalEos = false + ReplayShadowMediaCodec.blockOnDequeue = null + ReplayShadowMediaCodec.blockedOnDequeue = CountDownLatch(1) + ReplayShadowMediaCodec.released = false ShadowBitmapFactory.setAllowInvalidImageData(true) } @@ -92,6 +104,26 @@ class ReplayCacheTest { assertNull(video) } + @Test + fun `releases the muxer when the encoder fails to start`() { + ReplayShadowMediaCodec.throwOnStart = true + val replayCache = fixture.getSut(tmpDir) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + ShadowCloseGuard.reset() + assertFailsWith { + replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) + } + + val muxerLeaks = + ShadowCloseGuard.getErrors().filter { error -> + error.stackTrace.any { it.className.contains("MediaMuxer") } + } + assertTrue(muxerLeaks.isEmpty(), "MediaMuxer was not released: $muxerLeaks") + } + @Test fun `deletes frames after creating a video`() { ReplayShadowMediaCodec.framesToEncode = 3 @@ -443,7 +475,7 @@ class ReplayCacheTest { } @Test - fun `sets segmentId to 0 for buffer mode`() { + fun `sets segmentId to 0 for buffer mode when not flushed`() { fixture.options.run { cacheDirPath = tmpDir.newFolder()?.absolutePath } val replayId = SentryId() val replayCacheFolder = @@ -474,6 +506,39 @@ class ReplayCacheTest { assertEquals(0, lastSegment.id) } + @Test + fun `preserves segmentId for buffer mode when already flushed`() { + fixture.options.run { cacheDirPath = tmpDir.newFolder()?.absolutePath } + val replayId = SentryId() + val replayCacheFolder = + File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=5 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + $SEGMENT_KEY_FLUSHED=true + """ + .trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(5, lastSegment.id) + } + @Test fun `when screenshot is corrupted, deletes it immediately`() { ShadowBitmapFactory.setAllowInvalidImageData(false) @@ -597,4 +662,88 @@ class ReplayCacheTest { // No crash is success assertNull(error.get()) } + + @Test + fun `createVideoOf returns when the encoder never signals end of stream`() { + ReplayShadowMediaCodec.neverSignalEos = true + val replayCache = fixture.getSut(tmpDir) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + val done = CountDownLatch(1) + val error = AtomicReference() + val encoder = + thread(isDaemon = true) { + try { + replayCache.createVideoOf(1000L, 0L, 0, 100, 200, 1, 20_000) + } catch (t: Throwable) { + error.set(t) + } finally { + done.countDown() + } + } + + assertWithMessage("createVideoOf did not return, the drain loop is spinning") + .that(done.await(30, SECONDS)) + .isTrue() + encoder.join(SECONDS.toMillis(10)) + assertThat(error.get()).isNull() + } + + @Test + fun `close does not block when the encoder is wedged, and still marks the cache closed`() { + val wedge = CountDownLatch(1) + ReplayShadowMediaCodec.blockOnDequeue = wedge + val replayCache = fixture.getSut(tmpDir) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + // parks inside MediaCodec while holding the encoder lock + val encoder = + thread(isDaemon = true) { replayCache.createVideoOf(1000L, 0L, 0, 100, 200, 1, 20_000) } + try { + assertWithMessage("the encoder never reached dequeueOutputBuffer") + .that(ReplayShadowMediaCodec.blockedOnDequeue.await(30, SECONDS)) + .isTrue() + + // on a separate thread so a regression fails the test instead of hanging the run + val closed = CountDownLatch(1) + thread(isDaemon = true) { + replayCache.close() + closed.countDown() + } + assertWithMessage("close() blocked on the wedged encoder") + .that(closed.await(30, SECONDS)) + .isTrue() + + // giving up on the lock still counts as closed, otherwise we'd keep persisting segments + replayCache.persistSegmentValues(SEGMENT_KEY_ID, "0") + assertThat(File(replayCache.replayCacheDir, ONGOING_SEGMENT).exists()).isFalse() + + assertWithMessage("encoder should not be released when the lock times out") + .that(ReplayShadowMediaCodec.released) + .isFalse() + } finally { + wedge.countDown() + encoder.join(SECONDS.toMillis(10)) + } + } + + @Test + fun `createVideoOf releases the encoder even when EOS is never signalled`() { + ReplayShadowMediaCodec.neverSignalEos = true + val replayCache = fixture.getSut(tmpDir) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + // the stall bound breaks the drain loop, but release() must still be called + replayCache.createVideoOf(1000L, 0L, 0, 100, 200, 1, 20_000) + + assertWithMessage("encoder should be released even when EOS was never signalled") + .that(ReplayShadowMediaCodec.released) + .isTrue() + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 7b17c77a3b9..32b7f4e9285 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -18,6 +18,8 @@ import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayOptions +import io.sentry.TypeCheckHint import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE @@ -63,6 +65,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -120,7 +123,6 @@ class ReplayIntegrationTest { context: Context, sessionSampleRate: Double = 1.0, onErrorSampleRate: Double = 1.0, - isOffline: Boolean = false, isRateLimited: Boolean = false, recorderProvider: (() -> Recorder)? = null, replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, @@ -130,9 +132,6 @@ class ReplayIntegrationTest { options.run { sessionReplay.onErrorSampleRate = onErrorSampleRate sessionReplay.sessionSampleRate = sessionSampleRate - connectionStatusProvider = mock { - on { connectionStatus }.thenReturn(if (isOffline) DISCONNECTED else CONNECTED) - } } if (isRateLimited) { whenever(rateLimiter.isActiveForCategory(any())).thenReturn(true) @@ -623,13 +622,13 @@ class ReplayIntegrationTest { context, recorderProvider = { recorder }, replayCaptureStrategyProvider = { captureStrategy }, - isOffline = true, ) replay.register(fixture.scopes, fixture.options) replay.start() replay.onScreenshotRecorded(mock()) + replay.onConnectionStatusChanged(DISCONNECTED) verify(recorder).pause() } @@ -750,9 +749,16 @@ class ReplayIntegrationTest { Random(), // run tasks synchronously in tests mock { - doAnswer { (it.arguments[0] as Runnable).run() } - .whenever(mock) - .submit(any()) + whenever(mock.submit(any())).doAnswer { + (it.arguments[0] as Runnable).run() + null + } + }, + mock { + whenever(mock.submit(any())).doAnswer { + (it.arguments[0] as Runnable).run() + null + } }, ) { _ -> fixture.replayCache @@ -902,10 +908,10 @@ class ReplayIntegrationTest { context, recorderProvider = { recorder }, replayCaptureStrategyProvider = { captureStrategy }, - isOffline = true, ) replay.register(fixture.scopes, fixture.options) + replay.onConnectionStatusChanged(DISCONNECTED) replay.start() replay.pause() @@ -972,6 +978,154 @@ class ReplayIntegrationTest { assertFalse(replay.isDebugMaskingOverlayEnabled) } + @Test + fun `snapshot observer is invoked with bitmap and metadata`() { + var callbackInvoked = false + var receivedTimestamp = 0L + var receivedScreen: String? = null + var receivedBitmap: Bitmap? = null + + val captureStrategy = + mock { + doAnswer { + ((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke( + fixture.replayCache, + 1720693523997, + ) + } + .whenever(mock) + .onScreenshotRecorded(anyOrNull(), any()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + fixture.scopes.configureScope { it.screen = "MainActivity" } + replay.register(fixture.scopes, fixture.options) + replay.start() + + fixture.options.sessionReplay.frameObserver = + SentryReplayOptions.ReplayFrameObserver { hint, frameTimestamp, screenName -> + callbackInvoked = true + receivedTimestamp = frameTimestamp + receivedScreen = screenName + receivedBitmap = hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) + } + + val copyBitmap = mock() + val sourceBitmap = + mock { + on { config } doReturn ARGB_8888 + on { copy(any(), any()) } doReturn copyBitmap + } + replay.onScreenshotRecorded(sourceBitmap) + + assertTrue(callbackInvoked) + assertEquals(1720693523997, receivedTimestamp) + assertEquals("MainActivity", receivedScreen) + assertEquals(copyBitmap, receivedBitmap) + } + + @Test + fun `snapshot observer exception does not prevent frame storage`() { + val captureStrategy = + mock { + doAnswer { + ((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke( + fixture.replayCache, + 1720693523997, + ) + } + .whenever(mock) + .onScreenshotRecorded(anyOrNull(), any()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + + fixture.options.sessionReplay.frameObserver = + SentryReplayOptions.ReplayFrameObserver { _, _, _ -> + throw RuntimeException("test") + } + + val sourceBitmap = + mock { + on { config } doReturn ARGB_8888 + on { copy(any(), any()) } doReturn mock() + } + replay.onScreenshotRecorded(sourceBitmap) + + verify(fixture.replayCache).addFrame(any(), any(), anyOrNull()) + } + + @Test + fun `snapshot observer is not invoked when null`() { + val captureStrategy = + mock { + doAnswer { + ((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke( + fixture.replayCache, + 1720693523997, + ) + } + .whenever(mock) + .onScreenshotRecorded(anyOrNull(), any()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + + replay.onScreenshotRecorded(mock()) + + verify(fixture.replayCache).addFrame(any(), any(), anyOrNull()) + } + + @Test + fun `registerTraceId does nothing when replay is not started`() { + val replay = fixture.getSut(context) + + replay.register(fixture.scopes, fixture.options) + // Don't call start() + + // Should not throw + replay.registerTraceId(SentryId()) + } + + @Test + fun `registerTraceId forwards to capture strategy when recording`() { + var traceIdRegistered: SentryId? = null + val captureStrategy = + mock { + on { currentReplayId }.thenReturn(SentryId()) + doAnswer { traceIdRegistered = it.arguments[0] as SentryId } + .whenever(mock) + .registerTraceId(any()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + + val traceId = SentryId() + replay.registerTraceId(traceId) + + assertEquals(traceId, traceIdRegistered) + } + + @Test + fun `close shuts down replay executors`() { + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay = fixture.getSut(context) + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.stop() + replay.close() + + assertTrue(replay.replayExecutor.isShutdown) + assertTrue(replay.persistingExecutor.isShutdown) + } + private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy = SessionCaptureStrategy( options, @@ -979,7 +1133,17 @@ class ReplayIntegrationTest { CurrentDateProvider.getInstance(), executor = mock { - doAnswer { (it.arguments[0] as Runnable).run() }.whenever(mock).submit(any()) + whenever(mock.submit(any())).doAnswer { + (it.arguments[0] as Runnable).run() + null + } + }, + persistingExecutor = + mock { + whenever(mock.submit(any())).doAnswer { + (it.arguments[0] as Runnable).run() + null + } }, ) } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index c26e6be9c41..b5e15b5534f 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -23,13 +23,16 @@ import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.transport.RateLimiter import java.time.Duration +import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.BeforeTest import kotlin.test.assertEquals import kotlin.test.assertNotEquals +import kotlin.test.assertTrue import org.awaitility.core.ConditionTimeoutException import org.awaitility.kotlin.await import org.junit.Rule @@ -41,6 +44,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -61,11 +65,17 @@ class ReplaySmokeTest { internal class Fixture { val options = SentryOptions() val scope = Scope(options) + val rateLimiter = + mock { + on { isActiveForCategory(any()) }.thenReturn(false) + } val scopes = mock { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(it) .configureScope(any()) + + on { rateLimiter }.doReturn(rateLimiter) } private class ImmediateHandler : @@ -91,7 +101,10 @@ class ReplaySmokeTest { mainLooperHandler = mock { whenever(mock.handler).thenReturn(ImmediateHandler()) - whenever(mock.post(any())).then { (it.arguments[0] as Runnable).run() } + whenever(mock.post(any())).then { + (it.arguments[0] as Runnable).run() + true + } whenever(mock.postDelayed(any(), anyLong())).then { // have to use another thread here otherwise it will block the test thread recordingThread.schedule( @@ -243,6 +256,45 @@ class ReplaySmokeTest { assertNotEquals(falseReplay.rootViewsSpy, replay.rootViewsSpy) assertEquals(0, falseReplay.rootViewsSpy.listeners.size) } + + @Test + fun `close does not deadlock when executor task is waiting on lifecycleLock`() { + fixture.options.sessionReplay.sessionSampleRate = 1.0 + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay = fixture.getSut(context) + replay.register(fixture.scopes, fixture.options) + replay.start() + + val taskBlocked = CountDownLatch(1) + val lockReleased = CountDownLatch(1) + + // hold lifecycleLock on this thread + val token = replay.lifecycleLock.acquire() + + // submit a task on the executor that tries to acquire the same lock — it will block + replay.replayExecutor.submit { + taskBlocked.countDown() + replay.lifecycleLock.acquire().use {} + } + + // wait for the executor task to actually be running and blocked + assertTrue(taskBlocked.await(2, TimeUnit.SECONDS)) + + // release the lock, then close — if shutdown were inside the lock this would deadlock + token.close() + + // close() must complete within a reasonable time + val closedInTime = AtomicBoolean(false) + val closeThread = Thread { + replay.close() + closedInTime.set(true) + } + closeThread.start() + closeThread.join(5000) + + assertTrue(closedInTime.get(), "close() deadlocked") + } } private class ExampleActivity : Activity() { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt new file mode 100644 index 00000000000..00b58666669 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt @@ -0,0 +1,88 @@ +package io.sentry.android.replay + +import android.os.Handler +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ScreenshotStrategyType +import io.sentry.SentryOptions +import io.sentry.android.replay.ReplaySmokeTest.Fixture +import io.sentry.android.replay.screenshot.CanvasStrategy +import io.sentry.android.replay.screenshot.PixelCopyStrategy +import io.sentry.android.replay.screenshot.ScreenshotStrategy +import io.sentry.android.replay.util.MainLooperHandler +import java.util.concurrent.ScheduledExecutorService +import kotlin.test.Test +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class ScreenshotRecorderTest { + + internal class Fixture() { + + fun getSut(config: (options: SentryOptions) -> Unit = {}): ScreenshotRecorder { + val options = SentryOptions() + config(options) + return ScreenshotRecorder( + ScreenshotRecorderConfig(100, 100, 1f, 1f, 1, 1000), + options, + object : ExecutorProvider { + override fun getExecutor(): ScheduledExecutorService = mock() + + override fun getMainLooperHandler(): MainLooperHandler = mock() + + override fun getBackgroundHandler(): Handler = mock() + }, + null, + ) + } + } + + private val fixture = Fixture() + + @Test + fun `when config uses PIXEL_COPY strategy, ScreenshotRecorder creates PixelCopyStrategy`() { + val recorder = fixture.getSut { options -> + options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY + } + + val strategy = getStrategy(recorder) + + assertTrue( + strategy is PixelCopyStrategy, + "Expected PixelCopyStrategy but got ${strategy::class.simpleName}", + ) + } + + @Test + fun `when config uses CANVAS strategy, ScreenshotRecorder creates CanvasStrategy`() { + val recorder = fixture.getSut { options -> + options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.CANVAS + } + val strategy = getStrategy(recorder) + + assertTrue( + strategy is CanvasStrategy, + "Expected CanvasStrategy but got ${strategy::class.simpleName}", + ) + } + + @Test + fun `when config uses default strategy, ScreenshotRecorder creates PixelCopyStrategy`() { + val recorder = fixture.getSut() + val strategy = getStrategy(recorder) + + assertTrue( + strategy is PixelCopyStrategy, + "Expected PixelCopyStrategy as default but got ${strategy::class.simpleName}", + ) + } + + private fun getStrategy(recorder: ScreenshotRecorder): ScreenshotStrategy { + val strategyField = ScreenshotRecorder::class.java.getDeclaredField("screenshotStrategy") + strategyField.isAccessible = true + return strategyField.get(recorder) as ScreenshotStrategy + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index b3fb9058a95..fc1981a84b1 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -2,9 +2,12 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import android.view.MotionEvent +import io.sentry.DataCategory import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.SentryEnvelope +import io.sentry.SentryEnvelopeHeader import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.DefaultReplayBreadcrumbConverter @@ -17,9 +20,12 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayFrame import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.BufferCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION +import io.sentry.clientreport.DiscardReason +import io.sentry.clientreport.DiscardedEvent import io.sentry.protocol.SentryId import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.transport.RateLimiter import io.sentry.util.Random import java.io.File import kotlin.test.Test @@ -93,6 +99,16 @@ class BufferCaptureStrategyTest { bitRate = 20_000, ) + // client report counts are only readable by draining them onto an envelope + fun discardedEvents(): List = + options.clientReportRecorder + .attachReportToEnvelope(SentryEnvelope(SentryEnvelopeHeader(), emptyList())) + .items + .firstOrNull() + ?.getClientReport(options.serializer) + ?.discardedEvents + .orEmpty() + fun getSut( onErrorSampleRate: Double = 1.0, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), @@ -106,12 +122,16 @@ class BufferCaptureStrategyTest { dateProvider, Random(), mock { - doAnswer { invocation -> - (invocation.arguments[0] as Runnable).run() - null - } - .whenever(it) - .submit(any()) + whenever(it.submit(any())).doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + } + }, + mock { + whenever(it.submit(any())).doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + } }, ) { _ -> replayCache @@ -235,6 +255,19 @@ class BufferCaptureStrategyTest { assertTrue(converted is BufferCaptureStrategy) } + @Test + fun `convert stays in buffer mode when rate-limited`() { + val rateLimiter = mock { on { isActiveForCategory(any()) }.thenReturn(true) } + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) + val strategy = fixture.getSut() + strategy.start() + + strategy.captureReplay(false) {} + + val converted = strategy.convert() + assertTrue(converted is BufferCaptureStrategy) + } + @Test fun `convert converts to session strategy and sets replayId to scope`() { val strategy = fixture.getSut() @@ -332,6 +365,52 @@ class BufferCaptureStrategyTest { assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) } + @Test + fun `captureReplay does not capture segments when rate-limited`() { + val rateLimiter = mock { on { isActiveForCategory(any()) }.thenReturn(true) } + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + strategy.pause() + + strategy.captureReplay(false) {} + + // neither the current nor the buffered segment should be sent while rate-limited + verify(fixture.scopes, never()).captureReplay(any(), any()) + // the replayId is still set on the scope so the error that flushed the buffer stays linked to + // the replay that gets recorded once the rate limit lifts + assertEquals(strategy.currentReplayId, fixture.scope.replayId) + } + + @Test + fun `captureReplay records a lost replay event when rate-limited`() { + val rateLimiter = mock { on { isActiveForCategory(any()) }.thenReturn(true) } + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + strategy.pause() + + strategy.captureReplay(false) {} + + val discarded = fixture.discardedEvents() + assertEquals(1, discarded.size) + assertEquals(DiscardReason.RATELIMIT_BACKOFF.reason, discarded.first().reason) + assertEquals(DataCategory.Replay.category, discarded.first().category) + } + + @Test + fun `captureReplay does not record a lost replay event when not rate-limited`() { + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.captureReplay(false) {} + + assertTrue(fixture.discardedEvents().none { it.category == DataCategory.Replay.category }) + } + @Test fun `captureReplay sets replayId to scope and captures buffered segments`() { var called = false @@ -356,6 +435,7 @@ class BufferCaptureStrategyTest { strategy.onConfigurationChanged(fixture.recorderConfig) val oldTimestamp = strategy.segmentTimestamp + whenever(fixture.replayCache.firstFrameTimestamp()).thenReturn(oldTimestamp!!.time) strategy.captureReplay(false) { newTimestamp -> assertEquals(oldTimestamp!!.time + VIDEO_DURATION, newTimestamp.time) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index af30a5b73f7..fc2354eb1c0 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -122,6 +122,14 @@ class SessionCaptureStrategyTest { .whenever(it) .submit(any()) }, + mock { + doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + } + .whenever(it) + .submit(any()) + }, ) { _ -> replayCache } @@ -437,6 +445,7 @@ class SessionCaptureStrategyTest { "android.widget.TextView", "android.webkit.WebView", "android.widget.VideoView", + "androidx.camera.view.PreviewView", "androidx.media3.ui.PlayerView", "com.google.android.exoplayer2.ui.PlayerView", "com.google.android.exoplayer2.ui.StyledPlayerView", @@ -474,4 +483,137 @@ class SessionCaptureStrategyTest { }, ) } + + @Test + fun `registerTraceId includes trace IDs in next segment`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + val traceId1 = SentryId() + val traceId2 = SentryId() + strategy.registerTraceId(traceId1) + strategy.registerTraceId(traceId2) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && + event.traceIds?.size == 2 && + event.traceIds!!.contains(traceId1.toString()) && + event.traceIds!!.contains(traceId2.toString()) + }, + any(), + ) + } + + @Test + fun `registerTraceId clears trace IDs after segment is created`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + val traceId = SentryId() + strategy.registerTraceId(traceId) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && event.traceIds?.contains(traceId.toString()) == true + }, + any(), + ) + + // trigger another segment, trace IDs should be cleared + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 1 && event.traceIds.isNullOrEmpty() + }, + any(), + ) + } + + @Test + fun `registerTraceId ignores empty trace ID`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.registerTraceId(SentryId.EMPTY_ID) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> event is SentryReplayEvent && event.traceIds.isNullOrEmpty() }, + any(), + ) + } + + @Test + fun `registerSegmentName includes unique segment names in next segment and clears them`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.registerSegmentName("CheckoutActivity") + strategy.registerSegmentName("CheckoutActivity") + strategy.registerSegmentName("ProductDetailsActivity") + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && + event.segmentNames == listOf("CheckoutActivity", "ProductDetailsActivity") + }, + any(), + ) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 1 && event.segmentNames.isNullOrEmpty() + }, + any(), + ) + } + + @Test + fun `registerSegmentName ignores empty names and limits names to 100`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.registerSegmentName("") + repeat(101) { strategy.registerSegmentName("ProductActivity$it") } + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> event is SentryReplayEvent && event.segmentNames?.size == 100 }, + any(), + ) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt index bf3f9cb8443..6f5a02b54c7 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt @@ -5,6 +5,7 @@ import android.app.Activity import android.os.Bundle import android.view.MotionEvent import android.view.View +import android.view.Window import android.widget.LinearLayout import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.SentryOptions @@ -14,6 +15,7 @@ import io.sentry.android.replay.phoneWindow import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.runner.RunWith import org.robolectric.Robolectric @@ -37,17 +39,44 @@ class GestureRecorderTest { } @Test - fun `when new window added and window callback is already wrapped, does not wrap it again`() { + fun `does not double-wrap when root is added twice and another callback wraps on top`() { val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get() val gestureRecorder = fixture.getSut() - activity.root.phoneWindow?.callback = SentryReplayGestureRecorder(fixture.options, null, null) gestureRecorder.onRootViewsChanged(activity.root, true) + val ourWrapper = activity.root.phoneWindow?.callback as SentryReplayGestureRecorder - assertFalse( - (activity.root.phoneWindow?.callback as SentryReplayGestureRecorder).delegate - is SentryReplayGestureRecorder - ) + val outer = WrapperCallback(ourWrapper) + activity.root.phoneWindow?.callback = outer + + gestureRecorder.onRootViewsChanged(activity.root, true) + + assertSame(outer, activity.root.phoneWindow?.callback) + } + + @Test + fun `when stopped with another wrapper on top, inerts the buried recorder`() { + var called = false + val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get() + val gestureRecorder = + fixture.getSut( + touchRecorderCallback = + object : TouchRecorderCallback { + override fun onTouchEvent(event: MotionEvent) { + called = true + } + } + ) + + gestureRecorder.onRootViewsChanged(activity.root, true) + val ourWrapper = activity.root.phoneWindow?.callback as SentryReplayGestureRecorder + activity.root.phoneWindow?.callback = WrapperCallback(ourWrapper) + + gestureRecorder.onRootViewsChanged(activity.root, false) + + val motionEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0) + ourWrapper.dispatchTouchEvent(motionEvent) + assertFalse(called) } @Test @@ -109,6 +138,9 @@ class GestureRecorderTest { } } +private open class WrapperCallback(@JvmField val delegate: Window.Callback) : + Window.Callback by delegate + private class TestActivity : Activity() { lateinit var root: View diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt new file mode 100644 index 00000000000..587927e9793 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt @@ -0,0 +1,672 @@ +package io.sentry.android.replay.screenshot + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import android.graphics.RectF +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.SurfaceView +import android.view.View +import android.view.Window +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.replay.ExecutorProvider +import io.sentry.android.replay.ScreenshotRecorderCallback +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.CompletedFuture +import io.sentry.android.replay.util.DebugOverlayDrawable +import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.ReplayRunnable +import java.util.concurrent.Future +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric.buildActivity +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowPixelCopy + +@Config(shadows = [ShadowPixelCopy::class], sdk = [30]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@RunWith(AndroidJUnit4::class) +class PixelCopyStrategyTest { + + private class Fixture { + val options = SentryOptions() + val callback = mock() + val debugOverlayDrawable = mock() + val config = ScreenshotRecorderConfig(100, 100, 1f, 1f, 1, 1000) + val contentChangedMarked = AtomicBoolean(false) + + fun getSut(executor: ScheduledExecutorService = mock()): PixelCopyStrategy { + return PixelCopyStrategy( + object : ExecutorProvider { + override fun getExecutor(): ScheduledExecutorService = executor + + override fun getMainLooperHandler(): MainLooperHandler = MainLooperHandler() + + override fun getBackgroundHandler(): Handler = mock() + }, + callback, + options, + config, + debugOverlayDrawable, + markContentChanged = { contentChangedMarked.set(true) }, + ) + } + + /** Executor mock that runs submitted tasks synchronously on the calling thread. */ + fun inlineExecutor(): ScheduledExecutorService { + return mock { + doAnswer { + (it.arguments[0] as Runnable).run() + // Mirror ReplayExecutorService's inline contract: a completed future, not null. Null + // means "rejected" and would make capture() run its null-fallback finishFrame on top of + // the task's own, a double-release production never does on the inline path. + CompletedFuture + } + .whenever(mock) + .submit(any()) + } + } + } + + private val fixture = Fixture() + + @BeforeTest + fun setup() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + System.setProperty("robolectric.pixelCopyRenderMode", "hardware") + DeferredWindowPixelCopyShadow.reset() + } + + @Test + fun `when strategy is closed, lastCaptureSuccessful returns false`() { + val strategy = fixture.getSut() + + strategy.close() + + assertFalse(strategy.lastCaptureSuccessful()) + } + + @Test + fun `when close races the mask task, masking is skipped and no screenshot is emitted`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + + var strategy: PixelCopyStrategy? = null + + val failure = AtomicReference() + // Custom executor that closes the strategy right before running the mask task, to simulate + // close() racing an in-flight mask task. We key off the mask task specifically (not "the first + // submit") because close() itself submits the cleanup task — closing again when that runs would + // recurse via close() -> scheduleCleanup() -> submit(), a loop no real code path can produce. + val executorThatClosesFirst = mock() + whenever(executorThatClosesFirst.submit(any())).doAnswer { + val task = it.getArgument(0) + if ((task as? ReplayRunnable)?.taskName == "screenshot_recorder.mask") { + strategy?.close() + } + try { + task.run() + } catch (e: Throwable) { + // PixelCopyStrategy swallows the exception, so we have to capture it here and rethrow later + failure.set(e) + } + CompletedFuture + } + + strategy = fixture.getSut(executor = executorThatClosesFirst) + strategy.capture(activity.get().findViewById(android.R.id.content)) + shadowOf(Looper.getMainLooper()).idle() + + if (failure.get() != null) throw failure.get() + // close() landed before masking ran, so applyMaskingAndNotify must bail out early and never + // hand a screenshot to the callback after the strategy is closed. + verify(fixture.callback, never()).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture drops frame while PixelCopy is in flight`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + + strategy.capture(root) + strategy.capture(root) + + assertTrue(fixture.contentChangedMarked.get()) + + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(fixture.callback).onScreenshotRecorded(any()) + + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture drops frame while masking is in flight`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val tasks = mutableListOf() + val executor = mock() + whenever(executor.submit(any())).doAnswer { + tasks += it.getArgument(0) + mock>() + } + val strategy = fixture.getSut(executor) + + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, tasks.size) + tasks.removeAt(0).run() + + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, tasks.size) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `emitLastScreenshot skips while frame is in flight`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + captureStableFrame(strategy, root) + + strategy.capture(root) + strategy.emitLastScreenshot() + + verify(fixture.callback).onScreenshotRecorded(any()) + + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `emitLastScreenshot holds the frame gate until the emit task drains`() { + // emit submits the consumer call to the executor so the bitmap read (JPEG compress) runs + // inline on the worker thread while the gate is held — same pattern as the masked capture path. + // Invariant: while the emit task is still queued (gate held), a racing capture is dropped. + // Without the gate (old `if (!frameInFlight.get())`) that capture proceeds -> extra frame. + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val tasks = mutableListOf() + val executor = mock() + whenever(executor.submit(any())).doAnswer { + tasks.add(it.arguments[0] as Runnable) + mock>() + } + val strategy = fixture.getSut(executor) + + // Set up a successful last capture: capture -> queued mask task -> drain releases the gate. + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + tasks.removeAll { + it.run() + true + } + verify(fixture.callback, times(1)).onScreenshotRecorded(any()) + + // Emit takes the gate and queues the consumer task (still pending). + strategy.emitLastScreenshot() + // Callback hasn't fired yet — the task is queued, not drained. + verify(fixture.callback, times(1)).onScreenshotRecorded(any()) + + // A capture racing in before the emit task drains must be dropped (gate held). + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + verify(fixture.callback, times(1)).onScreenshotRecorded(any()) + + // Drain the emit task -> callback fires, gate released -> captures resume. + tasks.removeAll { + it.run() + true + } + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + captureStableFrame(strategy, root) + tasks.removeAll { + it.run() + true + } + verify(fixture.callback, times(3)).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `close defers cleanup until PixelCopy completes`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val executor = mock() + val strategy = fixture.getSut(executor) + + strategy.capture(root) + strategy.close() + + verify(executor, never()).submit(any()) + + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(executor).submit(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `close-triggered cleanup keeps the frame gate so a racing capture cannot double-clean up`() { + // Guards the CAS handoff in finishFrame(). The real race is a 3-thread interleave (a new + // capture takes the gate the instant finishFrame releases it, then the old finishFrame recycles + // the bitmap the new capture is writing) and isn't deterministically reproducible single- + // threaded. This exercises its observable invariant instead: when finishFrame cleans up on + // close, it must re-take the gate (frameInFlight stays held), so any later capture is dropped + // rather than sneaking through to schedule a *second* cleanup on the shared screenshot. + // Without the CAS (plain frameInFlight.set(false)) the gate is left free and the follow-up + // capture reaches the isClosed guard and schedules cleanup again -> 2 submits. + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val executor = mock() + whenever(executor.submit(any())).thenReturn(mock>()) + val strategy = fixture.getSut(executor) + + strategy.capture(root) + strategy.close() // in-flight -> cleanup deferred, no submit yet + + // PixelCopy completes; the callback sees isClosed and runs finishFrame -> the one cleanup. + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + // A capture racing in after close must be dropped (gate still held), not schedule cleanup + // again. + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(executor, times(1)).submit(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `idle close claims the gate so a racing capture cannot schedule a second cleanup`() { + // Mirror of the finishFrame guard, but for close()'s idle path (no frame in flight). close() + // must atomically claim the gate before scheduling cleanup; otherwise a capture racing in right + // after the check can take the gate, see isClosed, run finishFrame and schedule cleanup a + // second + // time. Both cleanups are idempotent, but a single submit is the invariant we keep uniform. + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val executor = mock() + whenever(executor.submit(any())).thenReturn(mock>()) + val strategy = fixture.getSut(executor) + + strategy.close() // idle -> claims gate, schedules the one cleanup + // A capture landing after close must be dropped (gate held), not schedule cleanup again. + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(executor, times(1)).submit(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `frame gate is released when masking submit is rejected`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + // Simulate an already-shutdown executor: submit returns null. + val executor = mock() + whenever(executor.submit(any())).thenReturn(null) + val strategy = fixture.getSut(executor) + + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + // Gate must have been released; a follow-up capture should proceed rather than being dropped. + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(executor, times(2)).submit(any()) + } + + @Test + fun `close cleans up inline when executor is already shut down`() { + // submit returns null → previously the bitmap + maskRenderer would leak. + val executor = mock() + whenever(executor.submit(any())).thenReturn(null) + val strategy = fixture.getSut(executor) + + strategy.close() + + // No crash and the submit was attempted exactly once (cleanup ran inline as fallback). + verify(executor).submit(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture skips the first unstable PixelCopy result`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + captureUnstableFrame(strategy, root) + + assertFalse(strategy.lastCaptureSuccessful()) + verify(fixture.callback, never()).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture emits the second consecutive unstable PixelCopy result`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + captureUnstableFrame(strategy, root) + captureUnstableFrame(strategy, root) + + assertTrue(strategy.lastCaptureSuccessful()) + verify(fixture.callback).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture keeps emitting after entering continuous instability mode`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + captureUnstableFrame(strategy, root) + captureUnstableFrame(strategy, root) + captureUnstableFrame(strategy, root) + + assertTrue(strategy.lastCaptureSuccessful()) + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `stable capture resets the unstable PixelCopy counter`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + captureUnstableFrame(strategy, root) + captureUnstableFrame(strategy, root) + captureStableFrame(strategy, root) + captureUnstableFrame(strategy, root) + + assertFalse(strategy.lastCaptureSuccessful()) + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + } + + @Test + fun `capture does not call markContentChanged when option is disabled`() { + val activity = buildActivity(ActivityWithSurfaceView::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + + // Default: isCaptureSurfaceViews = false + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + strategy.capture(activity.get().findViewById(android.R.id.content)) + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(fixture.contentChangedMarked.get()) + assertTrue(strategy.lastCaptureSuccessful()) + val screenshot = argumentCaptor() + verify(fixture.callback).onScreenshotRecorded(screenshot.capture()) + assertEquals(Bitmap.Config.RGB_565, screenshot.firstValue.config) + } + + @Test + fun `capture re-arms contentChanged when option is enabled and SurfaceView is present`() { + val activity = buildActivity(ActivityWithSurfaceView::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + + fixture.options.sessionReplay.isCaptureSurfaceViews = true + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + strategy.capture(activity.get().findViewById(android.R.id.content)) + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(fixture.contentChangedMarked.get()) + } + + @Test + fun `capture completes when SurfaceView surface is not valid`() { + // In Robolectric the SurfaceView holder surface is not valid — this exercises the + // `surfaceView.holder.surface.isValid == false` branch: each SurfaceView skips its + // PixelCopy and onCaptureComplete still fires, eventually running the compositor and + // callback. + val activity = buildActivity(ActivityWithSurfaceView::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + fixture.options.sessionReplay.isCaptureSurfaceViews = true + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + strategy.capture(activity.get().findViewById(android.R.id.content)) + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(strategy.lastCaptureSuccessful()) + val screenshot = argumentCaptor() + verify(fixture.callback).onScreenshotRecorded(screenshot.capture()) + assertEquals(Bitmap.Config.ARGB_8888, screenshot.firstValue.config) + } + + @Test + fun `compositeSurfaceViewInto draws source behind existing destination with DST_OVER`() { + // Destination ("Window capture"): 100x100, opaque red in the top half, + // fully transparent in the bottom half (the "hole" where the SurfaceView sits). + val dest = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + val destCanvas = Canvas(dest) + destCanvas.drawColor(Color.RED) + val clearPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) } + destCanvas.drawRect(0f, 50f, 100f, 100f, clearPaint) + + // Source ("SurfaceView capture"): 100x50, solid blue — matches the hole. + val source = Bitmap.createBitmap(100, 50, Bitmap.Config.ARGB_8888) + source.eraseColor(Color.BLUE) + + val dstOverPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } + compositeSurfaceViewInto( + destCanvas = destCanvas, + destPaint = dstOverPaint, + tmpSrc = Rect(), + tmpDst = RectF(), + sourceBitmap = source, + sourceX = 0, + sourceY = 50, + windowX = 0, + windowY = 0, + scaleFactorX = 1f, + scaleFactorY = 1f, + ) + + // Top region: still red (DST_OVER must not overwrite existing opaque pixels). + assertEquals(Color.RED, dest.getPixel(50, 10)) + assertEquals(Color.RED, dest.getPixel(50, 49)) + // Bottom region: now blue (source filled the transparent hole). + assertEquals(Color.BLUE, dest.getPixel(50, 50)) + assertEquals(Color.BLUE, dest.getPixel(99, 99)) + } + + @Test + fun `compositeSurfaceViewInto respects scale factors and window offset`() { + // Destination is 50x50 (scaled recording), fully transparent. + val dest = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888) + val destCanvas = Canvas(dest) + + // Source is 40x40, solid green; its on-screen location is (20, 20). + val source = Bitmap.createBitmap(40, 40, Bitmap.Config.ARGB_8888) + source.eraseColor(Color.GREEN) + + val dstOverPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } + compositeSurfaceViewInto( + destCanvas = destCanvas, + destPaint = dstOverPaint, + tmpSrc = Rect(), + tmpDst = RectF(), + sourceBitmap = source, + sourceX = 20, + sourceY = 20, + windowX = 10, // window is at (10, 10) + windowY = 10, + scaleFactorX = 0.5f, // 0.5x scale → destination coords halve + scaleFactorY = 0.5f, + ) + + // Expected destination rect: ((20-10)*0.5, (20-10)*0.5) = (5, 5), size 40*0.5 = 20x20 + // → occupies pixels [5..25) × [5..25). Check inside, on the edge, and just outside. + assertEquals(Color.GREEN, dest.getPixel(5, 5)) + assertEquals(Color.GREEN, dest.getPixel(15, 15)) + assertEquals(Color.GREEN, dest.getPixel(24, 24)) + // Just outside the rect — still transparent. + assertEquals(0, dest.getPixel(4, 4)) + assertEquals(0, dest.getPixel(25, 25)) + } + + private fun captureUnstableFrame(strategy: PixelCopyStrategy, root: View) { + strategy.capture(root) + strategy.onContentChanged() + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + } + + private fun captureStableFrame(strategy: PixelCopyStrategy, root: View) { + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + } +} + +@Implements(PixelCopy::class) +class DeferredWindowPixelCopyShadow { + companion object { + private val pendingCallbacks = mutableListOf<() -> Unit>() + + fun reset() { + pendingCallbacks.clear() + } + + fun flush() { + val callbacks = pendingCallbacks.toList() + pendingCallbacks.clear() + callbacks.forEach { it.invoke() } + } + + @JvmStatic + @Implementation + @Suppress("UNUSED_PARAMETER") + fun request( + _source: Window, + _dest: Bitmap, + listener: PixelCopy.OnPixelCopyFinishedListener, + listenerThread: Handler, + ) { + pendingCallbacks.add { + listenerThread.post { listener.onPixelCopyFinished(PixelCopy.SUCCESS) } + } + } + } +} + +private class SimpleActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = + LinearLayout(this).apply { + setBackgroundColor(android.R.color.white) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + + val textView = + TextView(this).apply { + text = "Hello, World!" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + linearLayout.addView(textView) + + setContentView(linearLayout) + } +} + +private class ActivityWithSurfaceView : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val root = + FrameLayout(this).apply { + setBackgroundColor(android.R.color.white) + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + } + root.addView( + TextView(this).apply { + text = "Overlay" + layoutParams = FrameLayout.LayoutParams(200, 50) + } + ) + root.addView(SurfaceView(this).apply { layoutParams = FrameLayout.LayoutParams(200, 200) }) + setContentView(root) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/MaskRendererTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/MaskRendererTest.kt new file mode 100644 index 00000000000..48b98b50c03 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/MaskRendererTest.kt @@ -0,0 +1,326 @@ +package io.sentry.android.replay.util + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Rect +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class MaskRendererTest { + + @Test + fun `renderMasks returns empty list for recycled bitmap`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + bitmap.recycle() + + val rootNode = + GenericViewHierarchyNode( + x = 0f, + y = 0f, + width = 100, + height = 100, + elevation = 0f, + distance = 0, + shouldMask = true, + isVisible = true, + visibleRect = Rect(0, 0, 100, 100), + ) + + val renderer = MaskRenderer() + val result = renderer.renderMasks(bitmap, rootNode, null) + + assertTrue(result.isEmpty()) + renderer.close() + } + + @Test + fun `renderMasks masks GenericViewHierarchyNode`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(Color.WHITE) + + val rootNode = + GenericViewHierarchyNode( + x = 0f, + y = 0f, + width = 100, + height = 100, + elevation = 0f, + distance = 0, + shouldMask = true, + isVisible = true, + visibleRect = Rect(10, 10, 90, 90), + ) + + val renderer = MaskRenderer() + val result = renderer.renderMasks(bitmap, rootNode, null) + + assertEquals(1, result.size) + assertEquals(Rect(10, 10, 90, 90), result[0]) + renderer.close() + } + + @Test + fun `renderMasks masks ImageViewHierarchyNode with dominant color`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(Color.RED) + + val imageNode = + ImageViewHierarchyNode( + x = 0f, + y = 0f, + width = 100, + height = 100, + elevation = 0f, + distance = 0, + shouldMask = true, + isVisible = true, + visibleRect = Rect(0, 0, 100, 100), + ) + + val renderer = MaskRenderer() + val result = renderer.renderMasks(bitmap, imageNode, null) + + assertEquals(1, result.size) + renderer.close() + } + + @Test + fun `renderMasks masks TextViewHierarchyNode with text color`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(Color.WHITE) + + val textNode = + TextViewHierarchyNode( + layout = null, + dominantColor = Color.BLUE, + paddingLeft = 0, + paddingTop = 0, + x = 0f, + y = 0f, + width = 100, + height = 50, + elevation = 0f, + distance = 0, + shouldMask = true, + isVisible = true, + visibleRect = Rect(0, 0, 100, 50), + ) + + val renderer = MaskRenderer() + val result = renderer.renderMasks(bitmap, textNode, null) + + assertEquals(1, result.size) + renderer.close() + } + + @Test + fun `renderMasks skips nodes with shouldMask false`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + + val rootNode = + GenericViewHierarchyNode( + x = 0f, + y = 0f, + width = 100, + height = 100, + elevation = 0f, + distance = 0, + shouldMask = false, + isVisible = true, + visibleRect = Rect(0, 0, 100, 100), + ) + + val renderer = MaskRenderer() + val result = renderer.renderMasks(bitmap, rootNode, null) + + assertTrue(result.isEmpty()) + renderer.close() + } + + @Test + fun `renderMasks skips nodes with zero dimensions`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + + val rootNode = + GenericViewHierarchyNode( + x = 0f, + y = 0f, + width = 0, + height = 0, + elevation = 0f, + distance = 0, + shouldMask = true, + isVisible = true, + visibleRect = Rect(0, 0, 0, 0), + ) + + val renderer = MaskRenderer() + val result = renderer.renderMasks(bitmap, rootNode, null) + + assertTrue(result.isEmpty()) + renderer.close() + } + + @Test + fun `renderMasks skips nodes with null visibleRect`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + + val rootNode = + GenericViewHierarchyNode( + x = 0f, + y = 0f, + width = 100, + height = 100, + elevation = 0f, + distance = 0, + shouldMask = true, + isVisible = true, + visibleRect = null, + ) + + val renderer = MaskRenderer() + val result = renderer.renderMasks(bitmap, rootNode, null) + + assertTrue(result.isEmpty()) + renderer.close() + } + + @Test + fun `renderMasks applies scale matrix when provided`() { + val bitmap = Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(Color.WHITE) + + val rootNode = + GenericViewHierarchyNode( + x = 0f, + y = 0f, + width = 100, + height = 100, + elevation = 0f, + distance = 0, + shouldMask = true, + isVisible = true, + visibleRect = Rect(0, 0, 100, 100), + ) + + val scaleMatrix = Matrix().apply { preScale(0.5f, 0.5f) } + + val renderer = MaskRenderer() + val result = renderer.renderMasks(bitmap, rootNode, scaleMatrix) + + assertEquals(1, result.size) + renderer.close() + } + + @Test + fun `renderMasks traverses child nodes`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(Color.WHITE) + + val childNode = + GenericViewHierarchyNode( + x = 10f, + y = 10f, + width = 30, + height = 30, + elevation = 0f, + distance = 1, + shouldMask = true, + isVisible = true, + visibleRect = Rect(10, 10, 40, 40), + ) + + val rootNode = + GenericViewHierarchyNode( + x = 0f, + y = 0f, + width = 100, + height = 100, + elevation = 0f, + distance = 0, + shouldMask = false, + isVisible = true, + visibleRect = Rect(0, 0, 100, 100), + ) + rootNode.children = listOf(childNode) + + val renderer = MaskRenderer() + val result = renderer.renderMasks(bitmap, rootNode, null) + + assertEquals(1, result.size) + assertEquals(Rect(10, 10, 40, 40), result[0]) + renderer.close() + } + + @Test + fun `renderMasks masks multiple nodes`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(Color.WHITE) + + val child1 = + GenericViewHierarchyNode( + x = 0f, + y = 0f, + width = 40, + height = 40, + elevation = 0f, + distance = 1, + shouldMask = true, + isVisible = true, + visibleRect = Rect(0, 0, 40, 40), + ) + + val child2 = + GenericViewHierarchyNode( + x = 50f, + y = 50f, + width = 40, + height = 40, + elevation = 0f, + distance = 2, + shouldMask = true, + isVisible = true, + visibleRect = Rect(50, 50, 90, 90), + ) + + val rootNode = + GenericViewHierarchyNode( + x = 0f, + y = 0f, + width = 100, + height = 100, + elevation = 0f, + distance = 0, + shouldMask = false, + isVisible = true, + visibleRect = Rect(0, 0, 100, 100), + ) + rootNode.children = listOf(child1, child2) + + val renderer = MaskRenderer() + val result = renderer.renderMasks(bitmap, rootNode, null) + + assertEquals(2, result.size) + renderer.close() + } + + @Test + fun `close recycles internal bitmap`() { + val renderer = MaskRenderer() + // Trigger lazy initialization + renderer.singlePixelBitmap + + renderer.close() + assertTrue(renderer.singlePixelBitmap.isRecycled) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt index f60c6688386..e0e13076ea0 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt @@ -3,6 +3,7 @@ package io.sentry.android.replay.util import android.media.MediaCodec import android.media.MediaCodec.BufferInfo import java.nio.ByteBuffer +import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit.MICROSECONDS import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean @@ -15,17 +16,44 @@ class ReplayShadowMediaCodec : ShadowMediaCodec() { companion object { var frameRate = 1 var framesToEncode = 5 + var throwOnStart = false + + /** Simulates an encoder that never emits [MediaCodec.BUFFER_FLAG_END_OF_STREAM]. */ + var neverSignalEos = false + + /** + * When set, [dequeueOutputBuffer] awaits this latch, simulating a native call that never + * returns. [blockedOnDequeue] is counted down right before, so tests can wait until the codec + * is actually stuck. + */ + var blockOnDequeue: CountDownLatch? = null + + var blockedOnDequeue = CountDownLatch(1) + + /** Set to `true` when [release] is called. */ + var released = false } private val encoded = AtomicBoolean(false) + @Implementation + fun release() { + released = true + } + @Implementation fun start() { + if (throwOnStart) { + throw IllegalStateException("Simulated codec start failure") + } super.native_start() } @Implementation fun signalEndOfInputStream() { + if (neverSignalEos) { + return + } encodeFrame(framesToEncode, frameRate, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) } @@ -33,6 +61,10 @@ class ReplayShadowMediaCodec : ShadowMediaCodec() { @Implementation fun dequeueOutputBuffer(info: BufferInfo, timeoutUs: Long): Int { + blockOnDequeue?.let { + blockedOnDequeue.countDown() + it.await() + } val encoderStatus = super.native_dequeueOutputBuffer(info, timeoutUs) super.validateOutputByteBuffer(getOutputBuffers(), encoderStatus, info) if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER && !encoded.getAndSet(true)) { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt index 44383330f1e..bdbf31aea1e 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt @@ -33,7 +33,13 @@ class TextViewDominantColorTest { TextViewActivity.textView?.setTextColor(Color.WHITE) - val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) + val node = + ViewHierarchyNode.fromView( + TextViewActivity.textView!!, + null, + 0, + SentryOptions().sessionReplay, + ) assertTrue(node is TextViewHierarchyNode) assertNull(node.layout?.dominantTextColor) } @@ -53,7 +59,13 @@ class TextViewDominantColorTest { shadowOf(Looper.getMainLooper()).idle() - val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) + val node = + ViewHierarchyNode.fromView( + TextViewActivity.textView!!, + null, + 0, + SentryOptions().sessionReplay, + ) assertTrue(node is TextViewHierarchyNode) assertEquals(Color.RED, node.layout?.dominantTextColor) } @@ -74,7 +86,13 @@ class TextViewDominantColorTest { shadowOf(Looper.getMainLooper()).idle() - val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) + val node = + ViewHierarchyNode.fromView( + TextViewActivity.textView!!, + null, + 0, + SentryOptions().sessionReplay, + ) assertTrue(node is TextViewHierarchyNode) assertEquals(Color.BLACK, node.layout?.dominantTextColor) } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt index 530c124af4f..3d5f6a9c506 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt @@ -1,15 +1,38 @@ package io.sentry.android.replay.util +import android.app.Activity +import android.os.Looper +import android.view.SurfaceView import android.view.View +import android.widget.FrameLayout +import android.widget.FrameLayout.LayoutParams +import android.widget.TextView import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.NoOpLogger +import io.sentry.SentryReplayOptions +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) class ViewsTest { + + @BeforeTest + fun setup() { + // Required so Robolectric reports the activity window as visible; otherwise + // View.isVisibleToUser() returns false and SurfaceView nodes are skipped. + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + } + @Test fun `hasSize returns true for positive values`() { val view = View(ApplicationProvider.getApplicationContext()) @@ -33,4 +56,59 @@ class ViewsTest { view.bottom = -1 assertFalse(view.hasSize()) } + + @Test + fun `traverse collects visible SurfaceView nodes when a list is supplied`() { + val (root, _) = buildSurfaceViewHierarchy() + val rootNode = ViewHierarchyNode.fromView(root, null, 0, SentryReplayOptions(false, null)) + val collected = mutableListOf() + + root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance(), collected) + + assertEquals(2, collected.size) + } + + @Test + fun `traverse does not collect SurfaceView nodes when list parameter is null`() { + val (root, _) = buildSurfaceViewHierarchy() + val rootNode = ViewHierarchyNode.fromView(root, null, 0, SentryReplayOptions(false, null)) + + root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance(), null) + } + + @Test + fun `traverse skips invisible SurfaceViews`() { + val (root, surfaceViews) = buildSurfaceViewHierarchy() + surfaceViews.first().visibility = View.GONE + + val rootNode = ViewHierarchyNode.fromView(root, null, 0, SentryReplayOptions(false, null)) + val collected = mutableListOf() + + root.traverse(rootNode, SentryReplayOptions(false, null), NoOpLogger.getInstance(), collected) + + assertEquals(1, collected.size) + } + + /** + * Builds and attaches a small view tree: `FrameLayout(SurfaceView, TextView, FrameLayout( + * SurfaceView))`. Returns the root [FrameLayout] and the two [SurfaceView]s in tree order so + * tests can mutate visibility without re-walking the hierarchy. + */ + private fun buildSurfaceViewHierarchy(): Pair> { + val activity = buildActivity(Activity::class.java).setup().get() + val sv1 = SurfaceView(activity).apply { layoutParams = LayoutParams(100, 100) } + val sv2 = SurfaceView(activity).apply { layoutParams = LayoutParams(50, 50) } + val nested = FrameLayout(activity).apply { addView(sv2) } + val root = + FrameLayout(activity).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + addView(sv1) + addView(TextView(activity).apply { text = "label" }) + addView(nested) + } + activity.setContentView(root) + // Flush the layout/attach pass so isAttachedToWindow / visibility computations are accurate. + shadowOf(Looper.getMainLooper()).idle() + return root to listOf(sv1, sv2) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index 7a21737981b..0accb9ed16d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.test.ext.junit.runners.AndroidJUnit4 import coil.compose.AsyncImage +import io.sentry.NoOpLogger import io.sentry.SentryOptions import io.sentry.android.replay.maskAllImages import io.sentry.android.replay.maskAllText @@ -43,6 +44,7 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHiera import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import java.io.File +import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -160,7 +162,12 @@ class ComposeMaskingOptionsTest { assertNotNull(composeView) val rootNode = GenericViewHierarchyNode(0f, 0f, 0, 0, 1.0f, -1, shouldMask = true) - ComposeViewHierarchyNode.fromView(composeView, rootNode, options) + ComposeViewHierarchyNode.fromView( + composeView, + rootNode, + options.sessionReplay, + NoOpLogger.getInstance(), + ) assertEquals(1, rootNode.children?.size) @@ -174,7 +181,7 @@ class ComposeMaskingOptionsTest { @Test fun `when retrieving the semantics fails, an error is thrown`() { val node = mock() - whenever(node.collapsedSemantics).thenThrow(RuntimeException("Compose Runtime Error")) + whenever(node.semanticsConfiguration).thenThrow(RuntimeException("Compose Runtime Error")) assertThrows(RuntimeException::class.java) { ComposeViewHierarchyNode.retrieveSemanticsConfiguration(node) @@ -213,6 +220,9 @@ class ComposeMaskingOptionsTest { } @Test + @Ignore( + "Flaky: Robolectric intermittently reports zero bounds for nodes, causing isVisible=false and making the assertion non-deterministic" + ) fun `when sentry-unmask modifier is set unmasks the node`() { ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.sentryReplayUnmask() } val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() @@ -222,18 +232,23 @@ class ComposeMaskingOptionsTest { val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] - textNodes.forEach { - if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { - assertFalse( - it.shouldMask, - "Node with text ${(it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text} should not be masked", - ) - } else { - assertTrue( - it.shouldMask, - "Node with text ${(it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text} should be masked", - ) - } + + val unmaskNode = textNodes.first { + (it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request" + } + assertTrue(unmaskNode.isVisible, "The unmasked node must be visible for the test to be valid") + assertFalse(unmaskNode.shouldMask, "Node with sentryReplayUnmask() should not be masked") + + // Robolectric may intermittently report zero bounds for some nodes when running + // the full test class, making them invisible (shouldMask = isVisible && ...). + // Assert that all other visible nodes remain masked. + val otherVisibleNodes = textNodes.filter { it !== unmaskNode && it.isVisible } + assertTrue(otherVisibleNodes.isNotEmpty(), "Expected at least one other visible text node") + otherVisibleNodes.forEach { + assertTrue( + it.shouldMask, + "Node with text ${(it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text} should be masked", + ) } } @@ -274,8 +289,8 @@ class ComposeMaskingOptionsTest { private inline fun Activity.collectNodesOfType(options: SentryOptions): List { val root = window.decorView - val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) - root.traverse(viewHierarchy, options) + val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay) + root.traverse(viewHierarchy, options.sessionReplay, NoOpLogger.getInstance()) val nodes = mutableListOf() viewHierarchy.traverse { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt index ae9915cf42c..5d5304555ce 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt @@ -43,7 +43,12 @@ class ContainerMaskingOptionsTest { } val textNode = - ViewHierarchyNode.fromView(MaskingOptionsActivity.textViewInUnmask!!, null, 0, options) + ViewHierarchyNode.fromView( + MaskingOptionsActivity.textViewInUnmask!!, + null, + 0, + options.sessionReplay, + ) assertFalse(textNode.shouldMask) } @@ -58,7 +63,12 @@ class ContainerMaskingOptionsTest { } val imageNode = - ViewHierarchyNode.fromView(MaskingOptionsActivity.imageViewInUnmask!!, null, 0, options) + ViewHierarchyNode.fromView( + MaskingOptionsActivity.imageViewInUnmask!!, + null, + 0, + options.sessionReplay, + ) assertFalse(imageNode.shouldMask) } @@ -70,7 +80,12 @@ class ContainerMaskingOptionsTest { SentryOptions().apply { sessionReplay.setMaskViewContainerClass(CustomMask::class.java.name) } val maskContainer = - ViewHierarchyNode.fromView(MaskingOptionsActivity.maskWithChildren!!, null, 0, options) + ViewHierarchyNode.fromView( + MaskingOptionsActivity.maskWithChildren!!, + null, + 0, + options.sessionReplay, + ) assertTrue(maskContainer.shouldMask) } @@ -86,20 +101,25 @@ class ContainerMaskingOptionsTest { } val maskContainer = - ViewHierarchyNode.fromView(MaskingOptionsActivity.unmaskWithChildren!!, null, 0, options) + ViewHierarchyNode.fromView( + MaskingOptionsActivity.unmaskWithChildren!!, + null, + 0, + options.sessionReplay, + ) val firstChild = ViewHierarchyNode.fromView( MaskingOptionsActivity.customViewInUnmask!!, maskContainer, 0, - options, + options.sessionReplay, ) val secondLevelChild = ViewHierarchyNode.fromView( MaskingOptionsActivity.secondLayerChildInUnmask!!, firstChild, 0, - options, + options.sessionReplay, ) assertFalse(maskContainer.shouldMask) @@ -118,13 +138,18 @@ class ContainerMaskingOptionsTest { } val unmaskNode = - ViewHierarchyNode.fromView(MaskingOptionsActivity.unmaskWithMaskChild!!, null, 0, options) + ViewHierarchyNode.fromView( + MaskingOptionsActivity.unmaskWithMaskChild!!, + null, + 0, + options.sessionReplay, + ) val maskNode = ViewHierarchyNode.fromView( MaskingOptionsActivity.maskAsDirectChildOfUnmask!!, unmaskNode, 0, - options, + options.sessionReplay, ) assertFalse(unmaskNode.shouldMask) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt index 87543555243..134359aeebe 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt @@ -46,9 +46,15 @@ class MaskingOptionsTest { val options = SentryOptions().apply { sessionReplay.maskAllText = true } - val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val textNode = + ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options.sessionReplay) val radioButtonNode = - ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + ViewHierarchyNode.fromView( + MaskingOptionsActivity.radioButton!!, + null, + 0, + options.sessionReplay, + ) assertTrue(textNode is TextViewHierarchyNode) assertTrue(textNode.shouldMask) @@ -64,9 +70,15 @@ class MaskingOptionsTest { val options = SentryOptions().apply { sessionReplay.maskAllText = false } - val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val textNode = + ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options.sessionReplay) val radioButtonNode = - ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + ViewHierarchyNode.fromView( + MaskingOptionsActivity.radioButton!!, + null, + 0, + options.sessionReplay, + ) assertTrue(textNode is TextViewHierarchyNode) assertFalse(textNode.shouldMask) @@ -82,7 +94,8 @@ class MaskingOptionsTest { val options = SentryOptions().apply { sessionReplay.maskAllImages = true } - val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + val imageNode = + ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options.sessionReplay) assertTrue(imageNode is ImageViewHierarchyNode) assertTrue(imageNode.shouldMask) @@ -95,7 +108,8 @@ class MaskingOptionsTest { val options = SentryOptions().apply { sessionReplay.maskAllImages = false } - val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + val imageNode = + ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options.sessionReplay) assertTrue(imageNode is ImageViewHierarchyNode) assertFalse(imageNode.shouldMask) @@ -109,7 +123,8 @@ class MaskingOptionsTest { val options = SentryOptions().apply { sessionReplay.maskAllText = false } MaskingOptionsActivity.textView!!.tag = "sentry-mask" - val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val textNode = + ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options.sessionReplay) assertTrue(textNode.shouldMask) } @@ -122,7 +137,8 @@ class MaskingOptionsTest { val options = SentryOptions().apply { sessionReplay.maskAllText = true } MaskingOptionsActivity.textView!!.tag = "sentry-unmask" - val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val textNode = + ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options.sessionReplay) assertFalse(textNode.shouldMask) } @@ -135,7 +151,8 @@ class MaskingOptionsTest { val options = SentryOptions().apply { sessionReplay.maskAllText = false } MaskingOptionsActivity.textView!!.sentryReplayMask() - val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val textNode = + ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options.sessionReplay) assertTrue(textNode.shouldMask) } @@ -148,7 +165,8 @@ class MaskingOptionsTest { val options = SentryOptions().apply { sessionReplay.maskAllText = true } MaskingOptionsActivity.textView!!.sentryReplayUnmask() - val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val textNode = + ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options.sessionReplay) assertFalse(textNode.shouldMask) } @@ -161,7 +179,8 @@ class MaskingOptionsTest { val options = SentryOptions().apply { sessionReplay.maskAllText = true } MaskingOptionsActivity.textView!!.visibility = View.GONE - val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val textNode = + ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options.sessionReplay) assertFalse(textNode.shouldMask) } @@ -177,7 +196,12 @@ class MaskingOptionsTest { } val customViewNode = - ViewHierarchyNode.fromView(MaskingOptionsActivity.customView!!, null, 0, options) + ViewHierarchyNode.fromView( + MaskingOptionsActivity.customView!!, + null, + 0, + options.sessionReplay, + ) assertTrue(customViewNode.shouldMask) } @@ -193,9 +217,15 @@ class MaskingOptionsTest { sessionReplay.unmaskViewClasses.add(RadioButton::class.java.canonicalName) } - val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val textNode = + ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options.sessionReplay) val radioButtonNode = - ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + ViewHierarchyNode.fromView( + MaskingOptionsActivity.radioButton!!, + null, + 0, + options.sessionReplay, + ) assertTrue(textNode.shouldMask) assertFalse(radioButtonNode.shouldMask) @@ -216,10 +246,12 @@ class MaskingOptionsTest { MaskingOptionsActivity.textView!!.parent as LinearLayout, null, 0, - options, + options.sessionReplay, ) - val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) - val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + val textNode = + ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options.sessionReplay) + val imageNode = + ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options.sessionReplay) assertFalse(linearLayoutNode.shouldMask) assertTrue(textNode.shouldMask) diff --git a/sentry-android-sqlite/README.md b/sentry-android-sqlite/README.md new file mode 100644 index 00000000000..307beb51f0e --- /dev/null +++ b/sentry-android-sqlite/README.md @@ -0,0 +1,23 @@ +# sentry-android-sqlite + +SQLite instrumentation for AndroidX APIs. + +Two instrumentation paths are supported: + +- **`androidx.sqlite.SQLiteDriver`**: Used by Room 2.7+ and 3.0+. Applied automatically by the Sentry Android Gradle Plugin. +- **`androidx.sqlite.db.SupportSQLiteOpenHelper`**: Used by SQLDelight and legacy (pre-2.7) Room. Applied automatically by the Sentry Android Gradle Plugin. + +To avoid duplicate spans, only one path should be used per database file. Most Room and SQLDelight APIs enforce that division. The exception is Room's `SupportSQLiteDriver`: either the `SupportSQLiteOpenHelper` it consumes should be wrapped or the support driver itself, but never both. + +See the [SQLite integration docs](https://docs.sentry.io/platforms/android/integrations/room-and-sqlite/) for more details. + +## Package layout + +The module is organized as two separate packages: + +- **`io.sentry.android.sqlite`**: Android-specific code. Depends on `android.database.*` and/or on `androidx.sqlite.db.*`. +- **`io.sentry.sqlite`**: No Android-specific code. Depends only on multiplatform `androidx.sqlite.*`. + +The split anticipates future Kotlin Multiplatform support. The `androidx.sqlite.*` interfaces are defined in KMP's `commonMain` source set and are used by Room in non-JVM environments. Classes in `io.sentry.sqlite` are written against those portable interfaces and are intended to lift cleanly into a KMP `commonMain` source set if/when the `sentry` core gains multiplatform targets. + +Note that the module artifact itself (`sentry-android-sqlite`) is currently an Android-only AAR regardless of package layout. diff --git a/sentry-android-sqlite/api/sentry-android-sqlite.api b/sentry-android-sqlite/api/sentry-android-sqlite.api index c8780f1338d..7b9f633b46a 100644 --- a/sentry-android-sqlite/api/sentry-android-sqlite.api +++ b/sentry-android-sqlite/api/sentry-android-sqlite.api @@ -21,3 +21,15 @@ public final class io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper$Compan public final fun create (Landroidx/sqlite/db/SupportSQLiteOpenHelper;)Landroidx/sqlite/db/SupportSQLiteOpenHelper; } +public final class io/sentry/sqlite/SentrySQLiteDriver : androidx/sqlite/SQLiteDriver { + public static final field Companion Lio/sentry/sqlite/SentrySQLiteDriver$Companion; + public synthetic fun (Landroidx/sqlite/SQLiteDriver;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver; + public fun hasConnectionPool ()Z + public fun open (Ljava/lang/String;)Landroidx/sqlite/SQLiteConnection; +} + +public final class io/sentry/sqlite/SentrySQLiteDriver$Companion { + public final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver; +} + diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index 07fa7ad343f..9637b91546a 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -1,10 +1,10 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } @@ -25,10 +25,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { @@ -73,7 +77,6 @@ dependencies { // tests testImplementation(libs.androidx.sqlite) testImplementation(libs.kotlin.test.junit) - testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) } diff --git a/sentry-android-sqlite/proguard-rules.pro b/sentry-android-sqlite/proguard-rules.pro index 02ab589d3bd..13fa4bf9dea 100644 --- a/sentry-android-sqlite/proguard-rules.pro +++ b/sentry-android-sqlite/proguard-rules.pro @@ -4,4 +4,8 @@ # https://developer.android.com/studio/build/shrink-code#decode-stack-trace -keepattributes LineNumberTable,SourceFile +# SentrySQLiteDriver.create() uses a runtime class-name check to skip wrapping the Room 2.7+ +# SupportSQLiteDriver bridge adapter and avoid duplicate spans. +-keepnames class androidx.sqlite.driver.SupportSQLiteDriver + ##---------------End: proguard configuration for SQLite ---------- diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt similarity index 96% rename from sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt rename to sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt index 1bdeb7d369c..059eb1bb1b5 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt @@ -13,7 +13,8 @@ import io.sentry.SpanStatus private const val TRACE_ORIGIN = "auto.db.sqlite" -internal class SQLiteSpanManager( +/** Span instrumentation for [SentrySupportSQLiteOpenHelper]. */ +internal class OpenHelperSpans( private val scopes: IScopes = ScopesAdapter.getInstance(), private val databaseName: String? = null, ) { diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt index 1f3796a8975..01da8c476db 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt @@ -2,20 +2,20 @@ package io.sentry.android.sqlite import android.database.CrossProcessCursor import android.database.CursorWindow +import android.database.CursorWrapper /* * SQLiteCursor executes the query lazily, when one of getCount() and onMove() is called. * Also, by docs, fillWindow() can be used to fill the cursor with data. * So we wrap these methods to create a span. - * SQLiteCursor is never used directly in the code, but only the Cursor interface. - * This means we can use CrossProcessCursor - that extends Cursor - as wrapper, since - * CrossProcessCursor is an interface and we can use Kotlin delegation. + * Ordinary Cursor methods are delegated through CursorWrapper to avoid adding Sentry frames to + * app database exceptions that the wrapper did not instrument. */ internal class SentryCrossProcessCursor( private val delegate: CrossProcessCursor, - private val spanManager: SQLiteSpanManager, + private val spans: OpenHelperSpans, private val sql: String, -) : CrossProcessCursor by delegate { +) : CursorWrapper(delegate), CrossProcessCursor { // We have to start the span only the first time, regardless of how many times its methods get // called. private var isSpanStarted = false @@ -25,7 +25,7 @@ internal class SentryCrossProcessCursor( return delegate.count } isSpanStarted = true - return spanManager.performSql(sql) { delegate.count } + return spans.performSql(sql) { delegate.count } } override fun onMove(oldPosition: Int, newPosition: Int): Boolean { @@ -33,14 +33,16 @@ internal class SentryCrossProcessCursor( return delegate.onMove(oldPosition, newPosition) } isSpanStarted = true - return spanManager.performSql(sql) { delegate.onMove(oldPosition, newPosition) } + return spans.performSql(sql) { delegate.onMove(oldPosition, newPosition) } } + override fun getWindow(): CursorWindow? = delegate.window + override fun fillWindow(position: Int, window: CursorWindow?) { if (isSpanStarted) { return delegate.fillWindow(position, window) } isSpanStarted = true - return spanManager.performSql(sql) { delegate.fillWindow(position, window) } + return spans.performSql(sql) { delegate.fillWindow(position, window) } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt index bfe3265f89b..458203a232f 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt @@ -14,11 +14,11 @@ import androidx.sqlite.db.SupportSQLiteStatement * and it's created automatically by the [SentrySupportSQLiteOpenHelper]. * * @param delegate The [SupportSQLiteDatabase] instance to delegate calls to. - * @param sqLiteSpanManager The [SQLiteSpanManager] responsible for the creation of the spans. + * @param spans The [OpenHelperSpans] manager responsible for the creation of the spans. */ internal class SentrySupportSQLiteDatabase( private val delegate: SupportSQLiteDatabase, - private val sqLiteSpanManager: SQLiteSpanManager, + private val spans: OpenHelperSpans, ) : SupportSQLiteDatabase by delegate { /** * Compiles the given SQL statement. It will return Sentry's wrapper around @@ -28,35 +28,34 @@ internal class SentrySupportSQLiteDatabase( * @return Compiled statement. */ override fun compileStatement(sql: String): SupportSQLiteStatement = - SentrySupportSQLiteStatement(delegate.compileStatement(sql), sqLiteSpanManager, sql) + SentrySupportSQLiteStatement(delegate.compileStatement(sql), spans, sql) @Suppress("AcronymName") // To keep consistency with framework method name. override fun execPerConnectionSQL( sql: String, @SuppressLint("ArrayReturn") bindArgs: Array?, ) { - sqLiteSpanManager.performSql(sql) { delegate.execPerConnectionSQL(sql, bindArgs) } + spans.performSql(sql) { delegate.execPerConnectionSQL(sql, bindArgs) } } - override fun query(query: String): Cursor = - sqLiteSpanManager.performSql(query) { delegate.query(query) } + override fun query(query: String): Cursor = spans.performSql(query) { delegate.query(query) } override fun query(query: String, bindArgs: Array): Cursor = - sqLiteSpanManager.performSql(query) { delegate.query(query, bindArgs) } + spans.performSql(query) { delegate.query(query, bindArgs) } override fun query(query: SupportSQLiteQuery): Cursor = - sqLiteSpanManager.performSql(query.sql) { delegate.query(query) } + spans.performSql(query.sql) { delegate.query(query) } override fun query(query: SupportSQLiteQuery, cancellationSignal: CancellationSignal?): Cursor = - sqLiteSpanManager.performSql(query.sql) { delegate.query(query, cancellationSignal) } + spans.performSql(query.sql) { delegate.query(query, cancellationSignal) } @Throws(SQLException::class) override fun execSQL(sql: String) { - sqLiteSpanManager.performSql(sql) { delegate.execSQL(sql) } + spans.performSql(sql) { delegate.execSQL(sql) } } @Throws(SQLException::class) override fun execSQL(sql: String, bindArgs: Array) { - sqLiteSpanManager.performSql(sql) { delegate.execSQL(sql, bindArgs) } + spans.performSql(sql) { delegate.execSQL(sql, bindArgs) } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt index 76b405d9f11..12b63cfa128 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt @@ -33,14 +33,14 @@ import androidx.sqlite.db.SupportSQLiteOpenHelper public class SentrySupportSQLiteOpenHelper private constructor(private val delegate: SupportSQLiteOpenHelper) : SupportSQLiteOpenHelper by delegate { - private val sqLiteSpanManager = SQLiteSpanManager(databaseName = delegate.databaseName) + private val spans = OpenHelperSpans(databaseName = delegate.databaseName) private val sentryWritableDatabase: SupportSQLiteDatabase by lazy { - SentrySupportSQLiteDatabase(delegate.writableDatabase, sqLiteSpanManager) + SentrySupportSQLiteDatabase(delegate.writableDatabase, spans) } private val sentryReadableDatabase: SupportSQLiteDatabase by lazy { - SentrySupportSQLiteDatabase(delegate.readableDatabase, sqLiteSpanManager) + SentrySupportSQLiteDatabase(delegate.readableDatabase, spans) } override val writableDatabase: SupportSQLiteDatabase diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt index 1a364dc27ba..3df6d287b28 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt @@ -9,25 +9,22 @@ import androidx.sqlite.db.SupportSQLiteStatement * [SentrySupportSQLiteDatabase.compileStatement]. * * @param delegate The [SupportSQLiteStatement] instance to delegate calls to. - * @param sqLiteSpanManager The [SQLiteSpanManager] responsible for the creation of the spans. + * @param spans The [OpenHelperSpans] manager responsible for the creation of the spans. * @param sql The query string. */ internal class SentrySupportSQLiteStatement( private val delegate: SupportSQLiteStatement, - private val sqLiteSpanManager: SQLiteSpanManager, + private val spans: OpenHelperSpans, private val sql: String, ) : SupportSQLiteStatement by delegate { - override fun execute() = sqLiteSpanManager.performSql(sql) { delegate.execute() } + override fun execute() = spans.performSql(sql) { delegate.execute() } - override fun executeUpdateDelete(): Int = - sqLiteSpanManager.performSql(sql) { delegate.executeUpdateDelete() } + override fun executeUpdateDelete(): Int = spans.performSql(sql) { delegate.executeUpdateDelete() } - override fun executeInsert(): Long = - sqLiteSpanManager.performSql(sql) { delegate.executeInsert() } + override fun executeInsert(): Long = spans.performSql(sql) { delegate.executeInsert() } - override fun simpleQueryForLong(): Long = - sqLiteSpanManager.performSql(sql) { delegate.simpleQueryForLong() } + override fun simpleQueryForLong(): Long = spans.performSql(sql) { delegate.simpleQueryForLong() } override fun simpleQueryForString(): String? = - sqLiteSpanManager.performSql(sql) { delegate.simpleQueryForString() } + spans.performSql(sql) { delegate.simpleQueryForString() } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt new file mode 100644 index 00000000000..598dc524ed1 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt @@ -0,0 +1,38 @@ +package io.sentry.sqlite + +/** [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] value for in-memory databases. */ +internal const val DB_SYSTEM_IN_MEMORY = "in-memory" + +/** [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] value for SQLite databases. */ +internal const val DB_SYSTEM_SQLITE = "sqlite" + +/** + * Sentinel file name that [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open] interprets as an + * in-memory database (see docs + * [here](https://developer.android.com/reference/androidx/sqlite/driver/AndroidSQLiteDriver)). + */ +private const val IN_MEMORY_DB_FILENAME = ":memory:" + +/** Path separators matching [File.separatorChar][java.io.File.separatorChar]. */ +private val FILE_NAME_PATH_SEPARATORS = charArrayOf('/', '\\') + +internal data class DbMetadata(val name: String?, val system: String) + +/** + * Returns metadata based on the [fileName] argument passed to + * [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open]. + */ +internal fun dbMetadataFromFileName(fileName: String): DbMetadata { + if (fileName == IN_MEMORY_DB_FILENAME) { + return DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY) + } + + val trimmed = fileName.trimEnd { it in FILE_NAME_PATH_SEPARATORS } + if (trimmed.isEmpty()) { + return DbMetadata(name = null, system = DB_SYSTEM_SQLITE) + } + + val index = trimmed.lastIndexOfAny(FILE_NAME_PATH_SEPARATORS) + val basename = if (index >= 0) trimmed.substring(index + 1) else trimmed + return DbMetadata(name = basename.ifEmpty { null }, system = DB_SYSTEM_SQLITE) +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt new file mode 100644 index 00000000000..b3c0eb7c713 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt @@ -0,0 +1,117 @@ +package io.sentry.sqlite + +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.Instrumenter +import io.sentry.ScopesAdapter +import io.sentry.SentryDate +import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate +import io.sentry.SentryStackTraceFactory +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus + +private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" + +/** + * Sentinel for extracting a [SentryNanotimeDate]'s underlying [System.nanoTime] value via + * [SentryDate.diff]. + */ +private val EMPTY_NANO_TIME = SentryNanotimeDate(0, 0L) + +/** Span instrumentation for [SentrySQLiteDriver]. */ +internal class DriverSpans(private val scopes: IScopes, private val dbMetadata: DbMetadata) { + + private val stackTraceFactory = SentryStackTraceFactory(scopes.options) + + /** + * Returns a timestamp in nanoseconds for use with [record]. Timestamp is ns-precise if the active + * parent span uses a [SentryNanotimeDate] (the ordinary case); otherwise it's ms-precise. + * + * Note: Internalizing the start time in [record] would shift spans to end-of-work on the trace + * timeline, which is less desirable; callers capture the start before doing database work and + * pass it back to [record]. + */ + fun startTimestamp(): Long = + // Try to retain nanosecond precision + avoid SentryDate allocation... + scopes.span?.computeNanoStartTimestampForChild() + // ...otherwise fall back to millisecond precision + allocate. + ?: scopes.options.dateProvider.now().nanoTimestamp() + + /** Records a `db.sql.query` span. */ + fun record( + sql: String, + startTimestampNanos: Long, + durationNanos: Long, + status: SpanStatus, + throwable: Throwable? = null, + ) { + val parent = scopes.span ?: return + val startTimestamp = SentryLongDate(startTimestampNanos) + val endTimestamp = SentryLongDate(startTimestampNanos + durationNanos) + + parent.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY).apply { + spanContext.origin = SQLITE_TRACE_ORIGIN + throwable?.let { this.throwable = it } + + val isMainThread = scopes.options.threadChecker.isMainThread + setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread) + + if (isMainThread) { + setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack) + } + + dbMetadata.name?.let { setData(SpanDataConvention.DB_NAME_KEY, it) } + setData(SpanDataConvention.DB_SYSTEM_KEY, dbMetadata.system) + finish(status, endTimestamp) + } + } + + companion object { + + /** + * Returns [DriverSpans] based on the [fileName] argument passed to + * [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open]. + */ + fun fromFileName(fileName: String, scopes: IScopes = ScopesAdapter.getInstance()): DriverSpans = + DriverSpans(scopes, dbMetadataFromFileName(fileName)) + } +} + +/** + * Computes a start timestamp with nanosecond precision for the child of the receiver span. Returns + * null if nanosecond precision isn't possible. + * + * Lets us improve the display of spans in the Sentry UI. If timestamps are only ms-precise, the + * Sentry UI will left-align and arbitrarily reorder spans that share the same wall clock ms: + * ``` + * (Relative start times out of order) + * ↓ + * Parent span ├█████████████┤ + * END TRANSACTION ├███┤ 0.33 ms + * BEGIN IMMEDIATE TRANSACTION ├████┤ 0.02 ms + * INSERT INTO `my_db` … ├██┤ 0.30 ms + * ↑ + * (All spans share the same ms baseline + * even though their execution was staggered) + * ``` + * + * Nanosecond precision ensures proper ordering and lets the spans stagger: + * ``` + * Parent span ├█████████████┤ + * BEGIN IMMEDIATE TRANSACTION ├████┤ 0.02 ms + * INSERT INTO `my_db` … ├██┤ 0.30 ms + * END TRANSACTION ├███┤ 0.33 ms + * ``` + */ +internal fun ISpan.computeNanoStartTimestampForChild(): Long? { + if (startDate !is SentryNanotimeDate) { + return null + } + + val parentWallClockNanos = startDate.nanoTimestamp() + val parentMonotonicNanos = startDate.diff(EMPTY_NANO_TIME) + val elapsedSinceParentStart = System.nanoTime() - parentMonotonicNanos + // Return the child's absolute start time. + return parentWallClockNanos + elapsedSinceParentStart +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt new file mode 100644 index 00000000000..e01544b0523 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt @@ -0,0 +1,15 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement + +internal class SentrySQLiteConnection( + private val delegate: SQLiteConnection, + private val spans: DriverSpans, +) : SQLiteConnection by delegate { + + override fun prepare(sql: String): SQLiteStatement { + val statement = delegate.prepare(sql) + return statement as? SentrySQLiteStatement ?: SentrySQLiteStatement(statement, spans, sql) + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt new file mode 100644 index 00000000000..28b661cd3e7 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -0,0 +1,107 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import io.sentry.ScopesAdapter +import io.sentry.SentryLevel +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion + +/** + * Wraps a [SQLiteDriver] and automatically adds spans for each SQL statement it executes. + * + * Example usage: + * ``` + * val driver = SentrySQLiteDriver.create(AndroidSQLiteDriver()) + * ``` + * + * If you use Room: + * ``` + * val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName") + * .setDriver(SentrySQLiteDriver.create(AndroidSQLiteDriver())) + * .build() + * ``` + * + * If you're using the Sentry Android Gradle Plugin (SAGP) 6.13.0+, wrapping will be performed + * automatically for Room. + * + * @param delegate The [SQLiteDriver] instance to delegate calls to. + */ +public class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) : + SQLiteDriver { + + init { + addIntegrationToSdkVersion("SQLiteDriver") + } + + @Suppress("INAPPLICABLE_JVM_NAME") + @get:JvmName("hasConnectionPool") + override val hasConnectionPool: Boolean + get() = + try { + delegate.hasConnectionPool + } catch (_: LinkageError) { + // Delegates on androidx.sqlite < 2.6.0 won't have a hasConnectionPool property. + false + } + + @Suppress("TooGenericExceptionCaught") + override fun open(fileName: String): SQLiteConnection { + val connection = delegate.open(fileName) + + return try { + val spans = DriverSpans.fromFileName(fileName) + // create() ensures delegate is unwrapped, so we don't need to protect against double-wrapping + // the connection. + SentrySQLiteConnection(connection, spans) + } catch (t: Throwable) { + ScopesAdapter.getInstance() + .options + .logger + .log( + SentryLevel.ERROR, + "Failed to instrument SQLite connection; returning uninstrumented connection.", + t, + ) + connection + } + } + + public companion object { + + /** + * Name of the bridge adapter often used with Room 2.7+. It implements the `SQLiteDriver` + * interface and its constructor consumes a `SupportSQLiteOpenHelper`. (Users of the Sentry + * Android Gradle Plugin will have the `SupportSQLiteOpenHelper` wrapped for them + * automatically.) We deliberately avoid wrapping the adapter to prevent duplicate spans. + * + * String (rather than an `is` check) lets us avoid a compile-time dependency on + * androidx.sqlite:sqlite-framework. + */ + private const val SUPPORT_SQLITE_DRIVER_FQN = "androidx.sqlite.driver.SupportSQLiteDriver" + + /** + * Wraps the provided delegate in a [SentrySQLiteDriver]. + * + * To avoid duplicate spans, returns the delegate as-is if: + * 1. it's already wrapped, or + * 2. it's an `androidx.sqlite.driver.SupportSQLiteDriver`. + * + * In the case of (2), wrap the open helper passed to the `SupportSQLiteDriver` constructor via + * `SentrySupportSQLiteOpenHelper` instead. + * + * Note that wrapping will be performed if the delegate isn't a `SupportSQLiteDriver` itself but + * wraps or subclasses one. In that case, ensure the open helper passed to the support driver + * constructor is *not* wrapped. + */ + // Warning! The SAGP depends on this method's ABI. + @JvmStatic + public fun create(delegate: SQLiteDriver): SQLiteDriver = + // FQN check simplifies our SAGP implementation, allowing it to naively instrument all + // RoomDatabase.Builder.setDriver() call sites. + if (delegate is SentrySQLiteDriver || delegate.javaClass.name == SUPPORT_SQLITE_DRIVER_FQN) { + delegate + } else { + SentrySQLiteDriver(delegate) + } + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt new file mode 100644 index 00000000000..e220a74cd1e --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt @@ -0,0 +1,79 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteStatement +import io.sentry.SpanStatus + +/** + * Wraps a [SQLiteStatement] and records a single Sentry span covering all [step] calls for the + * statement's lifetime (until [step] iteration is complete or the statement is [reset] or + * [closed][close]). + * + * Span duration is restricted to accumulated database time, i.e., each [step] call is individually + * timed and the durations are summed. Time the application spends between steps (e.g., processing + * rows, sleeping, or doing I/O) is intentionally excluded. + * + * Not thread-safe: assumes sequential access within each SQL statement (normal SQLite usage). + */ +internal class SentrySQLiteStatement( + private val delegate: SQLiteStatement, + private val spans: DriverSpans, + private val sql: String, + private val nanoTimeProvider: () -> Long = { System.nanoTime() }, +) : SQLiteStatement by delegate { + + private var firstStepTimestampNanos: Long? = null + private var accumulatedDbNanos: Long = 0L + private var stepsComplete = false + private var closed = false + + @Suppress("TooGenericExceptionCaught") + override fun step(): Boolean { + if (stepsComplete || closed) { + return delegate.step() + } + + val beforeNanos = nanoTimeProvider() + return try { + if (firstStepTimestampNanos == null) { + firstStepTimestampNanos = spans.startTimestamp() + } + + stepsComplete = !delegate.step() + accumulatedDbNanos += nanoTimeProvider() - beforeNanos + if (stepsComplete) { + recordSpan(SpanStatus.OK) + } + !stepsComplete + } catch (e: Throwable) { + accumulatedDbNanos += nanoTimeProvider() - beforeNanos + recordSpan(SpanStatus.INTERNAL_ERROR, e) + throw e + } + } + + override fun reset() { + if (closed) { + return delegate.reset() + } + + try { + recordSpan(SpanStatus.OK) + } finally { + delegate.reset() + stepsComplete = false + } + } + + override fun close() { + closed = true + delegate.use { recordSpan(SpanStatus.OK) } + } + + private fun recordSpan(status: SpanStatus, throwable: Throwable? = null) { + val startNanos = firstStepTimestampNanos ?: return + val duration = accumulatedDbNanos + firstStepTimestampNanos = null + accumulatedDbNanos = 0L + spans.record(sql, startNanos, duration, status, throwable) + } +} diff --git a/sentry-android-sqlite/src/test/AndroidManifest.xml b/sentry-android-sqlite/src/test/AndroidManifest.xml new file mode 100644 index 00000000000..967265a1f16 --- /dev/null +++ b/sentry-android-sqlite/src/test/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt new file mode 100644 index 00000000000..2de7f1d38f5 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt @@ -0,0 +1,18 @@ +package androidx.sqlite.driver + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver + +/** + * Minimal stub of `androidx.sqlite.driver.SupportSQLiteDriver` (which lives in + * `androidx.sqlite:sqlite-framework`, not on this module's compile/test classpath) for verifying + * behavior of `SentrySQLiteDriver.create(SupportSQLiteDriver)`. + */ +internal class SupportSQLiteDriver : SQLiteDriver { + + override val hasConnectionPool: Boolean = false + + override fun open(fileName: String): SQLiteConnection { + throw UnsupportedOperationException("Test stub; not for runtime use") + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt similarity index 97% rename from sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt rename to sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt index 6fd6fa51bb3..0552094838e 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt @@ -21,13 +21,13 @@ import org.junit.Before import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -class SQLiteSpanManagerTest { +class OpenHelperSpansTest { private class Fixture { private val scopes = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions - fun getSut(isSpanActive: Boolean = true, databaseName: String? = null): SQLiteSpanManager { + fun getSut(isSpanActive: Boolean = true, databaseName: String? = null): OpenHelperSpans { options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } whenever(scopes.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) @@ -35,7 +35,7 @@ class SQLiteSpanManagerTest { if (isSpanActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SQLiteSpanManager(scopes, databaseName) + return OpenHelperSpans(scopes, databaseName) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt index 44836dd0c97..ba77b2398c7 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt @@ -1,6 +1,7 @@ package io.sentry.android.sqlite import android.database.CrossProcessCursor +import android.database.CursorWrapper import io.sentry.IScopes import io.sentry.ISpan import io.sentry.SentryOptions @@ -20,7 +21,7 @@ import org.mockito.kotlin.whenever class SentryCrossProcessCursorTest { private class Fixture { private val scopes = mock() - private val spanManager = SQLiteSpanManager(scopes) + private val spans = OpenHelperSpans(scopes) val mockCursor = mock() lateinit var options: SentryOptions lateinit var sentryTracer: SentryTracer @@ -33,7 +34,7 @@ class SentryCrossProcessCursorTest { if (isSpanActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SentryCrossProcessCursor(mockCursor, spanManager, sql) + return SentryCrossProcessCursor(mockCursor, spans, sql) } } @@ -52,13 +53,14 @@ class SentryCrossProcessCursorTest { cursor.fillWindow(0, mock()) verify(fixture.mockCursor).fillWindow(eq(0), any()) + } - // Let's verify other methods are delegated, even if not explicitly - cursor.close() - verify(fixture.mockCursor).close() + @Test + fun `ordinary cursor methods are delegated by Android CursorWrapper`() { + val getStringMethod = + SentryCrossProcessCursor::class.java.getMethod("getString", Int::class.javaPrimitiveType!!) - cursor.getString(1) - verify(fixture.mockCursor).getString(eq(1)) + assertEquals(CursorWrapper::class.java, getStringMethod.declaringClass) } @Test diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt index 81bd964cc87..6a47eb6fa92 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt @@ -23,7 +23,7 @@ import org.mockito.kotlin.whenever class SentrySupportSQLiteDatabaseTest { private class Fixture { private val scopes = mock() - private val spanManager = SQLiteSpanManager(scopes) + private val spans = OpenHelperSpans(scopes) val mockDatabase = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions @@ -41,7 +41,7 @@ class SentrySupportSQLiteDatabaseTest { whenever(scopes.span).thenReturn(sentryTracer) } - return SentrySupportSQLiteDatabase(mockDatabase, spanManager) + return SentrySupportSQLiteDatabase(mockDatabase, spans) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt index b2b4998ace8..c4d810adbcd 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt @@ -18,7 +18,7 @@ import org.mockito.kotlin.whenever class SentrySupportSQLiteStatementTest { private class Fixture { private val scopes = mock() - private val spanManager = SQLiteSpanManager(scopes) + private val spans = OpenHelperSpans(scopes) val mockStatement = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions @@ -31,7 +31,7 @@ class SentrySupportSQLiteStatementTest { if (isSpanActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SentrySupportSQLiteStatement(mockStatement, spanManager, sql) + return SentrySupportSQLiteStatement(mockStatement, spans, sql) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt new file mode 100644 index 00000000000..13ae1389b77 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt @@ -0,0 +1,99 @@ +package io.sentry.sqlite + +import io.sentry.DateUtils +import io.sentry.ISpan +import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ComputeNanoStartTimestampForChildTest { + + @Test + fun `returns parent wall clock plus elapsed monotonic time since parent started`() { + val wallClockMillis = 1_000_000L + val elapsedNanos = 500_000L + val parentMonotonicNanos = System.nanoTime() - elapsedNanos + val span = spanWithNanotimeStart(wallClockMillis, parentMonotonicNanos) + + val timestamp = span.computeNanoStartTimestampForChild()!! + + val elapsedSinceParentStart = timestamp - DateUtils.millisToNanos(wallClockMillis) + assertTrue(elapsedSinceParentStart >= elapsedNanos) + assertTrue(elapsedSinceParentStart < elapsedNanos + TEST_SLACK_NANOS) + } + + @Test + fun `same millisecond wall clocks with different monotonic offsets produce distinct ordered timestamps`() { + val wallClockMillis = 1_000_000L + val wallClockNanos = DateUtils.millisToNanos(wallClockMillis) + val earlierParentMonotonicNanos = System.nanoTime() - 200_000L + val laterParentMonotonicNanos = System.nanoTime() - 800_000L + val earlierSpan = spanWithNanotimeStart(wallClockMillis, earlierParentMonotonicNanos) + val laterSpan = spanWithNanotimeStart(wallClockMillis, laterParentMonotonicNanos) + + assertEquals( + earlierSpan.startDate.nanoTimestamp(), + laterSpan.startDate.nanoTimestamp(), + "Raw parent timestamps share the same ms-quantized value", + ) + + val earlier = earlierSpan.computeNanoStartTimestampForChild()!! + val later = laterSpan.computeNanoStartTimestampForChild()!! + + assertTrue(earlier > wallClockNanos) + assertTrue(later > wallClockNanos) + assertTrue(earlier < later) + assertTrue(later - earlier >= 500_000L) + } + + @Test + fun `returns parent wall clock when no monotonic time has elapsed since parent started`() { + val wallClockMillis = 1_000_000L + val parentMonotonicNanos = System.nanoTime() + val span = spanWithNanotimeStart(wallClockMillis, parentMonotonicNanos) + + val elapsedSinceParentStart = + span.computeNanoStartTimestampForChild()!! - DateUtils.millisToNanos(wallClockMillis) + assertTrue(elapsedSinceParentStart >= 0L) + assertTrue(elapsedSinceParentStart < TEST_SLACK_NANOS) + } + + @Test + fun `works when parent wall clock differs from millisecond baseline`() { + val wallClockMillis = 1_000_001L + val elapsedNanos = 1_500_000L + val parentMonotonicNanos = System.nanoTime() - elapsedNanos + val span = spanWithNanotimeStart(wallClockMillis, parentMonotonicNanos) + + val elapsedSinceParentStart = + span.computeNanoStartTimestampForChild()!! - DateUtils.millisToNanos(wallClockMillis) + assertTrue(elapsedSinceParentStart >= elapsedNanos) + assertTrue(elapsedSinceParentStart < elapsedNanos + TEST_SLACK_NANOS) + } + + @Test + fun `returns null when start date is not SentryNanotimeDate`() { + val span = mock() + whenever(span.startDate).thenReturn(SentryLongDate(DateUtils.millisToNanos(1_000_000L))) + + assertNull(span.computeNanoStartTimestampForChild()) + } + + private fun spanWithNanotimeStart(wallClockMillis: Long, parentMonotonicNanos: Long): ISpan { + val startDate = SentryNanotimeDate(wallClockMillis, parentMonotonicNanos) + val span = mock() + whenever(span.startDate).thenReturn(startDate) + return span + } + + companion object { + + // Upper bound for monotonic drift while the test body runs. + private const val TEST_SLACK_NANOS = 50_000_000L + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt new file mode 100644 index 00000000000..09d80793ed2 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt @@ -0,0 +1,79 @@ +package io.sentry.sqlite + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DbMetadataTest { + + @Test + fun `dbMetadataFromFileName returns in-memory system with no db name for in-memory sentinel`() { + assertEquals( + DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY), + dbMetadataFromFileName(":memory:"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for unix path`() { + assertEquals( + DbMetadata(name = "tracks.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data/data/com.example/databases/tracks.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name when fileName has no separator`() { + assertEquals( + DbMetadata(name = "tracks", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("tracks"), + ) + assertEquals( + DbMetadata(name = "tracks.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("tracks.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for relative path with forward slashes`() { + assertEquals( + DbMetadata(name = "myapp.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("databases/myapp.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for windows-style path`() { + assertEquals( + DbMetadata(name = "myapp.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("C:\\Users\\app\\databases\\myapp.db"), + ) + } + + @Test + fun `dbMetadataFromFileName uses last separator when both slash types are present`() { + assertEquals( + DbMetadata(name = "db.sqlite", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data\\mixed/path\\db.sqlite"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name when fileName ends with separator`() { + assertEquals( + DbMetadata(name = "databases", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data/data/com.example/databases/"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and unknown db name when fileName contains only separators`() { + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("/")) + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("///")) + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("\\\\")) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and unknown db name for empty fileName`() { + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("")) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt new file mode 100644 index 00000000000..319fc20d7ce --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt @@ -0,0 +1,219 @@ +package io.sentry.sqlite + +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.SentryDateProvider +import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.util.thread.IThreadChecker +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class DriverSpansTest { + + private class Fixture { + + val scopes = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + + fun getSut(isTransactionActive: Boolean = true, fileName: String = ":memory:"): DriverSpans { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) + if (isTransactionActive) { + whenever(scopes.span).thenReturn(sentryTracer) + } + return DriverSpans.fromFileName(fileName, scopes) + } + } + + private val fixture = Fixture() + + @Test + fun `startTimestamp is ns-precise and skips date provider when parent uses SentryNanotimeDate`() { + // Only the parent date is queued. If startTimestamp() were to call dateProvider.now(), + // the queue would underflow and the test would fail loudly — this is what verifies the + // optimization is in effect. + val parentDate = SentryNanotimeDate(1_000_000L, 100_000_000L) + val sut = setUpWithNanotimeDates(parentDate) + + val start = sut.startTimestamp() + + val durationNanos = 42_000_000L + sut.record("SELECT 1", start, durationNanos, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + + // startTimestamp returns an already-ns-precise value, anchored to the parent's wall clock and + // offset by elapsed System.nanoTime(). The exact ns-math is unit-tested in + // ChildStartTimestampOrNullTest; here we verify the integration shape. + assertIs(span.startDate) + assertEquals(start, span.startDate.nanoTimestamp()) + assertEquals(start + durationNanos, span.finishDate!!.nanoTimestamp()) + } + + @Test + fun `startTimestamp falls back to date provider when parent does not use SentryNanotimeDate`() { + val providerDate = SentryNanotimeDate(2_000_000L, 200_000_000L) + val parentSpan = mock() + whenever(parentSpan.startDate).thenReturn(SentryLongDate(1_000_000_000_000_000L)) + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + dateProvider = SentryDateProvider { providerDate } + } + whenever(fixture.scopes.options).thenReturn(options) + whenever(fixture.scopes.span).thenReturn(parentSpan) + + val sut = DriverSpans.fromFileName(":memory:", fixture.scopes) + + assertEquals(providerDate.nanoTimestamp(), sut.startTimestamp()) + } + + @Test + fun `startTimestamp falls back to date provider when no transaction is active`() { + val providerDate = SentryNanotimeDate(2_000_000L, 200_000_000L) + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + dateProvider = SentryDateProvider { providerDate } + } + whenever(fixture.scopes.options).thenReturn(options) + whenever(fixture.scopes.span).thenReturn(null) + + val sut = DriverSpans.fromFileName(":memory:", fixture.scopes) + + assertEquals(providerDate.nanoTimestamp(), sut.startTimestamp()) + } + + @Test + fun `record method records a span if a transaction is active`() { + val sut = fixture.getSut(isTransactionActive = true) + sut.record("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + assertEquals(1, fixture.sentryTracer.children.size) + } + + @Test + fun `record method does not record a span if no transaction is active`() { + val sut = fixture.getSut(isTransactionActive = false) + val start = sut.startTimestamp() + sut.record("SELECT 1", start, 1_000_000, SpanStatus.OK) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `record method creates a span with correct properties`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + sut.record("SELECT * FROM users", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.firstOrNull() + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals("SELECT * FROM users", span.description) + assertEquals("auto.db.sqlite", span.spanContext.origin) + assertEquals(SpanStatus.OK, span.status) + assertTrue(span.isFinished) + } + + @Test + fun `record method sets finishDate equal to startDate + durationNanos`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + val durationNanos = 42_000_000L + + sut.record("SELECT 1", start, durationNanos, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals(span.startDate.nanoTimestamp() + durationNanos, span.finishDate!!.nanoTimestamp()) + } + + @Test + fun `record method attaches throwable when provided`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + val exception = RuntimeException("disk I/O error") + + sut.record("INSERT INTO t VALUES(1)", start, 500_000, SpanStatus.INTERNAL_ERROR, exception) + + val span = fixture.sentryTracer.children.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + } + + @Test + fun `record method sets db system and db name when fileName is not the in-memory sentinel`() { + val sut = fixture.getSut(fileName = "/data/data/com.example/databases/tracks.db") + val start = sut.startTimestamp() + sut.record("SELECT 1", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `record method sets db system only when fileName is the in-memory sentinel`() { + val sut = fixture.getSut(fileName = ":memory:") + val start = sut.startTimestamp() + sut.record("SELECT 1", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("in-memory", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertNull(span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `record method sets blocked_main_thread to true and attaches call stack on main thread`() { + val sut = fixture.getSut() + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(true) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("main") + + sut.record("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertTrue(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + assertNotNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) + } + + @Test + fun `record method sets blocked_main_thread to false and does not attach a call stack on background thread`() { + val sut = fixture.getSut() + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(false) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("worker") + + sut.record("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertFalse(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + assertNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) + } + + private fun setUpWithNanotimeDates(vararg dates: SentryNanotimeDate): DriverSpans { + val dateQueue = ArrayDeque(dates.toList()) + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + dateProvider = SentryDateProvider { dateQueue.removeFirst() } + } + whenever(fixture.scopes.options).thenReturn(options) + fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) + return DriverSpans.fromFileName(":memory:", fixture.scopes) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt new file mode 100644 index 00000000000..212e3b032e4 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt @@ -0,0 +1,63 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement +import io.sentry.IScopes +import io.sentry.SentryOptions +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertSame +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteConnectionTest { + + private class Fixture { + + val scopes = mock() + val mockConnection = mock() + val mockStatement = mock() + lateinit var options: SentryOptions + + fun getSut(): SentrySQLiteConnection { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + whenever(mockConnection.prepare("SELECT 1")).thenReturn(mockStatement) + val spans = DriverSpans.fromFileName("test.db", scopes) + return SentrySQLiteConnection(mockConnection, spans) + } + } + + private val fixture = Fixture() + + @Test + fun `prepare returns a SentrySQLiteStatement`() { + val sut = fixture.getSut() + val statement = sut.prepare("SELECT 1") + assertIs(statement) + } + + @Test + fun `prepare with already-wrapped statement returns same instance without re-wrapping`() { + val sut = fixture.getSut() + val spans = DriverSpans.fromFileName("test.db", fixture.scopes) + val alreadyInstrumented = SentrySQLiteStatement(fixture.mockStatement, spans, "SELECT 1") + whenever(fixture.mockConnection.prepare("SELECT 1")).thenReturn(alreadyInstrumented) + + val statement = sut.prepare("SELECT 1") + + assertSame(alreadyInstrumented, statement) + } + + @Test + fun `all calls are propagated to the delegate`() { + val sut = fixture.getSut() + + sut.prepare("SELECT 1") + verify(fixture.mockConnection).prepare("SELECT 1") + + sut.close() + verify(fixture.mockConnection).close() + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt new file mode 100644 index 00000000000..5816f3d859c --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt @@ -0,0 +1,156 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteStatement +import androidx.sqlite.driver.SupportSQLiteDriver +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.TransactionContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.Before +import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteDriverTest { + + private class Fixture { + + val mockDriver = mock() + val mockConnection = mock() + + fun getSut(fileName: String): SentrySQLiteDriver { + whenever(mockDriver.open(fileName)).thenReturn(mockConnection) + return SentrySQLiteDriver.create(mockDriver) as SentrySQLiteDriver + } + } + + private val fixture = Fixture() + + @Before + fun setup() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + } + + @Test + fun `create registers SQLiteDriver integration`() { + assertFalse(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLiteDriver")) + SentrySQLiteDriver.create(fixture.mockDriver) + assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLiteDriver")) + } + + @Test + fun `create with non-wrapped driver returns SentrySQLiteDriver`() { + val result = SentrySQLiteDriver.create(fixture.mockDriver) + assertIs(result) + } + + @Test + fun `create with already-wrapped driver returns same instance without re-wrapping`() { + val wrapped = SentrySQLiteDriver.create(fixture.mockDriver) + val doubleWrapped = SentrySQLiteDriver.create(wrapped) + assertSame(wrapped, doubleWrapped) + } + + @Test + fun `create with SupportSQLiteDriver bridge returns same instance without wrapping`() { + val bridge = SupportSQLiteDriver() + + val result = SentrySQLiteDriver.create(bridge) + + assertSame(bridge, result) + assertFalse(result is SentrySQLiteDriver) + } + + @Test + fun `hasConnectionPool forwards delegate value when supported`() { + whenever(fixture.mockDriver.hasConnectionPool).thenReturn(true) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertTrue(sut.hasConnectionPool) + } + + @Test + fun `hasConnectionPool returns false when delegate throws LinkageError`() { + whenever(fixture.mockDriver.hasConnectionPool).thenThrow(AbstractMethodError()) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertFalse(sut.hasConnectionPool) + } + + @Test + fun `hasConnectionPool does not catch non-LinkageErrors`() { + whenever(fixture.mockDriver.hasConnectionPool).thenThrow(IllegalStateException()) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertFailsWith { sut.hasConnectionPool } + } + + @Test + fun `open returns SentrySQLiteConnection wrapping delegate if wrapping succeeds`() { + val driver = fixture.getSut("myapp.db") + val connection = driver.open("myapp.db") + assertIs(connection) + } + + @Test + fun `open returns the unwrapped delegate if wrapping fails`() { + val brokenScopes = mock() + val validOptions = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(brokenScopes.options) + .thenThrow(RuntimeException("Sentry options unavailable")) + .thenReturn(validOptions) + + Mockito.mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(brokenScopes) + + val driver = fixture.getSut("myapp.db") + val result = driver.open("myapp.db") + + assertSame(fixture.mockConnection, result) + verify(fixture.mockDriver).open("myapp.db") + } + } + + // Smoke test ensuring all layers are properly wired up. + @Test + fun `full stack produces a span with correct metadata`() { + val scopes = mock() + val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + val tracer = SentryTracer(TransactionContext("name", "op"), scopes) + whenever(scopes.span).thenReturn(tracer) + + val mockStatement = mock() + whenever(fixture.mockConnection.prepare("SELECT * FROM users")).thenReturn(mockStatement) + whenever(mockStatement.step()).thenReturn(true, false) + + Mockito.mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + + val driver = fixture.getSut("/data/data/com.example/databases/myapp.db") + val connection = driver.open("/data/data/com.example/databases/myapp.db") + val statement = connection.prepare("SELECT * FROM users") + + assertIs(connection) + assertIs(statement) + + statement.step() + statement.step() + + val span = tracer.children.firstOrNull() + assertNotNull(span) + assertEquals("myapp.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt new file mode 100644 index 00000000000..bc6b074545a --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt @@ -0,0 +1,290 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteStatement +import io.sentry.SpanStatus +import java.util.concurrent.atomic.AtomicLong +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteStatementTest { + + private class Fixture { + val mockStatement = mock() + val mockSpans = mock() + val startTimestampNanos = 1_000_000_000_000L + val fakeClock = AtomicLong(0L) + + fun getSut(sql: String): SentrySQLiteStatement { + whenever(mockSpans.startTimestamp()).thenReturn(startTimestampNanos) + return SentrySQLiteStatement(mockStatement, mockSpans, sql, fakeClock::getAndIncrement) + } + } + + private val fixture = Fixture() + + @Test + fun `step calls recordSpan once after iteration completes`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, true, false) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + sut.step() + verify(fixture.mockSpans) + .record( + eq("SELECT * FROM users"), + eq(fixture.startTimestampNanos), + any(), + eq(SpanStatus.OK), + anyOrNull(), + ) + } + + @Test + fun `step that throws an exception calls recordSpan with INTERNAL_ERROR and exception`() { + val sut = fixture.getSut("BAD SQL") + val exception = RuntimeException("db error") + whenever(fixture.mockStatement.step()).thenThrow(exception) + + assertFailsWith { sut.step() } + + verify(fixture.mockSpans) + .record( + eq("BAD SQL"), + eq(fixture.startTimestampNanos), + any(), + eq(SpanStatus.INTERNAL_ERROR), + eq(exception), + ) + } + + @Test + fun `step after exception calls recordSpan once new iteration cycle completes`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()) + .thenThrow(RuntimeException("first failure")) + .thenReturn(false) + + assertFailsWith { sut.step() } + verifyCalledRecordSpan(times = 1) + + sut.step() + verifyCalledRecordSpan(times = 2) + } + + @Test + fun `step after step iteration completes does not call recordSpan again`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()).thenReturn(true, false, false) + + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.step() + + verifyCalledRecordSpan(times = 1) + verify(fixture.mockStatement, times(3)).step() + } + + @Test + fun `reset calls recordSpan if step iteration is in progress`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + + sut.reset() + + verifyCalledRecordSpan() + } + + @Test + fun `reset does not call recordSpan if step iteration has not started`() { + val sut = fixture.getSut("SELECT 1") + sut.reset() + verifyNeverCalledRecordSpan() + } + + @Test + fun `reset does not call recordSpan if step iteration has completed`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, false) + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.reset() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `step after reset calls recordSpan when new iteration cycle completes`() { + val sut = fixture.getSut("SELECT 1") + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.reset() + sut.step() + + verifyCalledRecordSpan(times = 2) + } + + @Test + fun `close calls recordSpan if step iteration is in progress`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + + sut.close() + + verifyCalledRecordSpan() + } + + @Test + fun `close does not call recordSpan if step iteration has not started`() { + val sut = fixture.getSut("SELECT 1") + sut.close() + verifyNeverCalledRecordSpan() + } + + @Test + fun `close does not call recordSpan if step iteration has completed`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, false) + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.close() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `step after close does not call recordSpan`() { + val sut = fixture.getSut("SELECT 1") + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.close() + sut.step() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `reset after close does not call recordSpan`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.close() + verifyCalledRecordSpan(times = 1) + + sut.reset() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `recorded duration captures step time but excludes time between steps`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()) + .thenAnswer { + fixture.fakeClock.addAndGet(10) + true + } + .thenAnswer { + fixture.fakeClock.addAndGet(20) + true + } + .thenAnswer { + fixture.fakeClock.addAndGet(30) + false + } + + sut.step() + // Simulate work done between steps. + fixture.fakeClock.addAndGet(1_000_000) + sut.step() + fixture.fakeClock.addAndGet(2_000_000) + sut.step() + + val durationCaptor = argumentCaptor() + verify(fixture.mockSpans).record(any(), any(), durationCaptor.capture(), any(), anyOrNull()) + // Each step contributes its internal time (10 + 20 + 30) plus one unit from + // fakeClock::getAndIncrement between before/after reads, so total is 63. + assertEquals(63L, durationCaptor.firstValue) + } + + @Test + fun `all calls are propagated to the delegate`() { + val sut = fixture.getSut("SELECT 1") + + sut.bindBlob(0, byteArrayOf()) + verify(fixture.mockStatement).bindBlob(0, byteArrayOf()) + + sut.bindDouble(0, 1.0) + verify(fixture.mockStatement).bindDouble(0, 1.0) + + sut.bindLong(0, 1L) + verify(fixture.mockStatement).bindLong(0, 1L) + + sut.bindText(0, "text") + verify(fixture.mockStatement).bindText(0, "text") + + sut.bindNull(0) + verify(fixture.mockStatement).bindNull(0) + + sut.getDouble(0) + verify(fixture.mockStatement).getDouble(0) + + sut.getLong(0) + verify(fixture.mockStatement).getLong(0) + + sut.getText(0) + verify(fixture.mockStatement).getText(0) + + sut.isNull(0) + verify(fixture.mockStatement).isNull(0) + + sut.getColumnCount() + verify(fixture.mockStatement).getColumnCount() + + sut.getColumnName(0) + verify(fixture.mockStatement).getColumnName(0) + + sut.step() + verify(fixture.mockStatement).step() + + sut.reset() + verify(fixture.mockStatement).reset() + + sut.clearBindings() + verify(fixture.mockStatement).clearBindings() + + sut.close() + verify(fixture.mockStatement).close() + } + + private fun verifyNeverCalledRecordSpan() { + verifyCalledRecordSpan(times = 0) + } + + private fun verifyCalledRecordSpan(times: Int = 1) { + verify(fixture.mockSpans, times(times)).record(any(), any(), any(), any(), anyOrNull()) + } +} diff --git a/sentry-android-timber/build.gradle.kts b/sentry-android-timber/build.gradle.kts index 16083b43f1b..3c8ac1ea1e4 100644 --- a/sentry-android-timber/build.gradle.kts +++ b/sentry-android-timber/build.gradle.kts @@ -1,10 +1,10 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } @@ -32,10 +32,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt b/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt index 743501d664f..61b1f99fb16 100644 --- a/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt +++ b/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt @@ -3,6 +3,8 @@ package io.sentry.android.timber import android.util.Log import io.sentry.Breadcrumb import io.sentry.IScopes +import io.sentry.SentryAttribute +import io.sentry.SentryAttributes import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.SentryLogLevel @@ -66,7 +68,7 @@ public class SentryTimberTree( /** Log an info message with optional format args. */ override fun i(message: String?, vararg args: Any?) { - super.d(message, *args) + super.i(message, *args) logWithSentry(Log.INFO, null, message, *args) } @@ -183,7 +185,7 @@ public class SentryTimberTree( captureEvent(level, tag, sentryMessage, throwable) addBreadcrumb(level, sentryMessage, throwable) - addLog(logLevel, message, throwable, *args) + addLog(logLevel, message, tag, throwable, *args) } /** do not log if it's lower than min. required level. */ @@ -240,12 +242,16 @@ public class SentryTimberTree( private fun addLog( sentryLogLevel: SentryLogLevel, msg: String?, + tag: String?, throwable: Throwable?, vararg args: Any?, ) { // checks the log level if (isLoggable(sentryLogLevel, minLogLevel)) { - val params = SentryLogParameters() + val attributes = tag?.let { + SentryAttributes.of(SentryAttribute.stringAttribute("timber.tag", tag)) + } + val params = SentryLogParameters.create(attributes) params.origin = "auto.log.timber" val throwableMsg = throwable?.message diff --git a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt index 43a45da7bb3..7c21eca8ef0 100644 --- a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt +++ b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt @@ -1,10 +1,14 @@ package io.sentry.android.timber import io.sentry.IScopes +import io.sentry.ITransportFactory +import io.sentry.ScopesAdapter +import io.sentry.Sentry import io.sentry.SentryLevel import io.sentry.SentryLogLevel import io.sentry.SentryOptions import io.sentry.protocol.SdkVersion +import io.sentry.transport.ITransport import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -12,6 +16,7 @@ import kotlin.test.assertTrue import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import timber.log.Timber class SentryTimberIntegrationTest { @@ -112,4 +117,43 @@ class SentryTimberIntegrationTest { assertTrue(fixture.options.sdkVersion!!.integrationSet.contains("Timber")) } + + @Test + fun `a beforeSend callback that logs via Timber does not recurse`() { + // End-to-end guard against SDK-CRASHES-JAVA-3T3H style recursion: with a real Sentry instance, + // a beforeSend callback that logs through the planted SentryTimberTree must not loop back into + // capture forever. + val transport = mock() + val transportFactory = mock() + whenever(transportFactory.create(any(), any())).thenReturn(transport) + + var beforeSendInvocations = 0 + Sentry.init { options -> + options.dsn = "https://key@sentry.io/123" + options.setTransportFactory(transportFactory) + options.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> + beforeSendInvocations++ + Timber.e("logging from beforeSend") + event + } + } + Timber.plant( + SentryTimberTree( + ScopesAdapter.getInstance(), + SentryLevel.ERROR, + SentryLevel.INFO, + SentryLogLevel.INFO, + ) + ) + + try { + Timber.e("outer error") + + // Without the core re-entrancy guard this recurses until a StackOverflowError. The nested + // Timber.e is dropped before its own beforeSend, so the callback runs exactly once. + assertEquals(1, beforeSendInvocations) + } finally { + Sentry.close() + } + } } diff --git a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberTreeTest.kt b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberTreeTest.kt index 2e610cf2798..f1d6d5a51bd 100644 --- a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberTreeTest.kt +++ b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberTreeTest.kt @@ -1,5 +1,6 @@ package io.sentry.android.timber +import android.util.Log import io.sentry.Breadcrumb import io.sentry.Scopes import io.sentry.SentryLevel @@ -23,18 +24,19 @@ import timber.log.Timber class SentryTimberTreeTest { private class Fixture { - val scopes = mock() - val logs = mock() - - init { - whenever(scopes.logger()).thenReturn(logs) - } + lateinit var scopes: Scopes + lateinit var logs: ILoggerApi fun getSut( minEventLevel: SentryLevel = SentryLevel.ERROR, minBreadcrumbLevel: SentryLevel = SentryLevel.INFO, minLogsLevel: SentryLogLevel = SentryLogLevel.INFO, - ): SentryTimberTree = SentryTimberTree(scopes, minEventLevel, minBreadcrumbLevel, minLogsLevel) + ): SentryTimberTree { + logs = mock() + scopes = mock() + whenever(scopes.logger()).thenReturn(logs) + return SentryTimberTree(scopes, minEventLevel, minBreadcrumbLevel, minLogsLevel) + } } private val fixture = Fixture() @@ -139,6 +141,56 @@ class SentryTimberTreeTest { verify(fixture.scopes).captureEvent(check { assertEquals("tag", it.getTag("TimberTag")) }) } + @Test + fun `Tree captures an event with TimberTag tag for debug events`() { + val sut = fixture.getSut(minEventLevel = SentryLevel.INFO) + Timber.plant(sut) + // only available thru static class + Timber.tag("infoTag").i("message") + verify(fixture.scopes).captureEvent(check { assertEquals("infoTag", it.getTag("TimberTag")) }) + } + + @Test + fun `Tree captures an event with chained tag usage`() { + val sut = fixture.getSut(minEventLevel = SentryLevel.INFO) + Timber.plant(sut) + // only available thru static class + Timber.tag("infoTag").log(Log.INFO, "message") + verify(fixture.scopes).captureEvent(check { assertEquals("infoTag", it.getTag("TimberTag")) }) + } + + @Test + fun `Tree properly propagates all levels`() { + val levels = + listOf( + Pair(Log.DEBUG, SentryLevel.DEBUG), + Pair(Log.VERBOSE, SentryLevel.DEBUG), + Pair(Log.INFO, SentryLevel.INFO), + Pair(Log.WARN, SentryLevel.WARNING), + Pair(Log.ERROR, SentryLevel.ERROR), + Pair(Log.ASSERT, SentryLevel.FATAL), + ) + + for (level in levels) { + Timber.uprootAll() + + val logLevel = level.first + val sentryLevel = level.second + + val sut = fixture.getSut(minEventLevel = sentryLevel) + Timber.plant(sut) + // only available thru static class + Timber.tag("tag").log(logLevel, "message") + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("tag", it.getTag("TimberTag")) + assertEquals(sentryLevel, it.level) + } + ) + } + } + @Test fun `Tree captures an event without TimberTag tag`() { val sut = fixture.getSut() @@ -334,8 +386,28 @@ class SentryTimberTreeTest { verify(fixture.logs) .log( eq(SentryLogLevel.ERROR), - check { assertEquals("auto.log.timber", it.origin) }, + check { + assertEquals("auto.log.timber", it.origin) + assertEquals(null, it.attributes?.attributes?.get("timber.tag")) + }, eq("My message\nthrowable message"), ) } + + @Test + fun `Tree logs timber tag`() { + val sut = fixture.getSut() + Timber.plant(sut) + Timber.tag("timberTag").i("message") + + verify(fixture.logs) + .log( + eq(SentryLogLevel.INFO), + check { + assertEquals("auto.log.timber", it.origin) + assertEquals("timberTag", it.attributes?.attributes?.get("timber.tag")?.value) + }, + eq("message"), + ) + } } diff --git a/sentry-apache-http-client-5/build.gradle.kts b/sentry-apache-http-client-5/build.gradle.kts index 4c9aba6e31b..7502fb6c4b8 100644 --- a/sentry-apache-http-client-5/build.gradle.kts +++ b/sentry-apache-http-client-5/build.gradle.kts @@ -1,19 +1,20 @@ import net.ltgt.gradle.errorprone.errorprone -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } -tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 +kotlin { + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } dependencies { @@ -34,27 +35,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-apache-http-client-5/src/main/java/io/sentry/transport/apache/ApacheHttpClientTransport.java b/sentry-apache-http-client-5/src/main/java/io/sentry/transport/apache/ApacheHttpClientTransport.java index 2cf1484b563..0d768bf1c52 100644 --- a/sentry-apache-http-client-5/src/main/java/io/sentry/transport/apache/ApacheHttpClientTransport.java +++ b/sentry-apache-http-client-5/src/main/java/io/sentry/transport/apache/ApacheHttpClientTransport.java @@ -113,16 +113,29 @@ public void send(final @NotNull SentryEnvelope envelope, final @NotNull Hint hin @Override public void completed(SimpleHttpResponse response) { if (response.getCode() != 200) { - options - .getLogger() - .log(ERROR, "Request failed, API returned %s", response.getCode()); + if (response.getCode() == 413) { + options + .getLogger() + .log( + ERROR, + "Envelope was discarded by the server because it was too large." + + " Consider reducing the size of events, breadcrumbs," + + " or attachments." + + " You can use the `SentryOptions.onOversizedEvent`" + + " callback to customize how oversized events" + + " are handled."); + } else { + options + .getLogger() + .log(ERROR, "Request failed, API returned %s", response.getCode()); + } if (response.getCode() >= 400 && response.getCode() != 429) { if (!HintUtils.hasType(hint, Retryable.class)) { options .getClientReportRecorder() .recordLostEnvelope( - DiscardReason.NETWORK_ERROR, envelopeWithClientReport); + DiscardReason.SEND_ERROR, envelopeWithClientReport); } } } else { diff --git a/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportClientReportTest.kt b/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportClientReportTest.kt index d110b802345..afe0b38538d 100644 --- a/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportClientReportTest.kt +++ b/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportClientReportTest.kt @@ -145,7 +145,7 @@ class ApacheHttpClientTransportClientReportTest { .attachReportToEnvelope(same(fixture.envelopeBeforeClientReportAttached)) verify(fixture.clientReportRecorder, times(1)) .recordLostEnvelope( - eq(DiscardReason.NETWORK_ERROR), + eq(DiscardReason.SEND_ERROR), same(fixture.envelopeAfterClientReportAttached), ) verifyNoMoreInteractions(fixture.clientReportRecorder) @@ -173,7 +173,7 @@ class ApacheHttpClientTransportClientReportTest { .attachReportToEnvelope(same(fixture.envelopeBeforeClientReportAttached)) verify(fixture.clientReportRecorder, times(1)) .recordLostEnvelope( - eq(DiscardReason.NETWORK_ERROR), + eq(DiscardReason.SEND_ERROR), same(fixture.envelopeAfterClientReportAttached), ) verifyNoMoreInteractions(fixture.clientReportRecorder) @@ -191,6 +191,34 @@ class ApacheHttpClientTransportClientReportTest { verifyNoMoreInteractions(fixture.clientReportRecorder) } + @Test + fun `records lost envelope with send_error on 413 for non retryable`() { + val sut = fixture.getSut(SimpleHttpResponse(413)) + + sut.send(fixture.envelopeBeforeClientReportAttached) + + verify(fixture.clientReportRecorder, times(1)) + .attachReportToEnvelope(same(fixture.envelopeBeforeClientReportAttached)) + verify(fixture.clientReportRecorder, times(1)) + .recordLostEnvelope( + eq(DiscardReason.SEND_ERROR), + same(fixture.envelopeAfterClientReportAttached), + ) + verifyNoMoreInteractions(fixture.clientReportRecorder) + } + + @Test + fun `does not record lost envelope on 413 error for retryable`() { + val sut = fixture.getSut(SimpleHttpResponse(413)) + + sut.send(fixture.envelopeBeforeClientReportAttached, retryableHint()) + + verify(fixture.clientReportRecorder, times(1)) + .attachReportToEnvelope(same(fixture.envelopeBeforeClientReportAttached)) + verify(fixture.clientReportRecorder, never()).recordLostEnvelope(any(), any()) + verifyNoMoreInteractions(fixture.clientReportRecorder) + } + @Test fun `does not record lost envelope on 429 error for non retryable`() { val sut = fixture.getSut(SimpleHttpResponse(429)) diff --git a/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt b/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt index 639dd4e0513..9f5c9b910ad 100644 --- a/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt +++ b/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt @@ -213,7 +213,7 @@ class ApacheHttpClientTransportTest { val now = Date(9001) val sut = fixture.getSut() fixture.options.dateProvider = mock() - whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now, 0)) + whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now.time, 0)) val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) sut.send(envelope) @@ -226,7 +226,7 @@ class ApacheHttpClientTransportTest { val now = Date(9001) val sut = fixture.getSut() fixture.options.dateProvider = mock() - whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now, 0)) + whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now.time, 0)) val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) sut.send(envelope, Hint()) diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index 8819e0993d4..70f43d946ef 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -5,11 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -38,31 +37,6 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") -} - -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } } tasks.withType().configureEach { diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index 85ea2c3b52b..4f1276f0bf4 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -5,16 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) -} - -configure { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -45,31 +39,6 @@ dependencies { testImplementation(libs.mockito.inline) testImplementation(libs.okhttp.mockwebserver) testImplementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0") - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") -} - -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } } tasks.withType().configureEach { diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index 909d52aa127..2da8d8b20c1 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -5,11 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -39,31 +38,6 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") -} - -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } } tasks.withType().configureEach { diff --git a/sentry-async-profiler/api/sentry-async-profiler.api b/sentry-async-profiler/api/sentry-async-profiler.api index 6366fccc24b..045465349c2 100644 --- a/sentry-async-profiler/api/sentry-async-profiler.api +++ b/sentry-async-profiler/api/sentry-async-profiler.api @@ -3,18 +3,18 @@ public final class io/sentry/asyncprofiler/BuildConfig { public static final field VERSION_NAME Ljava/lang/String; } -public final class io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter : io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter { - public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments;Lio/sentry/SentryStackTraceFactory;Lio/sentry/ILogger;)V +public final class io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter : one/convert/JfrConverter { + public fun (Lone/jfr/JfrReader;Lone/convert/Arguments;Lio/sentry/SentryStackTraceFactory;Lio/sentry/ILogger;)V public static fun convertFromFileStatic (Ljava/lang/String;)Lio/sentry/protocol/profiling/SentryProfile; } -public final class io/sentry/asyncprofiler/convert/NonAggregatingEventCollector : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector { +public final class io/sentry/asyncprofiler/convert/NonAggregatingEventCollector : one/jfr/event/EventCollector { public fun ()V public fun afterChunk ()V public fun beforeChunk ()V - public fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)V + public fun collect (Lone/jfr/event/Event;)V public fun finish ()Z - public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor;)V + public fun forEach (Lone/jfr/event/EventCollector$Visitor;)V } public final class io/sentry/asyncprofiler/profiling/JavaContinuousProfiler : io/sentry/IContinuousProfiler, io/sentry/transport/RateLimiter$IRateLimitObserver { @@ -45,292 +45,3 @@ public final class io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverte public fun convertFromFile (Ljava/lang/String;)Lio/sentry/protocol/profiling/SentryProfile; } -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments { - public field alloc Z - public field bci Z - public field classify Z - public field cpu Z - public field dot Z - public field exclude Ljava/util/regex/Pattern; - public final field files Ljava/util/List; - public field from J - public field grain D - public field help Z - public field highlight Ljava/lang/String; - public field include Ljava/util/regex/Pattern; - public field inverted Z - public field leak Z - public field lines Z - public field live Z - public field lock Z - public field minwidth D - public field nativemem Z - public field norm Z - public field output Ljava/lang/String; - public field reverse Z - public field simple Z - public field skip I - public field state Ljava/lang/String; - public field threads Z - public field title Ljava/lang/String; - public field to J - public field total Z - public field wall Z - public fun ([Ljava/lang/String;)V -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Frame : java/util/HashMap { - public static final field TYPE_C1_COMPILED B - public static final field TYPE_CPP B - public static final field TYPE_INLINED B - public static final field TYPE_INTERPRETED B - public static final field TYPE_JIT_COMPILED B - public static final field TYPE_KERNEL B - public static final field TYPE_NATIVE B -} - -public abstract class io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter { - protected final field args Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments; - protected final field collector Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector; - protected final field jfr Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader; - protected field methodNames Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; - public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments;)V - protected fun collectEvents ()V - public fun convert ()V - protected fun convertChunk ()V - protected fun createCollector (Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments;)Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector; - public synthetic fun getCategory (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace;)Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Classifier$Category; - public fun getClassName (J)Ljava/lang/String; - public fun getMethodName (JB)Ljava/lang/String; - public fun getPlainThreadName (I)Ljava/lang/String; - public fun getStackTraceElement (JBI)Ljava/lang/StackTraceElement; - public fun getThreadName (I)Ljava/lang/String; - protected fun getThreadStates (Z)Ljava/util/BitSet; - protected fun isNativeFrame (B)Z - protected fun toThreadState (Ljava/lang/String;)I - protected fun toTicks (J)J -} - -protected abstract class io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter$AggregatedEventVisitor : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor { - protected fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter;)V - protected abstract fun visit (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;J)V - public final fun visit (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;JJ)V -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/ClassRef { - public final field name J - public fun (J)V -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary { - public fun ()V - public fun (I)V - public fun clear ()V - public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary$Visitor;)V - public fun get (J)Ljava/lang/Object; - public fun preallocate (I)I - public fun put (JLjava/lang/Object;)V - public fun size ()I -} - -public abstract interface class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary$Visitor { - public abstract fun visit (JLjava/lang/Object;)V -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt { - public fun ()V - public fun (I)V - public fun clear ()V - public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt$Visitor;)V - public fun get (J)I - public fun get (JI)I - public fun preallocate (I)I - public fun put (JI)V -} - -public abstract interface class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt$Visitor { - public abstract fun visit (JI)V -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrClass { - public fun field (Ljava/lang/String;)Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField; -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField { -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader : java/io/Closeable { - public field chunkEndNanos J - public field chunkStartNanos J - public field chunkStartTicks J - public final field classes Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; - public field endNanos J - public final field enums Ljava/util/Map; - public final field javaThreads Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; - public final field methods Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; - public final field settings Ljava/util/Map; - public final field stackTraces Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; - public field startNanos J - public field startTicks J - public field stopAtNewChunk Z - public final field strings Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; - public final field symbols Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; - public final field threads Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; - public field ticksPerSec J - public final field types Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; - public final field typesByName Ljava/util/Map; - public fun (Ljava/lang/String;)V - public fun (Ljava/nio/ByteBuffer;)V - public fun close ()V - public fun durationNanos ()J - public fun eof ()Z - public fun getBytes ()[B - public fun getDouble ()D - public fun getEnumKey (Ljava/lang/String;Ljava/lang/String;)I - public fun getEnumValue (Ljava/lang/String;I)Ljava/lang/String; - public fun getFloat ()F - public fun getString ()Ljava/lang/String; - public fun getVarint ()I - public fun getVarlong ()J - public fun hasMoreChunks ()Z - public fun incomplete ()Z - public fun readAllEvents ()Ljava/util/List; - public fun readAllEvents (Ljava/lang/Class;)Ljava/util/List; - public fun readEvent ()Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event; - public fun readEvent (Ljava/lang/Class;)Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event; - public fun registerEvent (Ljava/lang/String;Ljava/lang/Class;)V -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/MethodRef { - public final field cls J - public final field name J - public final field sig J - public fun (JJJ)V -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace { - public final field locations [I - public final field methods [J - public final field types [B - public fun ([J[B[I)V -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/AllocationSample : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { - public final field allocationSize J - public final field classId I - public final field tlabSize J - public fun (JIIIJJ)V - public fun classId ()J - public fun hashCode ()I - public fun sameGroup (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)Z - public fun value ()J -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/CPULoad : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { - public final field jvmSystem F - public final field jvmUser F - public final field machineTotal F - public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;)V -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ContendedLock : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { - public final field classId I - public final field duration J - public fun (JIIJI)V - public fun classId ()J - public fun hashCode ()I - public fun sameGroup (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)Z - public fun value ()J -} - -public abstract class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event : java/lang/Comparable { - public final field stackTraceId I - public final field tid I - public final field time J - protected fun (JII)V - public fun classId ()J - public fun compareTo (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)I - public synthetic fun compareTo (Ljava/lang/Object;)I - public fun hashCode ()I - public fun sameGroup (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)Z - public fun samples ()J - public fun toString ()Ljava/lang/String; - public fun value ()J -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventAggregator : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector { - public fun (ZD)V - public fun afterChunk ()V - public fun beforeChunk ()V - public fun coarsen (D)V - public fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)V - public fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;JJ)V - public fun finish ()Z - public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor;)V - public fun size ()I -} - -public abstract interface class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector { - public abstract fun afterChunk ()V - public abstract fun beforeChunk ()V - public abstract fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)V - public abstract fun finish ()Z - public abstract fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor;)V -} - -public abstract interface class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor { - public abstract fun visit (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;JJ)V -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ExecutionSample : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { - public final field samples I - public final field threadState I - public fun (JIIII)V - public fun samples ()J - public fun value ()J -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/GCHeapSummary : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { - public final field afterGC Z - public final field committed J - public final field gcId I - public final field reserved J - public final field used J - public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;)V -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/LiveObject : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { - public final field allocationSize J - public final field allocationTime J - public final field classId I - public fun (JIIIJJ)V - public fun classId ()J - public fun hashCode ()I - public fun sameGroup (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)Z - public fun value ()J -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocEvent : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { - public final field address J - public final field size J - public fun (JIIJJ)V - public fun value ()J -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocLeakAggregator : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector { - public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector;)V - public fun afterChunk ()V - public fun beforeChunk ()V - public fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)V - public fun finish ()Z - public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor;)V -} - -public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ObjectCount : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { - public final field classId I - public final field count J - public final field gcId I - public final field totalSize J - public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;)V -} - diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index 5b78d5b99e4..17454baa662 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -4,11 +4,11 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` kotlin("jvm") - jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -20,7 +20,8 @@ kotlin { explicitApi() } dependencies { api(projects.sentry) - implementation("tools.profiler:async-profiler:3.0") + implementation(libs.async.profiler) + implementation(libs.async.profiler.jfr.converter) compileOnly(libs.jetbrains.annotations) compileOnly(libs.nopen.annotations) @@ -36,27 +37,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java index f6db9a86ab3..718fae422f7 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -5,12 +5,6 @@ import io.sentry.Sentry; import io.sentry.SentryLevel; import io.sentry.SentryStackTraceFactory; -import io.sentry.asyncprofiler.vendor.asyncprofiler.convert.Arguments; -import io.sentry.asyncprofiler.vendor.asyncprofiler.convert.JfrConverter; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.StackTrace; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.Event; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.EventCollector; import io.sentry.protocol.SentryStackFrame; import io.sentry.protocol.profiling.SentryProfile; import io.sentry.protocol.profiling.SentrySample; @@ -20,13 +14,18 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import one.convert.Arguments; +import one.convert.JfrConverter; +import one.jfr.JfrReader; +import one.jfr.StackTrace; +import one.jfr.event.Event; +import one.jfr.event.EventCollector; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @ApiStatus.Internal public final class JfrAsyncProfilerToSentryProfileConverter extends JfrConverter { - private static final double NANOS_PER_SECOND = 1_000_000_000.0; private static final long UNKNOWN_THREAD_ID = -1; private final @NotNull SentryProfile sentryProfile = new SentryProfile(); @@ -83,7 +82,6 @@ private class ProfileEventVisitor implements EventCollector.Visitor { private final @NotNull SentryStackTraceFactory stackTraceFactory; private final @NotNull JfrReader jfr; private final @NotNull Arguments args; - private final double ticksPerNanosecond; public ProfileEventVisitor( @NotNull SentryProfile sentryProfile, @@ -94,7 +92,6 @@ public ProfileEventVisitor( this.stackTraceFactory = stackTraceFactory; this.jfr = jfr; this.args = args; - ticksPerNanosecond = jfr.ticksPerSec / NANOS_PER_SECOND; } @Override @@ -150,11 +147,7 @@ private void processSampleWithStack(Event event, long threadId, StackTrace stack } private double calculateTimestamp(Event event) { - long nanosFromStart = (long) ((event.time - jfr.chunkStartTicks) / ticksPerNanosecond); - - long timeNs = jfr.chunkStartNanos + nanosFromStart; - - return DateUtils.nanosToSeconds(timeNs); + return DateUtils.nanosToSeconds(jfr.eventTimeToNanos(event.time)); } private int addStackTrace(StackTrace stackTrace) { @@ -247,6 +240,11 @@ private String extractSanitizedClassName(String classNameWithLambdas) { } } + private String getPlainThreadName(int tid) { + String threadName = jfr.threads.get(tid); + return threadName == null ? "[tid=" + tid + ']' : threadName; + } + private boolean hasPackageStructure(String className) { return className.lastIndexOf('.') > 0; } diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/NonAggregatingEventCollector.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/NonAggregatingEventCollector.java index c39259b7799..da42b34868b 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/NonAggregatingEventCollector.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/NonAggregatingEventCollector.java @@ -1,9 +1,9 @@ package io.sentry.asyncprofiler.convert; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.Event; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.EventCollector; import java.util.ArrayList; import java.util.List; +import one.jfr.event.Event; +import one.jfr.event.EventCollector; import org.jetbrains.annotations.ApiStatus; @ApiStatus.Internal diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java index 7af3032f9ae..f5c314bf96c 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java @@ -230,10 +230,10 @@ private void start() { // Example command: start,jfr,event=wall,interval=9900us,file=/path/to/trace.jfr final String command = String.format( - "start,jfr,event=wall,interval=%s,file=%s", profilingIntervalMicros, filename); + "start,jfr,event=wall,nobatch,interval=%s,file=%s", + profilingIntervalMicros, filename); profiler.execute(command); - } catch (Exception e) { logger.log(SentryLevel.ERROR, "Failed to start profiling: ", e); filename = ""; diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider.java index b8aa9111fae..a106cffc7dc 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider.java @@ -5,7 +5,6 @@ import io.sentry.profiling.JavaProfileConverterProvider; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; /** * AsyncProfiler implementation of {@link JavaProfileConverterProvider}. This provider integrates @@ -15,7 +14,7 @@ public final class AsyncProfilerProfileConverterProvider implements JavaProfileConverterProvider { @Override - public @Nullable IProfileConverter getProfileConverter() { + public @NotNull IProfileConverter getProfileConverter() { return new AsyncProfilerProfileConverter(); } diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments.java deleted file mode 100644 index f3b44fccc09..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.convert; - -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; -import java.util.*; -import java.util.regex.Pattern; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@ApiStatus.Internal -public final class Arguments { - public @NotNull String title = "Flame Graph"; - public @Nullable String highlight; - public @Nullable String output; - public @Nullable String state; - public @Nullable Pattern include; - public @Nullable Pattern exclude; - public double minwidth; - public double grain; - public int skip; - public boolean help; - public boolean reverse; - public boolean inverted; - public boolean cpu; - public boolean wall; - public boolean alloc; - public boolean nativemem; - public boolean leak; - public boolean live; - public boolean lock; - public boolean threads; - public boolean classify; - public boolean total; - public boolean lines; - public boolean bci; - public boolean simple; - public boolean norm; - public boolean dot; - public long from; - public long to; - public final List files = new ArrayList<>(); - - public Arguments(String... args) { - for (int i = 0; i < args.length; i++) { - String arg = args[i]; - String fieldName; - if (arg.startsWith("--")) { - fieldName = arg.substring(2); - } else if (arg.startsWith("-") && arg.length() == 2) { - fieldName = alias(arg.charAt(1)); - } else { - files.add(arg); - continue; - } - - try { - Field f = Arguments.class.getDeclaredField(fieldName); - if ((f.getModifiers() & (Modifier.PRIVATE | Modifier.STATIC | Modifier.FINAL)) != 0) { - throw new IllegalArgumentException(arg); - } - - Class type = f.getType(); - if (type == String.class) { - f.set(this, args[++i]); - } else if (type == boolean.class) { - f.setBoolean(this, true); - } else if (type == int.class) { - f.setInt(this, Integer.parseInt(args[++i])); - } else if (type == double.class) { - f.setDouble(this, Double.parseDouble(args[++i])); - } else if (type == long.class) { - f.setLong(this, parseTimestamp(args[++i])); - } else if (type == Pattern.class) { - f.set(this, Pattern.compile(args[++i])); - } - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new IllegalArgumentException(arg); - } - } - } - - private static String alias(char c) { - switch (c) { - case 'h': - return "help"; - case 'o': - return "output"; - case 'r': - return "reverse"; - case 'i': - return "inverted"; - case 'I': - return "include"; - case 'X': - return "exclude"; - case 't': - return "threads"; - case 's': - return "state"; - default: - return String.valueOf(c); - } - } - - // Milliseconds or HH:mm:ss.S or yyyy-MM-dd'T'HH:mm:ss.S - private static long parseTimestamp(String time) { - if (time.indexOf(':') < 0) { - return Long.parseLong(time); - } - - GregorianCalendar cal = new GregorianCalendar(); - StringTokenizer st = new StringTokenizer(time, "-:.T"); - - if (time.indexOf('T') > 0) { - cal.set(Calendar.YEAR, Integer.parseInt(st.nextToken())); - cal.set(Calendar.MONTH, Integer.parseInt(st.nextToken()) - 1); - cal.set(Calendar.DAY_OF_MONTH, Integer.parseInt(st.nextToken())); - } - cal.set(Calendar.HOUR_OF_DAY, st.hasMoreTokens() ? Integer.parseInt(st.nextToken()) : 0); - cal.set(Calendar.MINUTE, st.hasMoreTokens() ? Integer.parseInt(st.nextToken()) : 0); - cal.set(Calendar.SECOND, st.hasMoreTokens() ? Integer.parseInt(st.nextToken()) : 0); - cal.set(Calendar.MILLISECOND, st.hasMoreTokens() ? Integer.parseInt(st.nextToken()) : 0); - - return cal.getTimeInMillis(); - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Classifier.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Classifier.java deleted file mode 100644 index ec955870930..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Classifier.java +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.convert; - -import static io.sentry.asyncprofiler.vendor.asyncprofiler.convert.Frame.*; - -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.StackTrace; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@ApiStatus.Internal -abstract class Classifier { - - enum Category { - GC("[gc]", TYPE_CPP), - JIT("[jit]", TYPE_CPP), - VM("[vm]", TYPE_CPP), - VTABLE_STUBS("[vtable_stubs]", TYPE_NATIVE), - NATIVE("[native]", TYPE_NATIVE), - INTERPRETER("[Interpreter]", TYPE_NATIVE), - C1_COMP("[c1_comp]", TYPE_C1_COMPILED), - C2_COMP("[c2_comp]", TYPE_INLINED), - ADAPTER("[c2i_adapter]", TYPE_INLINED), - CLASS_INIT("[class_init]", TYPE_CPP), - CLASS_LOAD("[class_load]", TYPE_CPP), - CLASS_RESOLVE("[class_resolve]", TYPE_CPP), - CLASS_VERIFY("[class_verify]", TYPE_CPP), - LAMBDA_INIT("[lambda_init]", TYPE_CPP); - - final String title; - final byte type; - - Category(String title, byte type) { - this.title = title; - this.type = type; - } - } - - public @Nullable Category getCategory(@NotNull StackTrace stackTrace) { - long[] methods = stackTrace.methods; - byte[] types = stackTrace.types; - - Category category; - if ((category = detectGcJit(methods, types)) == null - && (category = detectClassLoading(methods, types)) == null) { - category = detectOther(methods, types); - } - return category; - } - - private @Nullable Category detectGcJit(long[] methods, byte[] types) { - boolean vmThread = false; - for (int i = types.length; --i >= 0; ) { - if (types[i] == TYPE_CPP) { - switch (getMethodName(methods[i], types[i])) { - case "CompileBroker::compiler_thread_loop": - return Category.JIT; - case "GCTaskThread::run": - case "WorkerThread::run": - return Category.GC; - case "java_start": - case "thread_native_entry": - vmThread = true; - break; - } - } else if (types[i] != TYPE_NATIVE) { - break; - } - } - return vmThread ? Category.VM : null; - } - - private @Nullable Category detectClassLoading(long[] methods, byte[] types) { - for (int i = 0; i < methods.length; i++) { - String methodName = getMethodName(methods[i], types[i]); - if (methodName.equals("Verifier::verify")) { - return Category.CLASS_VERIFY; - } else if (methodName.startsWith("InstanceKlass::initialize")) { - return Category.CLASS_INIT; - } else if (methodName.startsWith("LinkResolver::") - || methodName.startsWith("InterpreterRuntime::resolve") - || methodName.startsWith("SystemDictionary::resolve")) { - return Category.CLASS_RESOLVE; - } else if (methodName.endsWith("ClassLoader.loadClass")) { - return Category.CLASS_LOAD; - } else if (methodName.endsWith("LambdaMetafactory.metafactory") - || methodName.endsWith("LambdaMetafactory.altMetafactory")) { - return Category.LAMBDA_INIT; - } else if (methodName.endsWith("table stub")) { - return Category.VTABLE_STUBS; - } else if (methodName.equals("Interpreter")) { - return Category.INTERPRETER; - } else if (methodName.startsWith("I2C/C2I")) { - return i + 1 < types.length && types[i + 1] == TYPE_INTERPRETED - ? Category.INTERPRETER - : Category.ADAPTER; - } - } - return null; - } - - private @NotNull Category detectOther(long[] methods, byte[] types) { - boolean inJava = true; - for (int i = 0; i < types.length; i++) { - switch (types[i]) { - case TYPE_INTERPRETED: - return inJava ? Category.INTERPRETER : Category.NATIVE; - case TYPE_JIT_COMPILED: - return inJava ? Category.C2_COMP : Category.NATIVE; - case TYPE_INLINED: - inJava = true; - break; - case TYPE_NATIVE: - { - String methodName = getMethodName(methods[i], types[i]); - if (methodName.startsWith("JVM_") - || methodName.startsWith("Unsafe_") - || methodName.startsWith("MHN_") - || methodName.startsWith("jni_")) { - return Category.VM; - } - switch (methodName) { - case "call_stub": - case "deoptimization": - case "unknown_Java": - case "not_walkable_Java": - case "InlineCacheBuffer": - return Category.VM; - } - if (methodName.endsWith("_arraycopy") || methodName.contains("pthread_cond")) { - break; - } - inJava = false; - break; - } - case TYPE_CPP: - { - String methodName = getMethodName(methods[i], types[i]); - if (methodName.startsWith("Runtime1::")) { - return Category.C1_COMP; - } - break; - } - case TYPE_C1_COMPILED: - return inJava ? Category.C1_COMP : Category.NATIVE; - } - } - return Category.NATIVE; - } - - protected abstract @NotNull String getMethodName(long method, byte type); -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Frame.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Frame.java deleted file mode 100644 index bb9768442c4..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Frame.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.convert; - -import java.util.HashMap; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public final class Frame extends HashMap { - private static final long serialVersionUID = 1L; - public static final byte TYPE_INTERPRETED = 0; - public static final byte TYPE_JIT_COMPILED = 1; - public static final byte TYPE_INLINED = 2; - public static final byte TYPE_NATIVE = 3; - public static final byte TYPE_CPP = 4; - public static final byte TYPE_KERNEL = 5; - public static final byte TYPE_C1_COMPILED = 6; - - private static final int TYPE_SHIFT = 28; - - final int key; - long total; - long self; - long inlined, c1, interpreted; - - private Frame(int key) { - this.key = key; - } - - Frame(int titleIndex, byte type) { - this(titleIndex | type << TYPE_SHIFT); - } - - Frame getChild(int titleIndex, byte type) { - return super.computeIfAbsent(titleIndex | type << TYPE_SHIFT, Frame::new); - } - - int getTitleIndex() { - return key & ((1 << TYPE_SHIFT) - 1); - } - - byte getType() { - if (inlined * 3 >= total) { - return TYPE_INLINED; - } else if (c1 * 2 >= total) { - return TYPE_C1_COMPILED; - } else if (interpreted * 2 >= total) { - return TYPE_INTERPRETED; - } else { - return (byte) (key >>> TYPE_SHIFT); - } - } - - int depth(long cutoff) { - int depth = 0; - if (size() > 0) { - for (Frame child : values()) { - if (child.total >= cutoff) { - depth = Math.max(depth, child.depth(cutoff)); - } - } - } - return depth + 1; - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter.java deleted file mode 100644 index 006bce7e03c..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter.java +++ /dev/null @@ -1,300 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.convert; - -import static io.sentry.asyncprofiler.vendor.asyncprofiler.convert.Frame.*; - -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.ClassRef; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.Dictionary; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.MethodRef; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.AllocationSample; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ContendedLock; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.Event; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.EventAggregator; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.EventCollector; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ExecutionSample; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.LiveObject; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.MallocEvent; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.MallocLeakAggregator; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.BitSet; -import java.util.Map; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; - -@ApiStatus.Internal -public abstract class JfrConverter extends Classifier { - protected final @NotNull JfrReader jfr; - protected final @NotNull Arguments args; - protected final @NotNull EventCollector collector; - protected @NotNull Dictionary methodNames; - - public JfrConverter(@NotNull JfrReader jfr, @NotNull Arguments args) { - this.jfr = jfr; - this.args = args; - this.methodNames = new Dictionary<>(); - - EventCollector collector = createCollector(args); - this.collector = args.nativemem && args.leak ? new MallocLeakAggregator(collector) : collector; - } - - public void convert() throws IOException { - jfr.stopAtNewChunk = true; - - while (jfr.hasMoreChunks()) { - // Reset method dictionary, since new chunk may have different IDs - methodNames = new Dictionary<>(); - - collector.beforeChunk(); - collectEvents(); - collector.afterChunk(); - - convertChunk(); - } - - if (collector.finish()) { - convertChunk(); - } - } - - protected EventCollector createCollector(Arguments args) { - return new EventAggregator(args.threads, args.grain); - } - - protected void collectEvents() throws IOException { - Class eventClass = - args.nativemem - ? MallocEvent.class - : args.live - ? LiveObject.class - : args.alloc - ? AllocationSample.class - : args.lock ? ContendedLock.class : ExecutionSample.class; - - BitSet threadStates = null; - if (args.state != null) { - threadStates = new BitSet(); - for (String state : args.state.toUpperCase().split(",", -1)) { - threadStates.set(toThreadState(state)); - } - } else if (args.cpu) { - threadStates = getThreadStates(true); - } else if (args.wall) { - threadStates = getThreadStates(false); - } - - long startTicks = args.from != 0 ? toTicks(args.from) : Long.MIN_VALUE; - long endTicks = args.to != 0 ? toTicks(args.to) : Long.MAX_VALUE; - - for (Event event; (event = jfr.readEvent(eventClass)) != null; ) { - if (event.time >= startTicks && event.time <= endTicks) { - if (threadStates == null || threadStates.get(((ExecutionSample) event).threadState)) { - collector.collect(event); - } - } - } - } - - protected void convertChunk() { - // To be overridden in subclasses - } - - protected int toThreadState(String name) { - Map threadStates = jfr.enums.get("jdk.types.ThreadState"); - if (threadStates != null) { - for (Map.Entry entry : threadStates.entrySet()) { - if (entry.getValue().startsWith(name, 6)) { - return entry.getKey(); - } - } - } - throw new IllegalArgumentException("Unknown thread state: " + name); - } - - protected BitSet getThreadStates(boolean cpu) { - BitSet set = new BitSet(); - Map threadStates = jfr.enums.get("jdk.types.ThreadState"); - if (threadStates != null) { - for (Map.Entry entry : threadStates.entrySet()) { - set.set(entry.getKey(), "STATE_DEFAULT".equals(entry.getValue()) == cpu); - } - } - return set; - } - - // millis can be an absolute timestamp or an offset from the beginning/end of the recording - protected long toTicks(long millis) { - long nanos = millis * 1_000_000; - if (millis < 0) { - nanos += jfr.endNanos; - } else if (millis < 1500000000000L) { - nanos += jfr.startNanos; - } - return (long) ((nanos - jfr.chunkStartNanos) * (jfr.ticksPerSec / 1e9)) + jfr.chunkStartTicks; - } - - @Override - public String getMethodName(long methodId, byte methodType) { - String result = methodNames.get(methodId); - if (result == null) { - methodNames.put(methodId, result = resolveMethodName(methodId, methodType)); - } - return result; - } - - private String resolveMethodName(long methodId, byte methodType) { - MethodRef method = jfr.methods.get(methodId); - if (method == null) { - return "unknown"; - } - - ClassRef cls = jfr.classes.get(method.cls); - byte[] className = jfr.symbols.get(cls.name); - byte[] methodName = jfr.symbols.get(method.name); - - if (className == null || className.length == 0 || isNativeFrame(methodType)) { - return new String(methodName, StandardCharsets.UTF_8); - } else { - String classStr = toJavaClassName(className, 0, args.dot); - if (methodName == null || methodName.length == 0) { - return classStr; - } - String methodStr = new String(methodName, StandardCharsets.UTF_8); - return classStr + '.' + methodStr; - } - } - - public String getClassName(long classId) { - ClassRef cls = jfr.classes.get(classId); - if (cls == null) { - return "null"; - } - byte[] className = jfr.symbols.get(cls.name); - - int arrayDepth = 0; - while (className[arrayDepth] == '[') { - arrayDepth++; - } - - String name = toJavaClassName(className, arrayDepth, true); - while (arrayDepth-- > 0) { - name = name.concat("[]"); - } - return name; - } - - private String toJavaClassName(byte[] symbol, int start, boolean dotted) { - int end = symbol.length; - if (start > 0) { - switch (symbol[start]) { - case 'B': - return "byte"; - case 'C': - return "char"; - case 'S': - return "short"; - case 'I': - return "int"; - case 'J': - return "long"; - case 'Z': - return "boolean"; - case 'F': - return "float"; - case 'D': - return "double"; - case 'L': - start++; - end--; - } - } - - if (args.norm) { - for (int i = end - 2; i > start; i--) { - if (symbol[i] == '/' || symbol[i] == '.') { - if (symbol[i + 1] >= '0' && symbol[i + 1] <= '9') { - end = i; - if (i > start + 19 && symbol[i - 19] == '+' && symbol[i - 18] == '0') { - // Original JFR transforms lambda names to something like - // pkg.ClassName$$Lambda+0x00007f8177090218/543846639 - end = i - 19; - } - } - break; - } - } - } - - if (args.simple) { - for (int i = end - 2; i >= start; i--) { - if (symbol[i] == '/' && (symbol[i + 1] < '0' || symbol[i + 1] > '9')) { - start = i + 1; - break; - } - } - } - - String s = new String(symbol, start, end - start, StandardCharsets.UTF_8); - return dotted ? s.replace('/', '.') : s; - } - - public StackTraceElement getStackTraceElement(long methodId, byte methodType, int location) { - MethodRef method = jfr.methods.get(methodId); - if (method == null) { - return new StackTraceElement("", "unknown", null, 0); - } - - ClassRef cls = jfr.classes.get(method.cls); - byte[] className = jfr.symbols.get(cls.name); - byte[] methodName = jfr.symbols.get(method.name); - - String classStr = - className == null || className.length == 0 || isNativeFrame(methodType) - ? "" - : toJavaClassName(className, 0, args.dot); - String methodStr = - methodName == null || methodName.length == 0 - ? "" - : new String(methodName, StandardCharsets.UTF_8); - return new StackTraceElement(classStr, methodStr, null, location >>> 16); - } - - public String getThreadName(int tid) { - String threadName = jfr.threads.get(tid); - return threadName == null - ? "[tid=" + tid + ']' - : threadName.startsWith("[tid=") ? threadName : '[' + threadName + " tid=" + tid + ']'; - } - - public String getPlainThreadName(int tid) { - String threadName = jfr.threads.get(tid); - return threadName == null ? "[tid=" + tid + ']' : threadName; - } - - protected boolean isNativeFrame(byte methodType) { - // In JDK Flight Recorder, TYPE_NATIVE denotes Java native methods, - // while in async-profiler, TYPE_NATIVE is for C methods - return (methodType == TYPE_NATIVE - && jfr.getEnumValue("jdk.types.FrameType", TYPE_KERNEL) != null) - || methodType == TYPE_CPP - || methodType == TYPE_KERNEL; - } - - // Select sum(samples) or sum(value) depending on the --total option. - // For lock events, convert lock duration from ticks to nanoseconds. - protected abstract class AggregatedEventVisitor implements EventCollector.Visitor { - final double factor = !args.total ? 0.0 : args.lock ? 1e9 / jfr.ticksPerSec : 1.0; - - @Override - public final void visit(Event event, long samples, long value) { - visit(event, factor == 0.0 ? samples : factor == 1.0 ? value : (long) (value * factor)); - } - - protected abstract void visit(Event event, long value); - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/ClassRef.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/ClassRef.java deleted file mode 100644 index 1727dc2156c..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/ClassRef.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; - -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public final class ClassRef { - public final long name; - - public ClassRef(long name) { - this.name = name; - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary.java deleted file mode 100644 index 5f3191a37e6..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; - -import java.util.Arrays; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -/** Fast and compact long->Object map. */ -public final class Dictionary { - private static final int INITIAL_CAPACITY = 16; - - private long[] keys; - private Object[] values; - private int size; - - public Dictionary() { - this(INITIAL_CAPACITY); - } - - public Dictionary(int initialCapacity) { - this.keys = new long[initialCapacity]; - this.values = new Object[initialCapacity]; - } - - public void clear() { - Arrays.fill(keys, 0); - Arrays.fill(values, null); - size = 0; - } - - public int size() { - return size; - } - - public void put(long key, T value) { - if (key == 0) { - throw new IllegalArgumentException("Zero key not allowed"); - } - - int mask = keys.length - 1; - int i = hashCode(key) & mask; - while (keys[i] != 0) { - if (keys[i] == key) { - values[i] = value; - return; - } - i = (i + 1) & mask; - } - keys[i] = key; - values[i] = value; - - if (++size * 2 > keys.length) { - resize(keys.length * 2); - } - } - - @SuppressWarnings("unchecked") - public T get(long key) { - int mask = keys.length - 1; - int i = hashCode(key) & mask; - while (keys[i] != key && keys[i] != 0) { - i = (i + 1) & mask; - } - return (T) values[i]; - } - - @SuppressWarnings("unchecked") - public void forEach(Visitor visitor) { - for (int i = 0; i < keys.length; i++) { - if (keys[i] != 0) { - visitor.visit(keys[i], (T) values[i]); - } - } - } - - public int preallocate(int count) { - if (count * 2 > keys.length) { - resize(Integer.highestOneBit(count * 4 - 1)); - } - return count; - } - - private void resize(int newCapacity) { - long[] newKeys = new long[newCapacity]; - Object[] newValues = new Object[newCapacity]; - int mask = newKeys.length - 1; - - for (int i = 0; i < keys.length; i++) { - if (keys[i] != 0) { - for (int j = hashCode(keys[i]) & mask; ; j = (j + 1) & mask) { - if (newKeys[j] == 0) { - newKeys[j] = keys[i]; - newValues[j] = values[i]; - break; - } - } - } - } - - keys = newKeys; - values = newValues; - } - - private static int hashCode(long key) { - key *= 0xc6a4a7935bd1e995L; - return (int) (key ^ (key >>> 32)); - } - - public interface Visitor { - void visit(long key, T value); - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt.java deleted file mode 100644 index 83d3a2772e1..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; - -import java.util.Arrays; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -/** Fast and compact long->int map. */ -public final class DictionaryInt { - private static final int INITIAL_CAPACITY = 16; - - private long[] keys; - private int[] values; - private int size; - - public DictionaryInt() { - this(INITIAL_CAPACITY); - } - - public DictionaryInt(int initialCapacity) { - this.keys = new long[initialCapacity]; - this.values = new int[initialCapacity]; - } - - public void clear() { - Arrays.fill(keys, 0); - Arrays.fill(values, 0); - size = 0; - } - - public void put(long key, int value) { - if (key == 0) { - throw new IllegalArgumentException("Zero key not allowed"); - } - - int mask = keys.length - 1; - int i = hashCode(key) & mask; - while (keys[i] != 0) { - if (keys[i] == key) { - values[i] = value; - return; - } - i = (i + 1) & mask; - } - keys[i] = key; - values[i] = value; - - if (++size * 2 > keys.length) { - resize(keys.length * 2); - } - } - - public int get(long key) { - int mask = keys.length - 1; - int i = hashCode(key) & mask; - while (keys[i] != key) { - if (keys[i] == 0) { - throw new IllegalArgumentException("No such key: " + key); - } - i = (i + 1) & mask; - } - return values[i]; - } - - public int get(long key, int notFound) { - int mask = keys.length - 1; - int i = hashCode(key) & mask; - while (keys[i] != key) { - if (keys[i] == 0) { - return notFound; - } - i = (i + 1) & mask; - } - return values[i]; - } - - public void forEach(Visitor visitor) { - for (int i = 0; i < keys.length; i++) { - if (keys[i] != 0) { - visitor.visit(keys[i], values[i]); - } - } - } - - public int preallocate(int count) { - if (count * 2 > keys.length) { - resize(Integer.highestOneBit(count * 4 - 1)); - } - return count; - } - - private void resize(int newCapacity) { - long[] newKeys = new long[newCapacity]; - int[] newValues = new int[newCapacity]; - int mask = newKeys.length - 1; - - for (int i = 0; i < keys.length; i++) { - if (keys[i] != 0) { - for (int j = hashCode(keys[i]) & mask; ; j = (j + 1) & mask) { - if (newKeys[j] == 0) { - newKeys[j] = keys[i]; - newValues[j] = values[i]; - break; - } - } - } - } - - keys = newKeys; - values = newValues; - } - - private static int hashCode(long key) { - key *= 0xc6a4a7935bd1e995L; - return (int) (key ^ (key >>> 32)); - } - - public interface Visitor { - void visit(long key, int value); - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Element.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Element.java deleted file mode 100644 index 0c12292106a..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Element.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; - -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -abstract class Element { - - void addChild(Element e) {} - - static final class NoOpElement extends Element { - // Empty implementation for unhandled element types - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrClass.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrClass.java deleted file mode 100644 index eb85be46eb3..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrClass.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@ApiStatus.Internal -public final class JfrClass extends Element { - final int id; - final boolean simpleType; - final @Nullable String name; - final List fields; - - JfrClass(@NotNull Map attributes) { - this.id = Integer.parseInt(attributes.get("id")); - this.simpleType = "true".equals(attributes.get("simpleType")); - this.name = attributes.get("name"); - this.fields = new ArrayList<>(2); - } - - @Override - void addChild(Element e) { - if (e instanceof JfrField) { - fields.add((JfrField) e); - } - } - - public @Nullable JfrField field(@NotNull String name) { - for (JfrField field : fields) { - if (field.name != null && field.name.equals(name)) { - return field; - } - } - return null; - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField.java deleted file mode 100644 index 635b87fd0f3..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; - -import java.util.Map; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@ApiStatus.Internal -public final class JfrField extends Element { - final @Nullable String name; - final int type; - final boolean constantPool; - - JfrField(@NotNull Map attributes) { - this.name = attributes.get("name"); - this.type = Integer.parseInt(attributes.get("class")); - this.constantPool = "true".equals(attributes.get("constantPool")); - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader.java deleted file mode 100644 index a0297d7afc1..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader.java +++ /dev/null @@ -1,714 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; - -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.AllocationSample; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.CPULoad; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ContendedLock; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.Event; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ExecutionSample; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.GCHeapSummary; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.LiveObject; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.MallocEvent; -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ObjectCount; -import java.io.Closeable; -import java.io.IOException; -import java.lang.reflect.Constructor; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.channels.FileChannel; -import java.nio.charset.StandardCharsets; -import java.nio.file.Paths; -import java.nio.file.StandardOpenOption; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** Parses JFR output produced by async-profiler. */ -public final class JfrReader implements Closeable { - private static final int BUFFER_SIZE = 2 * 1024 * 1024; - private static final int CHUNK_HEADER_SIZE = 68; - private static final int CHUNK_SIGNATURE = 0x464c5200; - - private static final byte STATE_NEW_CHUNK = 0; - private static final byte STATE_READING = 1; - private static final byte STATE_EOF = 2; - private static final byte STATE_INCOMPLETE = 3; - - private final @Nullable FileChannel ch; - private @NotNull ByteBuffer buf; - private final long fileSize; - private long filePosition; - private byte state; - - public long startNanos = Long.MAX_VALUE; - public long endNanos = Long.MIN_VALUE; - public long startTicks = Long.MAX_VALUE; - public long chunkStartNanos; - public long chunkEndNanos; - public long chunkStartTicks; - public long ticksPerSec; - public boolean stopAtNewChunk; - - public final Dictionary types = new Dictionary<>(); - public final Map typesByName = new HashMap<>(); - public final Dictionary threads = new Dictionary<>(); - // Maps thread IDs to Java thread IDs - // Change compared to original async-profiler JFR reader - public final Dictionary javaThreads = new Dictionary<>(); - public final Dictionary classes = new Dictionary<>(); - public final Dictionary strings = new Dictionary<>(); - public final Dictionary symbols = new Dictionary<>(); - public final Dictionary methods = new Dictionary<>(); - public final Dictionary stackTraces = new Dictionary<>(); - public final Map settings = new HashMap<>(); - public final Map> enums = new HashMap<>(); - - private final Dictionary> customEvents = new Dictionary<>(); - - private int executionSample; - private int nativeMethodSample; - private int wallClockSample; - private int allocationInNewTLAB; - private int allocationOutsideTLAB; - private int allocationSample; - private int liveObject; - private int monitorEnter; - private int threadPark; - private int activeSetting; - private int malloc; - private int free; - - @ApiStatus.Internal - public JfrReader(String fileName) throws IOException { - this.ch = FileChannel.open(Paths.get(fileName), StandardOpenOption.READ); - this.buf = ByteBuffer.allocateDirect(BUFFER_SIZE); - this.fileSize = ch.size(); - - buf.flip(); - ensureBytes(CHUNK_HEADER_SIZE); - if (!readChunk(0)) { - throw new IOException("Incomplete JFR file"); - } - } - - public JfrReader(@NotNull ByteBuffer buf) throws IOException { - this.ch = null; - this.buf = buf; - this.fileSize = buf.limit(); - - buf.order(ByteOrder.BIG_ENDIAN); - if (!readChunk(0)) { - throw new IOException("Incomplete JFR file"); - } - } - - @Override - public void close() throws IOException { - if (ch != null) { - ch.close(); - } - } - - public boolean eof() { - return state >= STATE_EOF; - } - - public boolean incomplete() { - return state == STATE_INCOMPLETE; - } - - public long durationNanos() { - return endNanos - startNanos; - } - - public void registerEvent(String name, Class eventClass) { - JfrClass type = typesByName.get(name); - if (type != null) { - try { - customEvents.put(type.id, eventClass.getConstructor(JfrReader.class)); - } catch (NoSuchMethodException e) { - throw new IllegalArgumentException("No suitable constructor found"); - } - } - } - - // Similar to eof(), but parses the next chunk header - public boolean hasMoreChunks() throws IOException { - return state == STATE_NEW_CHUNK ? readChunk(buf.position()) : state == STATE_READING; - } - - public List readAllEvents() throws IOException { - return readAllEvents(null); - } - - public List readAllEvents(@Nullable Class cls) throws IOException { - ArrayList events = new ArrayList<>(); - for (E event; (event = readEvent(cls)) != null; ) { - events.add(event); - } - Collections.sort(events); - return events; - } - - public @Nullable Event readEvent() throws IOException { - return readEvent(null); - } - - @SuppressWarnings("unchecked") - public @Nullable E readEvent(@Nullable Class cls) throws IOException { - while (ensureBytes(CHUNK_HEADER_SIZE)) { - int pos = buf.position(); - int size = getVarint(); - int type = getVarint(); - - if (type == 'L' && buf.getInt(pos) == CHUNK_SIGNATURE) { - if (state != STATE_NEW_CHUNK && stopAtNewChunk) { - buf.position(pos); - state = STATE_NEW_CHUNK; - } else if (readChunk(pos)) { - continue; - } - return null; - } - - if (type == executionSample || type == nativeMethodSample) { - if (cls == null || cls == ExecutionSample.class) return (E) readExecutionSample(false); - } else if (type == wallClockSample) { - if (cls == null || cls == ExecutionSample.class) return (E) readExecutionSample(true); - } else if (type == allocationInNewTLAB) { - if (cls == null || cls == AllocationSample.class) return (E) readAllocationSample(true); - } else if (type == allocationOutsideTLAB || type == allocationSample) { - if (cls == null || cls == AllocationSample.class) return (E) readAllocationSample(false); - } else if (type == malloc) { - if (cls == null || cls == MallocEvent.class) return (E) readMallocEvent(true); - } else if (type == free) { - if (cls == null || cls == MallocEvent.class) return (E) readMallocEvent(false); - } else if (type == liveObject) { - if (cls == null || cls == LiveObject.class) return (E) readLiveObject(); - } else if (type == monitorEnter) { - if (cls == null || cls == ContendedLock.class) return (E) readContendedLock(false); - } else if (type == threadPark) { - if (cls == null || cls == ContendedLock.class) return (E) readContendedLock(true); - } else if (type == activeSetting) { - readActiveSetting(); - } else { - Constructor customEvent = customEvents.get(type); - if (customEvent != null && (cls == null || cls == customEvent.getDeclaringClass())) { - try { - return (E) customEvent.newInstance(this); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException(e); - } finally { - seek(filePosition + pos + size); - } - } - } - - seek(filePosition + pos + size); - } - - state = STATE_EOF; - return null; - } - - private ExecutionSample readExecutionSample(boolean hasSamples) { - long time = getVarlong(); - int tid = getVarint(); - int stackTraceId = getVarint(); - int threadState = getVarint(); - int samples = hasSamples ? getVarint() : 1; - return new ExecutionSample(time, tid, stackTraceId, threadState, samples); - } - - private AllocationSample readAllocationSample(boolean tlab) { - long time = getVarlong(); - int tid = getVarint(); - int stackTraceId = getVarint(); - int classId = getVarint(); - long allocationSize = getVarlong(); - long tlabSize = tlab ? getVarlong() : 0; - return new AllocationSample(time, tid, stackTraceId, classId, allocationSize, tlabSize); - } - - private MallocEvent readMallocEvent(boolean hasSize) { - long time = getVarlong(); - int tid = getVarint(); - int stackTraceId = getVarint(); - long address = getVarlong(); - long size = hasSize ? getVarlong() : 0; - return new MallocEvent(time, tid, stackTraceId, address, size); - } - - private LiveObject readLiveObject() { - long time = getVarlong(); - int tid = getVarint(); - int stackTraceId = getVarint(); - int classId = getVarint(); - long allocationSize = getVarlong(); - long allocatimeTime = getVarlong(); - return new LiveObject(time, tid, stackTraceId, classId, allocationSize, allocatimeTime); - } - - private ContendedLock readContendedLock(boolean hasTimeout) { - long time = getVarlong(); - long duration = getVarlong(); - int tid = getVarint(); - int stackTraceId = getVarint(); - int classId = getVarint(); - if (hasTimeout) getVarlong(); - getVarlong(); - getVarlong(); - return new ContendedLock(time, tid, stackTraceId, duration, classId); - } - - private void readActiveSetting() { - JfrClass activeSetting = typesByName.get("jdk.ActiveSetting"); - if (activeSetting == null) return; - for (JfrField field : activeSetting.fields) { - getVarlong(); - if ("id".equals(field.name)) { - break; - } - } - String name = getString(); - String value = getString(); - settings.put(name, value); - } - - private boolean readChunk(int pos) throws IOException { - if (pos + CHUNK_HEADER_SIZE > buf.limit() || buf.getInt(pos) != CHUNK_SIGNATURE) { - throw new IOException("Not a valid JFR file"); - } - - int version = buf.getInt(pos + 4); - if (version < 0x20000 || version > 0x2ffff) { - throw new IOException( - "Unsupported JFR version: " + (version >>> 16) + "." + (version & 0xffff)); - } - - long chunkStart = filePosition + pos; - long chunkSize = buf.getLong(pos + 8); - if (chunkStart + chunkSize > fileSize) { - state = STATE_INCOMPLETE; - return false; - } - - long cpOffset = buf.getLong(pos + 16); - long metaOffset = buf.getLong(pos + 24); - if (cpOffset == 0 || metaOffset == 0) { - state = STATE_INCOMPLETE; - return false; - } - - chunkStartNanos = buf.getLong(pos + 32); - chunkEndNanos = buf.getLong(pos + 32) + buf.getLong(pos + 40); - chunkStartTicks = buf.getLong(pos + 48); - ticksPerSec = buf.getLong(pos + 56); - - startNanos = Math.min(startNanos, chunkStartNanos); - endNanos = Math.max(endNanos, chunkEndNanos); - startTicks = Math.min(startTicks, chunkStartTicks); - - types.clear(); - typesByName.clear(); - - readMeta(chunkStart + metaOffset); - readConstantPool(chunkStart + cpOffset); - cacheEventTypes(); - - seek(chunkStart + CHUNK_HEADER_SIZE); - state = STATE_READING; - return true; - } - - private void readMeta(long metaOffset) throws IOException { - seek(metaOffset); - ensureBytes(5); - - int posBeforeSize = buf.position(); - ensureBytes(getVarint() - (buf.position() - posBeforeSize)); - getVarint(); - getVarlong(); - getVarlong(); - getVarlong(); - - String[] strings = new String[getVarint()]; - for (int i = 0; i < strings.length; i++) { - strings[i] = getString(); - } - readElement(strings); - } - - private Element readElement(String[] strings) { - String name = strings[getVarint()]; - - int attributeCount = getVarint(); - Map attributes = new HashMap<>(attributeCount); - for (int i = 0; i < attributeCount; i++) { - attributes.put(strings[getVarint()], strings[getVarint()]); - } - - Element e = createElement(name, attributes); - int childCount = getVarint(); - for (int i = 0; i < childCount; i++) { - e.addChild(readElement(strings)); - } - return e; - } - - private Element createElement(String name, Map attributes) { - switch (name) { - case "class": - { - JfrClass type = new JfrClass(attributes); - if (!attributes.containsKey("superType")) { - types.put(type.id, type); - } - typesByName.put(type.name, type); - return type; - } - case "field": - return new JfrField(attributes); - default: - return new Element.NoOpElement(); - } - } - - private void readConstantPool(long cpOffset) throws IOException { - long delta; - do { - seek(cpOffset); - ensureBytes(5); - - int posBeforeSize = buf.position(); - ensureBytes(getVarint() - (buf.position() - posBeforeSize)); - getVarint(); - getVarlong(); - getVarlong(); - delta = getVarlong(); - getVarint(); - - int poolCount = getVarint(); - for (int i = 0; i < poolCount; i++) { - int type = getVarint(); - readConstants(types.get(type)); - } - } while (delta != 0 && (cpOffset += delta) > 0); - } - - private void readConstants(JfrClass type) { - String typeName = type.name; - if (typeName == null) { - readOtherConstants(type.fields); - return; - } - switch (typeName) { - case "jdk.types.ChunkHeader": - buf.position(buf.position() + (CHUNK_HEADER_SIZE + 3)); - break; - case "java.lang.Thread": - readThreads(type.fields.size()); - break; - case "java.lang.Class": - readClasses(type.fields.size()); - break; - case "java.lang.String": - readStrings(); - break; - case "jdk.types.Symbol": - readSymbols(); - break; - case "jdk.types.Method": - readMethods(); - break; - case "jdk.types.StackTrace": - readStackTraces(); - break; - default: - if (type.simpleType && type.fields.size() == 1) { - readEnumValues(typeName); - } else { - readOtherConstants(type.fields); - } - } - } - - private void readThreads(int fieldCount) { - int count = threads.preallocate(getVarint()); - for (int i = 0; i < count; i++) { - long id = getVarlong(); - String osName = getString(); - getVarint(); // osThreadId - String javaName = getString(); - long javaThreadId = getVarlong(); - readFields(fieldCount - 4); - javaThreads.put(id, javaThreadId); - String threadName = javaName != null ? javaName : (osName != null ? osName : "Thread-" + id); - threads.put(id, threadName); - } - } - - private void readClasses(int fieldCount) { - int count = classes.preallocate(getVarint()); - for (int i = 0; i < count; i++) { - long id = getVarlong(); - getVarlong(); - long name = getVarlong(); - getVarlong(); - getVarint(); - readFields(fieldCount - 4); - classes.put(id, new ClassRef(name)); - } - } - - private void readMethods() { - int count = methods.preallocate(getVarint()); - for (int i = 0; i < count; i++) { - long id = getVarlong(); - long cls = getVarlong(); - long name = getVarlong(); - long sig = getVarlong(); - getVarint(); - getVarint(); - methods.put(id, new MethodRef(cls, name, sig)); - } - } - - private void readStackTraces() { - int count = stackTraces.preallocate(getVarint()); - for (int i = 0; i < count; i++) { - long id = getVarlong(); - getVarint(); // int truncated - StackTrace stackTrace = readStackTrace(); - stackTraces.put(id, stackTrace); - } - } - - private StackTrace readStackTrace() { - int depth = getVarint(); - long[] methods = new long[depth]; - byte[] types = new byte[depth]; - int[] locations = new int[depth]; - for (int i = 0; i < depth; i++) { - methods[i] = getVarlong(); - int line = getVarint(); - int bci = getVarint(); - locations[i] = line << 16 | (bci & 0xffff); - types[i] = buf.get(); - } - return new StackTrace(methods, types, locations); - } - - private void readStrings() { - int count = strings.preallocate(getVarint()); - for (int i = 0; i < count; i++) { - String str = getString(); - if (str == null) str = ""; - strings.put(getVarlong(), str); - } - } - - private void readSymbols() { - int count = symbols.preallocate(getVarint()); - for (int i = 0; i < count; i++) { - long id = getVarlong(); - if (buf.get() != 3) { - throw new IllegalArgumentException("Invalid symbol encoding"); - } - symbols.put(id, getBytes()); - } - } - - private void readEnumValues(@NotNull String typeName) { - HashMap map = new HashMap<>(); - int count = getVarint(); - for (int i = 0; i < count; i++) { - map.put((int) getVarlong(), getString()); - } - enums.put(typeName, map); - } - - private void readOtherConstants(List fields) { - int stringType = getTypeId("java.lang.String"); - - boolean[] numeric = new boolean[fields.size()]; - for (int i = 0; i < numeric.length; i++) { - JfrField f = fields.get(i); - numeric[i] = f.constantPool || f.type != stringType; - } - - int count = getVarint(); - for (int i = 0; i < count; i++) { - getVarlong(); - readFields(numeric); - } - } - - private void readFields(boolean[] numeric) { - for (boolean n : numeric) { - if (n) { - getVarlong(); - } else { - getString(); - } - } - } - - private void readFields(int count) { - while (count-- > 0) { - getVarlong(); - } - } - - private void cacheEventTypes() { - executionSample = getTypeId("jdk.ExecutionSample"); - nativeMethodSample = getTypeId("jdk.NativeMethodSample"); - wallClockSample = getTypeId("profiler.WallClockSample"); - allocationInNewTLAB = getTypeId("jdk.ObjectAllocationInNewTLAB"); - allocationOutsideTLAB = getTypeId("jdk.ObjectAllocationOutsideTLAB"); - allocationSample = getTypeId("jdk.ObjectAllocationSample"); - liveObject = getTypeId("profiler.LiveObject"); - monitorEnter = getTypeId("jdk.JavaMonitorEnter"); - threadPark = getTypeId("jdk.ThreadPark"); - activeSetting = getTypeId("jdk.ActiveSetting"); - malloc = getTypeId("profiler.Malloc"); - free = getTypeId("profiler.Free"); - - registerEvent("jdk.CPULoad", CPULoad.class); - registerEvent("jdk.GCHeapSummary", GCHeapSummary.class); - registerEvent("jdk.ObjectCount", ObjectCount.class); - registerEvent("jdk.ObjectCountAfterGC", ObjectCount.class); - } - - private int getTypeId(String typeName) { - JfrClass type = typesByName.get(typeName); - return type != null ? type.id : -1; - } - - public int getEnumKey(String typeName, String value) { - Map enumValues = enums.get(typeName); - if (enumValues != null) { - for (Map.Entry entry : enumValues.entrySet()) { - if (value.equals(entry.getValue())) { - return entry.getKey(); - } - } - } - return -1; - } - - public @Nullable String getEnumValue(String typeName, int key) { - Map enumMap = enums.get(typeName); - return enumMap != null ? enumMap.get(key) : null; - } - - public int getVarint() { - int result = 0; - for (int shift = 0; ; shift += 7) { - byte b = buf.get(); - result |= (b & 0x7f) << shift; - if (b >= 0) { - return result; - } - } - } - - public long getVarlong() { - long result = 0; - for (int shift = 0; shift < 56; shift += 7) { - byte b = buf.get(); - result |= (b & 0x7fL) << shift; - if (b >= 0) { - return result; - } - } - return result | (buf.get() & 0xffL) << 56; - } - - public float getFloat() { - return buf.getFloat(); - } - - public double getDouble() { - return buf.getDouble(); - } - - public @Nullable String getString() { - switch (buf.get()) { - case 0: - return null; - case 1: - return ""; - case 2: - return strings.get(getVarlong()); - case 3: - return new String(getBytes(), StandardCharsets.UTF_8); - case 4: - { - char[] chars = new char[getVarint()]; - for (int i = 0; i < chars.length; i++) { - chars[i] = (char) getVarint(); - } - return new String(chars); - } - case 5: - return new String(getBytes(), StandardCharsets.ISO_8859_1); - default: - throw new IllegalArgumentException("Invalid string encoding"); - } - } - - public byte[] getBytes() { - byte[] bytes = new byte[getVarint()]; - buf.get(bytes); - return bytes; - } - - private void seek(long pos) throws IOException { - long bufPosition = pos - filePosition; - if (bufPosition >= 0 && bufPosition <= buf.limit()) { - buf.position((int) bufPosition); - } else { - filePosition = pos; - if (ch != null) { - ch.position(pos); - } - buf.rewind().flip(); - } - } - - private boolean ensureBytes(int needed) throws IOException { - if (buf.remaining() >= needed) { - return true; - } - - if (ch == null) { - return false; - } - - filePosition += buf.position(); - - if (buf.capacity() < needed) { - ByteBuffer newBuf = ByteBuffer.allocateDirect(needed); - newBuf.put(buf); - buf = newBuf; - } else { - buf.compact(); - } - - while (ch.read(buf) > 0 && buf.position() < needed) { - // keep reading - } - buf.flip(); - return buf.limit() > 0; - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/MethodRef.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/MethodRef.java deleted file mode 100644 index 7790a492375..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/MethodRef.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; - -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public final class MethodRef { - public final long cls; - public final long name; - public final long sig; - - public MethodRef(long cls, long name, long sig) { - this.cls = cls; - this.name = name; - this.sig = sig; - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace.java deleted file mode 100644 index 01e292f96f4..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; - -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public final class StackTrace { - public final long[] methods; - public final byte[] types; - public final int[] locations; - - public StackTrace(long[] methods, byte[] types, int[] locations) { - this.methods = methods; - this.types = types; - this.locations = locations; - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/AllocationSample.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/AllocationSample.java deleted file mode 100644 index 0f60086527d..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/AllocationSample.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; - -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public final class AllocationSample extends Event { - public final int classId; - public final long allocationSize; - public final long tlabSize; - - public AllocationSample( - long time, int tid, int stackTraceId, int classId, long allocationSize, long tlabSize) { - super(time, tid, stackTraceId); - this.classId = classId; - this.allocationSize = allocationSize; - this.tlabSize = tlabSize; - } - - @Override - public int hashCode() { - return classId * 127 + stackTraceId + (tlabSize == 0 ? 17 : 0); - } - - @Override - public boolean sameGroup(Event o) { - if (o instanceof AllocationSample) { - AllocationSample a = (AllocationSample) o; - return classId == a.classId && (tlabSize == 0) == (a.tlabSize == 0); - } - return false; - } - - @Override - public long classId() { - return classId; - } - - @Override - public long value() { - return tlabSize != 0 ? tlabSize : allocationSize; - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/CPULoad.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/CPULoad.java deleted file mode 100644 index 9134fe190ec..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/CPULoad.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; - -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public final class CPULoad extends Event { - public final float jvmUser; - public final float jvmSystem; - public final float machineTotal; - - public CPULoad(JfrReader jfr) { - super(jfr.getVarlong(), 0, 0); - this.jvmUser = jfr.getFloat(); - this.jvmSystem = jfr.getFloat(); - this.machineTotal = jfr.getFloat(); - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ContendedLock.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ContendedLock.java deleted file mode 100644 index e85595af4ce..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ContendedLock.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; - -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public final class ContendedLock extends Event { - public final long duration; - public final int classId; - - public ContendedLock(long time, int tid, int stackTraceId, long duration, int classId) { - super(time, tid, stackTraceId); - this.duration = duration; - this.classId = classId; - } - - @Override - public int hashCode() { - return classId * 127 + stackTraceId; - } - - @Override - public boolean sameGroup(Event o) { - if (o instanceof ContendedLock) { - ContendedLock c = (ContendedLock) o; - return classId == c.classId; - } - return false; - } - - @Override - public long classId() { - return classId; - } - - @Override - public long value() { - return duration; - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event.java deleted file mode 100644 index 5612904e404..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; - -import java.lang.reflect.Field; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public abstract class Event implements Comparable { - public final long time; - public final int tid; - public final int stackTraceId; - - protected Event(long time, int tid, int stackTraceId) { - this.time = time; - this.tid = tid; - this.stackTraceId = stackTraceId; - } - - @Override - public int compareTo(Event o) { - return Long.compare(time, o.time); - } - - @Override - public int hashCode() { - return stackTraceId; - } - - @Override - public String toString() { - StringBuilder sb = - new StringBuilder(getClass().getSimpleName()) - .append("{time=") - .append(time) - .append(",tid=") - .append(tid) - .append(",stackTraceId=") - .append(stackTraceId); - for (Field f : getClass().getDeclaredFields()) { - try { - sb.append(',').append(f.getName()).append('=').append(f.get(this)); - } catch (ReflectiveOperationException e) { - break; - } - } - return sb.append('}').toString(); - } - - public boolean sameGroup(Event o) { - return getClass() == o.getClass(); - } - - public long classId() { - return 0; - } - - public long samples() { - return 1; - } - - public long value() { - return 1; - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventAggregator.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventAggregator.java deleted file mode 100644 index a3b9c7dd17b..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventAggregator.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; - -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; - -@ApiStatus.Internal -public final class EventAggregator implements EventCollector { - private static final int INITIAL_CAPACITY = 1024; - - private final boolean threads; - private final double grain; - private @NotNull Event[] keys; - private @NotNull long[] samples; - private @NotNull long[] values; - private int size; - private double fraction; - - public EventAggregator(boolean threads, double grain) { - this.threads = threads; - this.grain = grain; - this.keys = new Event[INITIAL_CAPACITY]; - this.samples = new long[INITIAL_CAPACITY]; - this.values = new long[INITIAL_CAPACITY]; - - beforeChunk(); - } - - public int size() { - return size; - } - - @Override - public void collect(Event e) { - collect(e, e.samples(), e.value()); - } - - public void collect(Event e, long samples, long value) { - int mask = keys.length - 1; - int i = hashCode(e) & mask; - while (keys[i] != null) { - if (sameGroup(keys[i], e)) { - this.samples[i] += samples; - this.values[i] += value; - return; - } - i = (i + 1) & mask; - } - - this.keys[i] = e; - this.samples[i] = samples; - this.values[i] = value; - - if (++size * 2 > keys.length) { - resize(keys.length * 2); - } - } - - @Override - public void beforeChunk() { - if (keys == null || size > 0) { - keys = new Event[INITIAL_CAPACITY]; - samples = new long[INITIAL_CAPACITY]; - values = new long[INITIAL_CAPACITY]; - size = 0; - } - } - - @Override - public void afterChunk() { - if (grain > 0) { - coarsen(grain); - } - } - - @Override - public boolean finish() { - // Don't set to null as it would break nullability contract - keys = new Event[0]; - samples = new long[0]; - values = new long[0]; - return false; - } - - @Override - public void forEach(Visitor visitor) { - if (size > 0) { - for (int i = 0; i < keys.length; i++) { - if (keys[i] != null) { - visitor.visit(keys[i], samples[i], values[i]); - } - } - } - } - - public void coarsen(double grain) { - fraction = 0; - - for (int i = 0; i < keys.length; i++) { - if (keys[i] != null) { - long s0 = samples[i]; - long s1 = round(s0 / grain); - if (s1 == 0) { - keys[i] = null; - size--; - } - samples[i] = s1; - values[i] = (long) (values[i] * ((double) s1 / s0)); - } - } - } - - private long round(double d) { - long r = (long) d; - if ((fraction += d - r) >= 1.0) { - fraction -= 1.0; - r++; - } - return r; - } - - private int hashCode(Event e) { - return e.hashCode() + (threads ? e.tid * 31 : 0); - } - - private boolean sameGroup(Event e1, Event e2) { - return e1.stackTraceId == e2.stackTraceId && (!threads || e1.tid == e2.tid) && e1.sameGroup(e2); - } - - private void resize(int newCapacity) { - Event[] newKeys = new Event[newCapacity]; - long[] newSamples = new long[newCapacity]; - long[] newValues = new long[newCapacity]; - int mask = newKeys.length - 1; - - for (int i = 0; i < keys.length; i++) { - if (keys[i] != null) { - for (int j = hashCode(keys[i]) & mask; ; j = (j + 1) & mask) { - if (newKeys[j] == null) { - newKeys[j] = keys[i]; - newSamples[j] = samples[i]; - newValues[j] = values[i]; - break; - } - } - } - } - - keys = newKeys; - samples = newSamples; - values = newValues; - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector.java deleted file mode 100644 index 639faa88778..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; - -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public interface EventCollector { - - void collect(Event e); - - void beforeChunk(); - - void afterChunk(); - - // Returns true if this collector has remaining data to process - boolean finish(); - - void forEach(Visitor visitor); - - interface Visitor { - void visit(Event event, long samples, long value); - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ExecutionSample.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ExecutionSample.java deleted file mode 100644 index d4db5c5e585..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ExecutionSample.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; - -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public final class ExecutionSample extends Event { - public final int threadState; - public final int samples; - - public ExecutionSample(long time, int tid, int stackTraceId, int threadState, int samples) { - super(time, tid, stackTraceId); - this.threadState = threadState; - this.samples = samples; - } - - @Override - public long samples() { - return samples; - } - - @Override - public long value() { - return samples; - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/GCHeapSummary.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/GCHeapSummary.java deleted file mode 100644 index 68a8be94cf5..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/GCHeapSummary.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; - -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public final class GCHeapSummary extends Event { - public final int gcId; - public final boolean afterGC; - public final long committed; - public final long reserved; - public final long used; - - public GCHeapSummary(JfrReader jfr) { - super(jfr.getVarlong(), 0, 0); - this.gcId = jfr.getVarint(); - this.afterGC = jfr.getVarint() > 0; - jfr.getVarlong(); // long start - jfr.getVarlong(); // long committedEnd - this.committed = jfr.getVarlong(); - jfr.getVarlong(); // long reservedEnd - this.reserved = jfr.getVarlong(); - this.used = jfr.getVarlong(); - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/LiveObject.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/LiveObject.java deleted file mode 100644 index 6423d3b7f67..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/LiveObject.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; - -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public final class LiveObject extends Event { - public final int classId; - public final long allocationSize; - public final long allocationTime; - - public LiveObject( - long time, int tid, int stackTraceId, int classId, long allocationSize, long allocationTime) { - super(time, tid, stackTraceId); - this.classId = classId; - this.allocationSize = allocationSize; - this.allocationTime = allocationTime; - } - - @Override - public int hashCode() { - return classId * 127 + stackTraceId; - } - - @Override - public boolean sameGroup(Event o) { - if (o instanceof LiveObject) { - LiveObject a = (LiveObject) o; - return classId == a.classId; - } - return false; - } - - @Override - public long classId() { - return classId; - } - - @Override - public long value() { - return allocationSize; - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocEvent.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocEvent.java deleted file mode 100644 index 04aff9c71fe..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocEvent.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; - -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public final class MallocEvent extends Event { - public final long address; - public final long size; - - public MallocEvent(long time, int tid, int stackTraceId, long address, long size) { - super(time, tid, stackTraceId); - this.address = address; - this.size = size; - } - - @Override - public long value() { - return size; - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocLeakAggregator.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocLeakAggregator.java deleted file mode 100644 index 6fc81957342..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocLeakAggregator.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; - -@ApiStatus.Internal -public final class MallocLeakAggregator implements EventCollector { - private final EventCollector wrapped; - private final Map addresses; - private @NotNull List events; - - public MallocLeakAggregator(@NotNull EventCollector wrapped) { - this.wrapped = wrapped; - this.addresses = new HashMap<>(); - this.events = new ArrayList<>(); - } - - @Override - public void collect(Event e) { - events.add((MallocEvent) e); - } - - @Override - public void beforeChunk() { - events = new ArrayList<>(); - } - - @Override - public void afterChunk() { - events.sort(null); - - for (MallocEvent e : events) { - if (e.size > 0) { - addresses.put(e.address, e); - } else { - addresses.remove(e.address); - } - } - - events = new ArrayList<>(); - } - - @Override - public boolean finish() { - wrapped.beforeChunk(); - for (Event e : addresses.values()) { - wrapped.collect(e); - } - wrapped.afterChunk(); - - // Free memory before the final conversion - addresses.clear(); - return true; - } - - @Override - public void forEach(Visitor visitor) { - wrapped.forEach(visitor); - } -} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ObjectCount.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ObjectCount.java deleted file mode 100644 index 202619272eb..00000000000 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ObjectCount.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright The async-profiler authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; - -import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public final class ObjectCount extends Event { - public final int gcId; - public final int classId; - public final long count; - public final long totalSize; - - public ObjectCount(JfrReader jfr) { - super(jfr.getVarlong(), 0, 0); - this.gcId = jfr.getVarint(); - this.classId = jfr.getVarint(); - this.count = jfr.getVarlong(); - this.totalSize = jfr.getVarlong(); - } -} diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt index d565fd9d51d..26a10352176 100644 --- a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt @@ -218,13 +218,12 @@ class JfrAsyncProfilerToSentryProfileConverterTest { assertTrue(frames.isNotEmpty()) // Find frames with complete information - val completeFrames = - frames.filter { frame -> - frame.function != null && - frame.module != null && - frame.lineno != null && - frame.filename != null - } + val completeFrames = frames.filter { frame -> + frame.function != null && + frame.module != null && + frame.lineno != null && + frame.filename != null + } assertTrue(completeFrames.isNotEmpty(), "Should have frames with complete information") } @@ -238,15 +237,15 @@ class JfrAsyncProfilerToSentryProfileConverterTest { val frames = sentryProfile.frames // Verify system packages are marked as not in-app - val systemFrames = - frames.filter { frame -> - frame.module?.let { - it.startsWith("java.") || it.startsWith("sun.") || it.startsWith("jdk.") - } ?: false - } + val systemFrames = frames.filter { frame -> + frame.module?.let { + it.startsWith("java.") || it.startsWith("sun.") || it.startsWith("jdk.") + } ?: false + } - val inappSentryFrames = - frames.filter { frame -> frame.module?.startsWith("io.sentry.") ?: false } + val inappSentryFrames = frames.filter { frame -> + frame.module?.startsWith("io.sentry.") ?: false + } val emptyModuleFrames = frames.filter { it.module.isNullOrEmpty() } diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/init/AsyncProfilerInitUtilTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/init/AsyncProfilerInitUtilTest.kt new file mode 100644 index 00000000000..614e940021d --- /dev/null +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/init/AsyncProfilerInitUtilTest.kt @@ -0,0 +1,96 @@ +package io.sentry.asyncprofiler.init + +import io.sentry.ILogger +import io.sentry.ISentryExecutorService +import io.sentry.NoOpContinuousProfiler +import io.sentry.NoOpProfileConverter +import io.sentry.SentryOptions +import io.sentry.asyncprofiler.profiling.JavaContinuousProfiler +import io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider +import io.sentry.util.InitUtil +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import org.mockito.kotlin.mock + +class AsyncProfilerInitUtilTest { + + @Test + fun `initialize Profiler returns no-op profiler if profiling disabled`() { + val options = SentryOptions() + val profiler = InitUtil.initializeProfiler(options) + assert(profiler is NoOpContinuousProfiler) + } + + @Test + fun `initialize Converter returns no-op converter if profiling disabled`() { + val options = SentryOptions() + val converter = InitUtil.initializeProfileConverter(options) + assert(converter is NoOpProfileConverter) + } + + @Test + fun `initialize profiler returns the existing profiler from options if already initialized`() { + val initialProfiler = + JavaContinuousProfiler(mock(), "", 10, mock()) + val options = + SentryOptions().also { + it.setProfileSessionSampleRate(1.0) + it.setContinuousProfiler(initialProfiler) + } + + val profiler = InitUtil.initializeProfiler(options) + assertSame(initialProfiler, profiler) + } + + @Test + fun `initialize converter returns the existing converter from options if already initialized`() { + val initialConverter = AsyncProfilerProfileConverterProvider.AsyncProfilerProfileConverter() + val options = + SentryOptions().also { + it.setProfileSessionSampleRate(1.0) + it.profilerConverter = initialConverter + } + + val converter = InitUtil.initializeProfileConverter(options) + assertSame(initialConverter, converter) + } + + @Test + fun `initialize Profiler returns JavaContinuousProfiler if profiling enabled but profiler not yet initialized`() { + val options = SentryOptions().also { it.setProfileSessionSampleRate(1.0) } + val profiler = InitUtil.initializeProfiler(options) + assertSame(profiler, options.continuousProfiler) + assert(profiler is JavaContinuousProfiler) + } + + @Test + fun `initialize Converter returns AsyncProfilerProfileConverterProvider if profiling enabled but profiler not yet initialized`() { + val options = SentryOptions().also { it.setProfileSessionSampleRate(1.0) } + val converter = InitUtil.initializeProfileConverter(options) + assertSame(converter, options.profilerConverter) + assert(converter is AsyncProfilerProfileConverterProvider.AsyncProfilerProfileConverter) + } + + @Test + fun `initialize profiler uses existing profilingTracesDirPath when set`() { + val customPath = "/custom/path/to/traces" + val options = + SentryOptions().also { + it.setProfileSessionSampleRate(1.0) + it.profilingTracesDirPath = customPath + } + val profiler = InitUtil.initializeProfiler(options) + assert(profiler is JavaContinuousProfiler) + assertSame(customPath, options.profilingTracesDirPath) + } + + @Test + fun `initialize profiler creates and sets profilingTracesDirPath when null`() { + val options = SentryOptions().also { it.setProfileSessionSampleRate(1.0) } + val profiler = InitUtil.initializeProfiler(options) + assert(profiler is JavaContinuousProfiler) + assertNotNull(options.profilingTracesDirPath) + assert(options.profilingTracesDirPath!!.contains("sentry_profiling_traces")) + } +} diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProviderTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProviderTest.kt new file mode 100644 index 00000000000..22cafa5741a --- /dev/null +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProviderTest.kt @@ -0,0 +1,47 @@ +package io.sentry.asyncprofiler.provider + +import io.sentry.ILogger +import io.sentry.ISentryExecutorService +import io.sentry.NoOpContinuousProfiler +import io.sentry.asyncprofiler.profiling.JavaContinuousProfiler +import kotlin.test.Test +import kotlin.test.assertSame +import kotlin.test.assertTrue +import one.profiler.AsyncProfiler +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic + +class AsyncProfilerContinuousProfilerProviderTest { + + @Test + fun `provider returns JavaAsyncProfiler if AsyncProfiler can be loaded`() { + val profiler = + AsyncProfilerContinuousProfilerProvider() + .getContinuousProfiler( + mock(ILogger::class.java), + "", + 10, + mock(ISentryExecutorService::class.java), + ) + + assertTrue(profiler is JavaContinuousProfiler) + } + + @Test + fun `provider return NoopProfiler if AsyncProfiler cannot be loaded`() { + mockStatic(AsyncProfiler::class.java).use { + it.`when` { AsyncProfiler.getInstance() }.thenReturn(null) + + val profiler = + AsyncProfilerContinuousProfilerProvider() + .getContinuousProfiler( + mock(ILogger::class.java), + "", + 10, + mock(ISentryExecutorService::class.java), + ) + + assertSame(NoOpContinuousProfiler.getInstance(), profiler) + } + } +} diff --git a/sentry-bom/build.gradle.kts b/sentry-bom/build.gradle.kts index 45ef7363d47..f219c964e52 100644 --- a/sentry-bom/build.gradle.kts +++ b/sentry-bom/build.gradle.kts @@ -9,6 +9,7 @@ dependencies { .filter { !it.name.startsWith("sentry-samples") && it.name != project.name && + !it.name.endsWith("-bom") && !it.name.contains("test", ignoreCase = true) && !it.name.contains("sentry-android-distribution") } diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 3385d0328e2..8b835ba16fe 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -7,7 +7,6 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.compose) id("com.android.library") - alias(libs.plugins.kover) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) alias(libs.plugins.dokka) @@ -45,13 +44,13 @@ kotlin { } sourceSets { - val commonMain by getting { + getByName("commonMain") { compilerOptions { apiVersion.set(KotlinVersion.KOTLIN_1_9) languageVersion.set(KotlinVersion.KOTLIN_1_9) } } - val androidMain by getting { + getByName("androidMain") { dependencies { api(projects.sentry) api(projects.sentryAndroidNavigation) @@ -61,7 +60,7 @@ kotlin { implementation(libs.androidx.lifecycle.common.java8) } } - val androidUnitTest by getting { + getByName("androidUnitTest") { dependencies { implementation(libs.androidx.compose.ui.test.junit4) implementation(libs.androidx.navigation.compose) @@ -88,13 +87,15 @@ android { buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } - sourceSets["main"].apply { manifest.srcFile("src/androidMain/AndroidManifest.xml") } - buildTypes { getByName("debug") { consumerProguardFiles("proguard-rules.pro") } getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + testOptions { animationsDisabled = true unitTests.apply { diff --git a/sentry-compose/proguard-rules.pro b/sentry-compose/proguard-rules.pro index e6f6b1c8b25..ea5984aa88e 100644 --- a/sentry-compose/proguard-rules.pro +++ b/sentry-compose/proguard-rules.pro @@ -12,6 +12,7 @@ -keepnames class androidx.compose.foundation.ClickableElement -keepnames class androidx.compose.foundation.CombinedClickableElement -keepnames class androidx.compose.foundation.ScrollingLayoutElement +-keepnames class androidx.compose.foundation.ScrollingContainerElement -keepnames class androidx.compose.ui.platform.TestTagElement { *; } -keepnames class io.sentry.compose.SentryModifier$SentryTagModifierNodeElement { *; } diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeHelper.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeHelper.kt index 1f93f758756..10d02103ba1 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeHelper.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeHelper.kt @@ -86,7 +86,9 @@ public fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates?) val rootWidth = root.size.width.toFloat() val rootHeight = root.size.height.toFloat() - val bounds = root.localBoundingBoxOf(this) + // pass clipBounds explicitly to avoid the `localBoundingBoxOf$default` bridge that AGP 8.13's D8 + // desugars inconsistently on minSdk < 24 + val bounds = root.localBoundingBoxOf(this, true) val boundsLeft = bounds.left.fastCoerceIn(0f, rootWidth) val boundsTop = bounds.top.fastCoerceIn(0f, rootHeight) val boundsRight = bounds.right.fastCoerceIn(0f, rootWidth) diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt index 3fec407987b..3c8fb48c35a 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt @@ -53,5 +53,13 @@ public object SentryModifier { override fun SemanticsPropertyReceiver.applySemantics() { this[SentryTag] = tag } + + // SemanticsModifierNode.isImportantForBounds() was added as an abstract method in + // compose-ui 1.11. Classes compiled against earlier versions lack this method in + // their bytecode, which causes AbstractMethodError when the accessibility tree is + // traversed on 1.11+ runtimes. + // Returning true to match the default behavior + // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt;l=69-83;drc=bd7809b4bc9205721c2f1bc681694dd348885849 + @Suppress("unused") fun isImportantForBounds(): Boolean = true } } diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt index 93460b88893..d82451b79e2 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt @@ -15,13 +15,14 @@ import androidx.compose.ui.unit.dp import io.sentry.Sentry import io.sentry.SentryFeedbackOptions +@Deprecated("`SentryUserFeedbackButton` will be removed in the next major version") @Composable public fun SentryUserFeedbackButton( modifier: Modifier = Modifier, text: String = "Report a Bug", configurator: SentryFeedbackOptions.OptionsConfigurator? = null, ) { - Button(modifier = modifier, onClick = { Sentry.showUserFeedbackDialog(configurator) }) { + Button(modifier = modifier, onClick = { Sentry.feedback().show(configurator) }) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt index d3ecd9390a6..47dda6eda9c 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt @@ -15,7 +15,7 @@ import io.sentry.compose.boundsInWindow import io.sentry.internal.gestures.GestureTargetLocator import io.sentry.internal.gestures.UiElement import io.sentry.util.AutoClosableReentrantLock -import java.util.LinkedList +import java.util.ArrayDeque import java.util.Queue @OptIn(InternalComposeUiApi::class) @@ -44,65 +44,59 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT val rootLayoutNode = root.root - val queue: Queue = LinkedList() - queue.add(rootLayoutNode) + // Pair + val queue: Queue> = ArrayDeque() + queue.add(Pair(rootLayoutNode, null)) - // the final tag to return + // the final tag to return, only relevant for clicks + // as for scrolls, we return the first matching element var targetTag: String? = null - // the last known tag when iterating the node tree - var lastKnownTag: String? = null while (!queue.isEmpty()) { - val node = queue.poll() ?: continue + val (node, parentTag) = queue.poll() ?: continue if (node.isPlaced && layoutNodeBoundsContain(rootLayoutNode, node, x, y)) { - var isClickable = false - var isScrollable = false - - val modifiers = node.getModifierInfo() - for (index in modifiers.indices) { - val modifierInfo = modifiers[index] - val tag = composeHelper!!.extractTag(modifierInfo.modifier) - if (tag != null) { - lastKnownTag = tag - } - - if (modifierInfo.modifier is SemanticsModifier) { - val semanticsModifierCore = modifierInfo.modifier as SemanticsModifier - val semanticsConfiguration = semanticsModifierCore.semanticsConfiguration - - for (item in semanticsConfiguration) { - val key: String = item.key.name - if ("ScrollBy" == key) { - isScrollable = true - } else if ("OnClick" == key) { - isClickable = true + val tag = extractTag(composeHelper!!, node) ?: parentTag + if (tag != null) { + val modifiers = node.getModifierInfo() + for (index in modifiers.indices) { + val modifierInfo = modifiers[index] + if (modifierInfo.modifier is SemanticsModifier) { + val semanticsModifierCore = modifierInfo.modifier as SemanticsModifier + val semanticsConfiguration = semanticsModifierCore.semanticsConfiguration + + for (item in semanticsConfiguration) { + val key: String = item.key.name + if (targetType == UiElement.Type.SCROLLABLE && "ScrollBy" == key) { + return UiElement(null, null, null, tag, ORIGIN) + } else if (targetType == UiElement.Type.CLICKABLE && "OnClick" == key) { + targetTag = tag + } + } + } else { + // Jetpack Compose 1.5+: uses Node modifiers elements for clicks/scrolls + val modifier = modifierInfo.modifier + val type = modifier.javaClass.name + if ( + targetType == UiElement.Type.CLICKABLE && + ("androidx.compose.foundation.ClickableElement" == type || + "androidx.compose.foundation.CombinedClickableElement" == type) + ) { + targetTag = tag + } else if ( + targetType == UiElement.Type.SCROLLABLE && + ("androidx.compose.foundation.ScrollingLayoutElement" == type || + "androidx.compose.foundation.ScrollingContainerElement" == type) + ) { + return UiElement(null, null, null, tag, ORIGIN) } - } - } else { - val modifier = modifierInfo.modifier - // Newer Jetpack Compose 1.5 uses Node modifiers for clicks/scrolls - val type = modifier.javaClass.name - if ( - "androidx.compose.foundation.ClickableElement" == type || - "androidx.compose.foundation.CombinedClickableElement" == type - ) { - isClickable = true - } else if ("androidx.compose.foundation.ScrollingLayoutElement" == type) { - isScrollable = true } } } - - if (isClickable && targetType == UiElement.Type.CLICKABLE) { - targetTag = lastKnownTag - } - if (isScrollable && targetType == UiElement.Type.SCROLLABLE) { - targetTag = lastKnownTag - // skip any children for scrollable targets - break + val children = node.zSortedChildren.asMutableList() + for (index in children.indices) { + queue.add(Pair(children[index], tag)) } } - queue.addAll(node.zSortedChildren.asMutableList()) } return if (targetTag == null) { @@ -122,6 +116,18 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT return bounds.contains(Offset(x, y)) } + private fun extractTag(composeHelper: SentryComposeHelper, node: LayoutNode): String? { + val modifiers = node.getModifierInfo() + for (index in modifiers.indices) { + val modifierInfo = modifiers[index] + val tag = composeHelper.extractTag(modifierInfo.modifier) + if (tag != null) { + return tag + } + } + return null + } + public companion object { private const val ORIGIN = "jetpack_compose" } diff --git a/sentry-compose/src/androidUnitTest/kotlin/androidx/compose/foundation/GestureModifierStubs.kt b/sentry-compose/src/androidUnitTest/kotlin/androidx/compose/foundation/GestureModifierStubs.kt new file mode 100644 index 00000000000..43b3e53bc67 --- /dev/null +++ b/sentry-compose/src/androidUnitTest/kotlin/androidx/compose/foundation/GestureModifierStubs.kt @@ -0,0 +1,15 @@ +package androidx.compose.foundation + +import androidx.compose.ui.Modifier + +/** + * Stub classes used by [io.sentry.compose.gestures.ComposeGestureTargetLocatorTest] so that Mockito + * mocks of these classes return the correct [Class.getName] values at runtime. + */ +internal open class ClickableElement : Modifier.Element + +internal open class CombinedClickableElement : Modifier.Element + +internal open class ScrollingLayoutElement : Modifier.Element + +internal open class ScrollingContainerElement : Modifier.Element diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocatorTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocatorTest.kt new file mode 100644 index 00000000000..8efdf3ebe30 --- /dev/null +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocatorTest.kt @@ -0,0 +1,703 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package io.sentry.compose.gestures + +import androidx.compose.runtime.collection.mutableVectorOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.AlignmentLine +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.ModifierInfo +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.Owner +import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsModifier +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.unit.IntSize +import io.sentry.NoOpLogger +import io.sentry.internal.gestures.UiElement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ComposeGestureTargetLocatorTest { + + private val locator = ComposeGestureTargetLocator(NoOpLogger.getInstance()) + + /** + * Maps each child [LayoutCoordinates] to its bounding rect. Used by [FakeRootCoordinates] to + * return correct bounds when [LayoutCoordinates.localBoundingBoxOf] is called. + */ + private val coordsBounds = mutableMapOf() + + private lateinit var rootCoordinates: LayoutCoordinates + + @Before + fun setUp() { + coordsBounds.clear() + rootCoordinates = FakeRootCoordinates(1000, 1000, coordsBounds) + coordsBounds[rootCoordinates] = Rect(0f, 0f, 1000f, 1000f) + } + + @Test + fun `returns null for non-Owner root`() { + val result = locator.locate("not an owner", 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `returns null for null root`() { + val result = locator.locate(null, 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `returns null when no clickable elements`() { + val root = mockLayoutNode(isPlaced = true, tag = "root", width = 100, height = 100) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `detects clickable via SemanticsModifier OnClick`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = "btn", + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(clickableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNotNull(result) + assertEquals("btn", result!!.tag) + assertEquals("jetpack_compose", result.origin) + } + + @Test + fun `detects scrollable via SemanticsModifier ScrollBy`() { + val scrollableChild = + mockLayoutNode( + isPlaced = true, + tag = "list", + width = 50, + height = 50, + semanticsKeys = listOf("ScrollBy"), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(scrollableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.SCROLLABLE) + assertNotNull(result) + assertEquals("list", result!!.tag) + } + + @Test + fun `detects clickable via ClickableElement modifier`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = "btn", + width = 50, + height = 50, + nodeModifierClassName = "androidx.compose.foundation.ClickableElement", + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(clickableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNotNull(result) + assertEquals("btn", result!!.tag) + } + + @Test + fun `detects clickable via CombinedClickableElement modifier`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = "btn", + width = 50, + height = 50, + nodeModifierClassName = "androidx.compose.foundation.CombinedClickableElement", + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(clickableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNotNull(result) + assertEquals("btn", result!!.tag) + } + + @Test + fun `detects scrollable via ScrollingLayoutElement modifier`() { + val scrollableChild = + mockLayoutNode( + isPlaced = true, + tag = "scroll", + width = 50, + height = 50, + nodeModifierClassName = "androidx.compose.foundation.ScrollingLayoutElement", + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(scrollableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.SCROLLABLE) + assertNotNull(result) + assertEquals("scroll", result!!.tag) + } + + @Test + fun `detects scrollable via ScrollingContainerElement modifier`() { + val scrollableChild = + mockLayoutNode( + isPlaced = true, + tag = "scroll", + width = 50, + height = 50, + nodeModifierClassName = "androidx.compose.foundation.ScrollingContainerElement", + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(scrollableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.SCROLLABLE) + assertNotNull(result) + assertEquals("scroll", result!!.tag) + } + + @Test + fun `ignores clickable when looking for scrollable`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = "btn", + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(clickableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.SCROLLABLE) + assertNull(result) + } + + @Test + fun `ignores scrollable when looking for clickable`() { + val scrollableChild = + mockLayoutNode( + isPlaced = true, + tag = "list", + width = 50, + height = 50, + semanticsKeys = listOf("ScrollBy"), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(scrollableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `skips unplaced nodes`() { + val unplacedClickable = + mockLayoutNode( + isPlaced = false, + tag = "btn", + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(unplacedClickable), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `skips nodes outside bounds`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = "btn", + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + left = 200f, + top = 200f, + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 300, + height = 300, + children = listOf(clickableChild), + ) + val owner = mockOwner(root) + + // click at (5, 5) is outside the child bounds (200-250, 200-250) + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `child inherits parent tag`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + ) + val taggedParent = + mockLayoutNode( + isPlaced = true, + tag = "parent_tag", + width = 100, + height = 100, + children = listOf(clickableChild), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 200, + height = 200, + children = listOf(taggedParent), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNotNull(result) + assertEquals("parent_tag", result!!.tag) + } + + @Test + fun `returns deepest clickable for clicks`() { + val deepChild = + mockLayoutNode( + isPlaced = true, + tag = "deep_btn", + width = 20, + height = 20, + semanticsKeys = listOf("OnClick"), + ) + val parentClickable = + mockLayoutNode( + isPlaced = true, + tag = "parent_btn", + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + children = listOf(deepChild), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(parentClickable), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNotNull(result) + assertEquals("deep_btn", result!!.tag) + } + + @Test + fun `returns first scrollable immediately`() { + val deepScrollable = + mockLayoutNode( + isPlaced = true, + tag = "deep_scroll", + width = 20, + height = 20, + semanticsKeys = listOf("ScrollBy"), + ) + val parentScrollable = + mockLayoutNode( + isPlaced = true, + tag = "parent_scroll", + width = 50, + height = 50, + semanticsKeys = listOf("ScrollBy"), + children = listOf(deepScrollable), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(parentScrollable), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.SCROLLABLE) + assertNotNull(result) + assertEquals("parent_scroll", result!!.tag) + } + + @Test + fun `returns null when node has no tag and no parent tag`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(clickableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `finds tagged clickable nested under untagged containers`() { + // Tree: root(no tag) -> container(no tag) -> container(no tag) -> clickable(tag="deep_btn") + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = "deep_btn", + width = 10, + height = 10, + semanticsKeys = listOf("OnClick"), + ) + val innerContainer = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 30, + height = 30, + children = listOf(clickableChild), + ) + val outerContainer = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 60, + height = 60, + children = listOf(innerContainer), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(outerContainer), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNotNull(result) + assertEquals("deep_btn", result!!.tag) + } + + // -- helpers -- + + private fun mockOwner(rootNode: LayoutNode): Owner { + // Wire the root node's coordinates to the shared rootCoordinates + whenever(rootNode.coordinates).thenReturn(rootCoordinates) + val owner = mock() + whenever(owner.root).thenReturn(rootNode) + return owner + } + + private fun mockLayoutNode( + isPlaced: Boolean, + tag: String?, + width: Int, + height: Int, + children: List = emptyList(), + semanticsKeys: List = emptyList(), + nodeModifierClassName: String? = null, + left: Float = 0f, + top: Float = 0f, + ): LayoutNode { + val node = Mockito.mock(LayoutNode::class.java) + whenever(node.isPlaced).thenReturn(isPlaced) + + val modifierInfoList = mutableListOf() + + if (tag != null) { + val tagModifierInfo = mockTestTagModifierInfo(tag) + if (tagModifierInfo != null) { + modifierInfoList.add(tagModifierInfo) + } else { + modifierInfoList.add(mockSemanticsTagModifierInfo(tag)) + } + } + + if (semanticsKeys.isNotEmpty()) { + modifierInfoList.add(mockSemanticsKeysModifierInfo(semanticsKeys)) + } + + if (nodeModifierClassName != null) { + modifierInfoList.add(mockNodeModifierInfo(nodeModifierClassName)) + } + + whenever(node.getModifierInfo()).thenReturn(modifierInfoList) + whenever(node.zSortedChildren) + .thenReturn(mutableVectorOf().apply { addAll(children) }) + + val coordinates = FakeChildCoordinates(left, top, width.toFloat(), height.toFloat()) + coordsBounds[coordinates] = Rect(left, top, left + width, top + height) + whenever(node.coordinates).thenReturn(coordinates) + + return node + } + + /** + * Fake root [LayoutCoordinates] that avoids Mockito issues with inline classes like [Offset]. + * Implements [localBoundingBoxOf] by looking up child bounds in [coordsBounds], and + * [localToWindow] as an identity transform. + */ + private class FakeRootCoordinates( + private val width: Int, + private val height: Int, + private val boundsMap: Map, + ) : LayoutCoordinates { + override val size: IntSize + get() = IntSize(width, height) + + override val isAttached: Boolean + get() = true + + override val parentLayoutCoordinates: LayoutCoordinates? + get() = null + + override val parentCoordinates: LayoutCoordinates? + get() = null + + override val providedAlignmentLines: Set + get() = emptySet() + + override fun get(alignmentLine: AlignmentLine): Int = AlignmentLine.Unspecified + + override fun windowToLocal(relativeToWindow: Offset): Offset = relativeToWindow + + override fun localToWindow(relativeToLocal: Offset): Offset = relativeToLocal + + override fun localToRoot(relativeToLocal: Offset): Offset = relativeToLocal + + override fun localPositionOf( + sourceCoordinates: LayoutCoordinates, + relativeToSource: Offset, + ): Offset = relativeToSource + + override fun localBoundingBoxOf( + sourceCoordinates: LayoutCoordinates, + clipBounds: Boolean, + ): Rect = boundsMap[sourceCoordinates] ?: Rect.Zero + + @Deprecated("Deprecated in interface") + override fun localToScreen(relativeToLocal: Offset): Offset = relativeToLocal + + @Deprecated("Deprecated in interface") + override fun screenToLocal(relativeToScreen: Offset): Offset = relativeToScreen + } + + /** + * Minimal fake [LayoutCoordinates] for child nodes. The actual bounds are resolved via + * [FakeRootCoordinates.localBoundingBoxOf], so this only needs identity implementations. + */ + private class FakeChildCoordinates( + private val left: Float, + private val top: Float, + private val width: Float, + private val height: Float, + ) : LayoutCoordinates { + override val size: IntSize + get() = IntSize(width.toInt(), height.toInt()) + + override val isAttached: Boolean + get() = true + + override val parentLayoutCoordinates: LayoutCoordinates? + get() = null + + override val parentCoordinates: LayoutCoordinates? + get() = null + + override val providedAlignmentLines: Set + get() = emptySet() + + override fun get(alignmentLine: AlignmentLine): Int = AlignmentLine.Unspecified + + override fun windowToLocal(relativeToWindow: Offset): Offset = relativeToWindow + + override fun localToWindow(relativeToLocal: Offset): Offset = relativeToLocal + + override fun localToRoot(relativeToLocal: Offset): Offset = relativeToLocal + + override fun localPositionOf( + sourceCoordinates: LayoutCoordinates, + relativeToSource: Offset, + ): Offset = relativeToSource + + override fun localBoundingBoxOf( + sourceCoordinates: LayoutCoordinates, + clipBounds: Boolean, + ): Rect = Rect(left, top, left + width, top + height) + + @Deprecated("Deprecated in interface") + override fun localToScreen(relativeToLocal: Offset): Offset = relativeToLocal + + @Deprecated("Deprecated in interface") + override fun screenToLocal(relativeToScreen: Offset): Offset = relativeToScreen + } + + companion object { + private fun mockTestTagModifierInfo(tag: String): ModifierInfo? { + return try { + val clazz = Class.forName("androidx.compose.ui.platform.TestTagElement") + val constructor = clazz.declaredConstructors.firstOrNull() ?: return null + constructor.isAccessible = true + val instance = constructor.newInstance(tag) as Modifier + val modifierInfo = Mockito.mock(ModifierInfo::class.java) + whenever(modifierInfo.modifier).thenReturn(instance) + modifierInfo + } catch (_: Throwable) { + null + } + } + + private fun mockSemanticsTagModifierInfo(tag: String): ModifierInfo { + val modifierInfo = Mockito.mock(ModifierInfo::class.java) + whenever(modifierInfo.modifier) + .thenReturn( + object : SemanticsModifier { + override val semanticsConfiguration: SemanticsConfiguration + get() { + val config = SemanticsConfiguration() + config.set(SemanticsPropertyKey("TestTag") { s: String?, _: String? -> s }, tag) + return config + } + } + ) + return modifierInfo + } + + private fun mockSemanticsKeysModifierInfo(keys: List): ModifierInfo { + val modifierInfo = Mockito.mock(ModifierInfo::class.java) + whenever(modifierInfo.modifier) + .thenReturn( + object : SemanticsModifier { + override val semanticsConfiguration: SemanticsConfiguration + get() { + val config = SemanticsConfiguration() + for (key in keys) { + config.set(SemanticsPropertyKey(key) { _, _ -> }, Unit) + } + return config + } + } + ) + return modifierInfo + } + + private fun mockNodeModifierInfo(className: String): ModifierInfo { + val modifierWithClassName = + Mockito.mock( + try { + Class.forName(className) + } catch (_: ClassNotFoundException) { + Modifier::class.java + } + ) as Modifier + val modifierInfo = Mockito.mock(ModifierInfo::class.java) + whenever(modifierInfo.modifier).thenReturn(modifierWithClassName) + return modifierInfo + } + } +} diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts index a8256ca8a27..32db28fae8f 100644 --- a/sentry-graphql-22/build.gradle.kts +++ b/sentry-graphql-22/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -42,27 +42,6 @@ dependencies { testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt index e61fb44a856..c684688299e 100644 --- a/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt +++ b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt @@ -54,14 +54,14 @@ class SentryInstrumentationTest { activeSpan = SentryTracer(TransactionContext("name", "op"), scopes) val schema = """ - type Query { - shows: [Show] - } + type Query { + shows: [Show] + } - type Show { - id: Int - } - """ + type Show { + id: Int + } + """ .trimIndent() val graphQLSchema = diff --git a/sentry-graphql-core/build.gradle.kts b/sentry-graphql-core/build.gradle.kts index cb8c9f49493..34f71ab9cfb 100644 --- a/sentry-graphql-core/build.gradle.kts +++ b/sentry-graphql-core/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -41,27 +41,6 @@ dependencies { testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index 46bef6e4b9d..d92dc52c6d7 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -42,27 +42,6 @@ dependencies { testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt index 1e2e5c8f0f0..972b091a226 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt @@ -50,14 +50,14 @@ class SentryInstrumentationTest { activeSpan = SentryTracer(TransactionContext("name", "op"), scopes) val schema = """ - type Query { - shows: [Show] - } + type Query { + shows: [Show] + } - type Show { - id: Int - } - """ + type Show { + id: Int + } + """ .trimIndent() val graphQLSchema = diff --git a/sentry-jcache/README.md b/sentry-jcache/README.md new file mode 100644 index 00000000000..e4f4d8e49a4 --- /dev/null +++ b/sentry-jcache/README.md @@ -0,0 +1,13 @@ +# sentry-jcache + +This module provides an integration for JCache (JSR-107). + +JCache is a standard API — you need a provider implementation at runtime. Common implementations include: + +- [Caffeine](https://github.com/ben-manes/caffeine) (via `com.github.ben-manes.caffeine:jcache`) +- [Ehcache 3](https://www.ehcache.org/) (via `org.ehcache:ehcache`) +- [Hazelcast](https://hazelcast.com/) +- [Apache Ignite](https://ignite.apache.org/) +- [Infinispan](https://infinispan.org/) + +Please consult the documentation on how to install and use this integration in the Sentry Docs for [Java](https://docs.sentry.io/platforms/java/integrations/jcache/). diff --git a/sentry-jcache/api/sentry-jcache.api b/sentry-jcache/api/sentry-jcache.api new file mode 100644 index 00000000000..b834ba41064 --- /dev/null +++ b/sentry-jcache/api/sentry-jcache.api @@ -0,0 +1,38 @@ +public final class io/sentry/jcache/BuildConfig { + public static final field SENTRY_JCACHE_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/jcache/SentryJCacheWrapper : javax/cache/Cache { + public fun (Ljavax/cache/Cache;)V + public fun (Ljavax/cache/Cache;Lio/sentry/IScopes;)V + public fun clear ()V + public fun close ()V + public fun containsKey (Ljava/lang/Object;)Z + public fun deregisterCacheEntryListener (Ljavax/cache/configuration/CacheEntryListenerConfiguration;)V + public fun get (Ljava/lang/Object;)Ljava/lang/Object; + public fun getAll (Ljava/util/Set;)Ljava/util/Map; + public fun getAndPut (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + public fun getAndRemove (Ljava/lang/Object;)Ljava/lang/Object; + public fun getAndReplace (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + public fun getCacheManager ()Ljavax/cache/CacheManager; + public fun getConfiguration (Ljava/lang/Class;)Ljavax/cache/configuration/Configuration; + public fun getName ()Ljava/lang/String; + public fun invoke (Ljava/lang/Object;Ljavax/cache/processor/EntryProcessor;[Ljava/lang/Object;)Ljava/lang/Object; + public fun invokeAll (Ljava/util/Set;Ljavax/cache/processor/EntryProcessor;[Ljava/lang/Object;)Ljava/util/Map; + public fun isClosed ()Z + public fun iterator ()Ljava/util/Iterator; + public fun loadAll (Ljava/util/Set;ZLjavax/cache/integration/CompletionListener;)V + public fun put (Ljava/lang/Object;Ljava/lang/Object;)V + public fun putAll (Ljava/util/Map;)V + public fun putIfAbsent (Ljava/lang/Object;Ljava/lang/Object;)Z + public fun registerCacheEntryListener (Ljavax/cache/configuration/CacheEntryListenerConfiguration;)V + public fun remove (Ljava/lang/Object;)Z + public fun remove (Ljava/lang/Object;Ljava/lang/Object;)Z + public fun removeAll ()V + public fun removeAll (Ljava/util/Set;)V + public fun replace (Ljava/lang/Object;Ljava/lang/Object;)Z + public fun replace (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z + public fun unwrap (Ljava/lang/Class;)Ljava/lang/Object; +} + diff --git a/sentry-jcache/build.gradle.kts b/sentry-jcache/build.gradle.kts new file mode 100644 index 00000000000..b388f35881f --- /dev/null +++ b/sentry-jcache/build.gradle.kts @@ -0,0 +1,69 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 +} + +dependencies { + api(projects.sentry) + compileOnly(libs.jcache) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(projects.sentry) + testImplementation(projects.sentryTestSupport) + testImplementation(libs.jcache) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.jcache") + buildConfigField( + "String", + "SENTRY_JCACHE_SDK_NAME", + "\"${Config.Sentry.SENTRY_JCACHE_SDK_NAME}\"", + ) + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_JCACHE_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-jcache", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-jcache/src/main/java/io/sentry/jcache/SentryJCacheWrapper.java b/sentry-jcache/src/main/java/io/sentry/jcache/SentryJCacheWrapper.java new file mode 100644 index 00000000000..e1c3b786e85 --- /dev/null +++ b/sentry-jcache/src/main/java/io/sentry/jcache/SentryJCacheWrapper.java @@ -0,0 +1,506 @@ +package io.sentry.jcache; + +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.ScopesAdapter; +import io.sentry.SpanDataConvention; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import javax.cache.Cache; +import javax.cache.CacheManager; +import javax.cache.configuration.CacheEntryListenerConfiguration; +import javax.cache.configuration.Configuration; +import javax.cache.integration.CompletionListener; +import javax.cache.processor.EntryProcessor; +import javax.cache.processor.EntryProcessorException; +import javax.cache.processor.EntryProcessorResult; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Wraps a JCache {@link Cache} to create Sentry spans for cache operations. + * + * @param the type of key + * @param the type of value + */ +@ApiStatus.Experimental +public final class SentryJCacheWrapper implements Cache { + + private static final String TRACE_ORIGIN = "auto.cache.jcache"; + + private final @NotNull Cache delegate; + private final @NotNull IScopes scopes; + + public SentryJCacheWrapper(final @NotNull Cache delegate) { + this(delegate, ScopesAdapter.getInstance()); + } + + public SentryJCacheWrapper(final @NotNull Cache delegate, final @NotNull IScopes scopes) { + this.delegate = delegate; + this.scopes = scopes; + } + + // -- read operations -- + + @Override + public V get(final K key) { + final ISpan span = startSpan(key, "get"); + if (span == null) { + return delegate.get(key); + } + try { + final V result = delegate.get(key); + span.setData(SpanDataConvention.CACHE_HIT, result != null); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public Map getAll(final Set keys) { + final ISpan span = startSpanForKeys(keys, "getAll"); + if (span == null) { + return delegate.getAll(keys); + } + try { + final Map result = delegate.getAll(keys); + span.setData(SpanDataConvention.CACHE_HIT, !result.isEmpty()); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public boolean containsKey(final K key) { + return delegate.containsKey(key); + } + + // -- write operations -- + + @Override + public void put(final K key, final V value) { + final ISpan span = startSpan(key, "put"); + if (span == null) { + delegate.put(key, value); + return; + } + try { + delegate.put(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public V getAndPut(final K key, final V value) { + final ISpan span = startSpan(key, "getAndPut"); + if (span == null) { + return delegate.getAndPut(key, value); + } + try { + final V result = delegate.getAndPut(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public void putAll(final Map map) { + final ISpan span = startSpanForKeys(map.keySet(), "putAll"); + if (span == null) { + delegate.putAll(map); + return; + } + try { + delegate.putAll(map); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public boolean putIfAbsent(final K key, final V value) { + final ISpan span = startSpan(key, "putIfAbsent"); + if (span == null) { + return delegate.putIfAbsent(key, value); + } + try { + final boolean result = delegate.putIfAbsent(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, result); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public boolean replace(final K key, final V oldValue, final V newValue) { + final ISpan span = startSpan(key, "replace"); + if (span == null) { + return delegate.replace(key, oldValue, newValue); + } + try { + final boolean result = delegate.replace(key, oldValue, newValue); + span.setData(SpanDataConvention.CACHE_WRITE, result); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public boolean replace(final K key, final V value) { + final ISpan span = startSpan(key, "replace"); + if (span == null) { + return delegate.replace(key, value); + } + try { + final boolean result = delegate.replace(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, result); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public V getAndReplace(final K key, final V value) { + final ISpan span = startSpan(key, "getAndReplace"); + if (span == null) { + return delegate.getAndReplace(key, value); + } + try { + final V result = delegate.getAndReplace(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, result != null); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + // -- remove operations -- + + @Override + public boolean remove(final K key) { + final ISpan span = startSpan(key, "remove"); + if (span == null) { + return delegate.remove(key); + } + try { + final boolean result = delegate.remove(key); + span.setData(SpanDataConvention.CACHE_WRITE, result); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public boolean remove(final K key, final V oldValue) { + final ISpan span = startSpan(key, "remove"); + if (span == null) { + return delegate.remove(key, oldValue); + } + try { + final boolean result = delegate.remove(key, oldValue); + span.setData(SpanDataConvention.CACHE_WRITE, result); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public V getAndRemove(final K key) { + final ISpan span = startSpan(key, "getAndRemove"); + if (span == null) { + return delegate.getAndRemove(key); + } + try { + final V result = delegate.getAndRemove(key); + span.setData(SpanDataConvention.CACHE_WRITE, result != null); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public void removeAll(final Set keys) { + final ISpan span = startSpanForKeys(keys, "removeAll"); + if (span == null) { + delegate.removeAll(keys); + return; + } + try { + delegate.removeAll(keys); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public void removeAll() { + final ISpan span = startSpan(null, "removeAll"); + if (span == null) { + delegate.removeAll(); + return; + } + try { + delegate.removeAll(); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + // -- flush operations -- + + @Override + public void clear() { + final ISpan span = startSpan(null, "clear"); + if (span == null) { + delegate.clear(); + return; + } + try { + delegate.clear(); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public void close() { + delegate.close(); + } + + // -- entry processor operations -- + + @Override + public T invoke( + final K key, final EntryProcessor entryProcessor, final Object... arguments) + throws EntryProcessorException { + final ISpan span = startSpan(key, "invoke"); + if (span == null) { + return delegate.invoke(key, entryProcessor, arguments); + } + try { + final T result = delegate.invoke(key, entryProcessor, arguments); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public Map> invokeAll( + final Set keys, + final EntryProcessor entryProcessor, + final Object... arguments) { + final ISpan span = startSpanForKeys(keys, "invokeAll"); + if (span == null) { + return delegate.invokeAll(keys, entryProcessor, arguments); + } + try { + final Map> result = + delegate.invokeAll(keys, entryProcessor, arguments); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + // -- passthrough operations -- + + @Override + public void loadAll( + final Set keys, + final boolean replaceExistingValues, + final CompletionListener completionListener) { + delegate.loadAll(keys, replaceExistingValues, completionListener); + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public CacheManager getCacheManager() { + return delegate.getCacheManager(); + } + + @Override + public > C getConfiguration(final Class clazz) { + return delegate.getConfiguration(clazz); + } + + @Override + public boolean isClosed() { + return delegate.isClosed(); + } + + @Override + public T unwrap(final Class clazz) { + return delegate.unwrap(clazz); + } + + @Override + public void registerCacheEntryListener( + final CacheEntryListenerConfiguration cacheEntryListenerConfiguration) { + delegate.registerCacheEntryListener(cacheEntryListenerConfiguration); + } + + @Override + public void deregisterCacheEntryListener( + final CacheEntryListenerConfiguration cacheEntryListenerConfiguration) { + delegate.deregisterCacheEntryListener(cacheEntryListenerConfiguration); + } + + @Override + public Iterator> iterator() { + return delegate.iterator(); + } + + // -- span helpers -- + + private @Nullable ISpan startSpan( + final @Nullable Object key, final @NotNull String operationName) { + final String keyString = key != null ? String.valueOf(key) : null; + return startSpan( + operationName, keyString, keyString != null ? Collections.singletonList(keyString) : null); + } + + private @Nullable ISpan startSpanForKeys( + final @NotNull Set keys, final @NotNull String operationName) { + final List keyStrings = keys.stream().map(String::valueOf).collect(Collectors.toList()); + return startSpan(operationName, String.join(", ", keyStrings), keyStrings); + } + + private @Nullable ISpan startSpan( + final @NotNull String operationName, + final @Nullable String description, + final @Nullable List cacheKeys) { + if (!scopes.getOptions().isEnableCacheTracing()) { + return null; + } + + final ISpan activeSpan = scopes.getSpan(); + if (activeSpan == null || activeSpan.isNoOp()) { + return null; + } + + final SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(TRACE_ORIGIN); + final ISpan span = activeSpan.startChild("cache." + operationName, description, spanOptions); + if (span.isNoOp()) { + return null; + } + if (cacheKeys != null) { + span.setData(SpanDataConvention.CACHE_KEY, cacheKeys); + } + span.setData(SpanDataConvention.CACHE_OPERATION, operationName); + return span; + } +} diff --git a/sentry-jcache/src/test/kotlin/io/sentry/jcache/SentryJCacheWrapperTest.kt b/sentry-jcache/src/test/kotlin/io/sentry/jcache/SentryJCacheWrapperTest.kt new file mode 100644 index 00000000000..9e523d3df21 --- /dev/null +++ b/sentry-jcache/src/test/kotlin/io/sentry/jcache/SentryJCacheWrapperTest.kt @@ -0,0 +1,534 @@ +package io.sentry.jcache + +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import javax.cache.Cache +import javax.cache.CacheManager +import javax.cache.configuration.CacheEntryListenerConfiguration +import javax.cache.configuration.Configuration +import javax.cache.integration.CompletionListener +import javax.cache.processor.EntryProcessor +import javax.cache.processor.EntryProcessorResult +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryJCacheWrapperTest { + + private lateinit var scopes: IScopes + private lateinit var delegate: Cache + private lateinit var options: SentryOptions + + @BeforeTest + fun setup() { + scopes = mock() + delegate = mock() + options = SentryOptions().apply { isEnableCacheTracing = true } + whenever(scopes.options).thenReturn(options) + whenever(delegate.name).thenReturn("testCache") + } + + private fun createTransaction(): SentryTracer { + val tx = SentryTracer(TransactionContext("tx", "op"), scopes) + whenever(scopes.span).thenReturn(tx) + return tx + } + + // -- get(K key) -- + + @Test + fun `get creates span with cache hit true on hit`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.get("myKey")).thenReturn("value") + + val result = wrapper.get("myKey") + + assertEquals("value", result) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.get", span.operation) + assertEquals("myKey", span.description) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_HIT)) + assertNull(span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("auto.cache.jcache", span.spanContext.origin) + assertEquals("get", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + @Test + fun `get creates span with cache hit false on miss`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.get("myKey")).thenReturn(null) + + val result = wrapper.get("myKey") + + assertNull(result) + assertEquals(1, tx.spans.size) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_HIT)) + } + + // -- getAll -- + + @Test + fun `getAll creates span with cache hit true when results exist`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + val keys = setOf("k1", "k2") + whenever(delegate.getAll(keys)).thenReturn(mapOf("k1" to "v1")) + + val result = wrapper.getAll(keys) + + assertEquals(mapOf("k1" to "v1"), result) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.getAll", span.operation) + assertEquals("k1, k2", span.description) + assertEquals(true, span.getData(SpanDataConvention.CACHE_HIT)) + assertEquals(listOf("k1", "k2"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("getAll", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + @Test + fun `getAll creates span with cache hit false when empty`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + val keys = setOf("k1") + whenever(delegate.getAll(keys)).thenReturn(emptyMap()) + + wrapper.getAll(keys) + + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_HIT)) + } + + // -- put -- + + @Test + fun `put creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + + wrapper.put("myKey", "myValue") + + verify(delegate).put("myKey", "myValue") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.put", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("put", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- getAndPut -- + + @Test + fun `getAndPut creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.getAndPut("myKey", "newValue")).thenReturn("oldValue") + + val result = wrapper.getAndPut("myKey", "newValue") + + assertEquals("oldValue", result) + assertEquals(1, tx.spans.size) + assertEquals("cache.getAndPut", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("getAndPut", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- putAll -- + + @Test + fun `putAll creates cache put span with all keys`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + val entries = mapOf("k1" to "v1", "k2" to "v2") + + wrapper.putAll(entries) + + verify(delegate).putAll(entries) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.putAll", span.operation) + assertEquals("k1, k2", span.description) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("k1", "k2"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("putAll", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- putIfAbsent -- + + @Test + fun `putIfAbsent creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.putIfAbsent("myKey", "myValue")).thenReturn(true) + + val result = wrapper.putIfAbsent("myKey", "myValue") + + assertTrue(result) + verify(delegate).putIfAbsent("myKey", "myValue") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.putIfAbsent", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("putIfAbsent", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- replace -- + + @Test + fun `replace with old value creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.replace("myKey", "old", "new")).thenReturn(true) + + val result = wrapper.replace("myKey", "old", "new") + + assertTrue(result) + verify(delegate).replace("myKey", "old", "new") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.replace", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("replace", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + @Test + fun `replace creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.replace("myKey", "value")).thenReturn(true) + + val result = wrapper.replace("myKey", "value") + + assertTrue(result) + verify(delegate).replace("myKey", "value") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.replace", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("replace", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- getAndReplace -- + + @Test + fun `getAndReplace creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.getAndReplace("myKey", "newValue")).thenReturn("oldValue") + + val result = wrapper.getAndReplace("myKey", "newValue") + + assertEquals("oldValue", result) + verify(delegate).getAndReplace("myKey", "newValue") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.getAndReplace", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("getAndReplace", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- remove(K) -- + + @Test + fun `remove creates cache remove span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.remove("myKey")).thenReturn(true) + + val result = wrapper.remove("myKey") + + assertTrue(result) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.remove", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("remove", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- remove(K, V) -- + + @Test + fun `remove with value creates cache remove span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.remove("myKey", "myValue")).thenReturn(true) + + val result = wrapper.remove("myKey", "myValue") + + assertTrue(result) + assertEquals(1, tx.spans.size) + assertEquals("cache.remove", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("remove", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- getAndRemove -- + + @Test + fun `getAndRemove creates cache remove span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.getAndRemove("myKey")).thenReturn("value") + + val result = wrapper.getAndRemove("myKey") + + assertEquals("value", result) + assertEquals(1, tx.spans.size) + assertEquals("cache.getAndRemove", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("getAndRemove", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- removeAll(Set) -- + + @Test + fun `removeAll with keys creates cache remove span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + val keys = setOf("k1", "k2") + + wrapper.removeAll(keys) + + verify(delegate).removeAll(keys) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.removeAll", span.operation) + assertEquals("k1, k2", span.description) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("k1", "k2"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("removeAll", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- removeAll() -- + + @Test + fun `removeAll without keys creates cache flush span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + + wrapper.removeAll() + + verify(delegate).removeAll() + assertEquals(1, tx.spans.size) + assertEquals("cache.removeAll", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("removeAll", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- clear -- + + @Test + fun `clear creates cache flush span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + + wrapper.clear() + + verify(delegate).clear() + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.clear", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertNull(span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("clear", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- invoke -- + + @Test + fun `invoke creates cache get span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + val processor = mock>() + whenever(delegate.invoke("myKey", processor)).thenReturn("result") + + val result = wrapper.invoke("myKey", processor) + + assertEquals("result", result) + assertEquals(1, tx.spans.size) + assertEquals("cache.invoke", tx.spans.first().operation) + assertEquals("invoke", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- invokeAll -- + + @Test + fun `invokeAll creates cache get span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + val processor = mock>() + val keys = setOf("k1", "k2") + val resultMap = mock>>() + whenever(delegate.invokeAll(keys, processor)).thenReturn(resultMap) + + val result = wrapper.invokeAll(keys, processor) + + assertEquals(resultMap, result) + assertEquals(1, tx.spans.size) + assertEquals("cache.invokeAll", tx.spans.first().operation) + assertEquals("invokeAll", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- passthrough operations -- + + @Test + fun `containsKey delegates without creating span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.containsKey("myKey")).thenReturn(true) + + assertTrue(wrapper.containsKey("myKey")) + assertEquals(0, tx.spans.size) + } + + @Test + fun `getName delegates to underlying cache`() { + val wrapper = SentryJCacheWrapper(delegate, scopes) + assertEquals("testCache", wrapper.name) + } + + @Test + fun `getCacheManager delegates to underlying cache`() { + val manager = mock() + whenever(delegate.cacheManager).thenReturn(manager) + val wrapper = SentryJCacheWrapper(delegate, scopes) + assertEquals(manager, wrapper.cacheManager) + } + + @Test + fun `isClosed delegates to underlying cache`() { + whenever(delegate.isClosed).thenReturn(false) + val wrapper = SentryJCacheWrapper(delegate, scopes) + assertFalse(wrapper.isClosed) + } + + @Test + fun `close delegates to underlying cache`() { + val wrapper = SentryJCacheWrapper(delegate, scopes) + wrapper.close() + verify(delegate).close() + } + + @Test + fun `registerCacheEntryListener delegates to underlying cache`() { + val wrapper = SentryJCacheWrapper(delegate, scopes) + val config = mock>() + wrapper.registerCacheEntryListener(config) + verify(delegate).registerCacheEntryListener(config) + } + + @Test + fun `deregisterCacheEntryListener delegates to underlying cache`() { + val wrapper = SentryJCacheWrapper(delegate, scopes) + val config = mock>() + wrapper.deregisterCacheEntryListener(config) + verify(delegate).deregisterCacheEntryListener(config) + } + + @Test + fun `iterator delegates to underlying cache`() { + val iter = mock>>() + whenever(delegate.iterator()).thenReturn(iter) + val wrapper = SentryJCacheWrapper(delegate, scopes) + assertEquals(iter, wrapper.iterator()) + } + + @Test + fun `loadAll delegates to underlying cache`() { + val wrapper = SentryJCacheWrapper(delegate, scopes) + val keys = setOf("k1") + val listener = mock() + wrapper.loadAll(keys, true, listener) + verify(delegate).loadAll(keys, true, listener) + } + + @Test + fun `getConfiguration delegates to underlying cache`() { + val config = mock>() + whenever( + delegate.getConfiguration(Configuration::class.java as Class>) + ) + .thenReturn(config) + val wrapper = SentryJCacheWrapper(delegate, scopes) + assertEquals( + config, + wrapper.getConfiguration(Configuration::class.java as Class>), + ) + } + + @Test + fun `unwrap delegates to underlying cache`() { + whenever(delegate.unwrap(String::class.java)).thenReturn("unwrapped") + val wrapper = SentryJCacheWrapper(delegate, scopes) + assertEquals("unwrapped", wrapper.unwrap(String::class.java)) + } + + // -- no span when no active transaction -- + + @Test + fun `does not create span when there is no active transaction`() { + whenever(scopes.span).thenReturn(null) + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.get("myKey")).thenReturn(null) + + wrapper.get("myKey") + + verify(delegate).get("myKey") + } + + // -- no span when option is disabled -- + + @Test + fun `does not create span when enableCacheTracing is false`() { + options.isEnableCacheTracing = false + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.get("myKey")).thenReturn(null) + + wrapper.get("myKey") + + verify(delegate).get("myKey") + assertEquals(0, tx.spans.size) + } + + // -- error handling -- + + @Test + fun `sets error status and throwable on exception`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + val exception = RuntimeException("cache error") + whenever(delegate.get("myKey")).thenThrow(exception) + + assertFailsWith { wrapper.get("myKey") } + + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + } +} diff --git a/sentry-jdbc/api/sentry-jdbc.api b/sentry-jdbc/api/sentry-jdbc.api index dba5791f805..d2d46066184 100644 --- a/sentry-jdbc/api/sentry-jdbc.api +++ b/sentry-jdbc/api/sentry-jdbc.api @@ -6,6 +6,7 @@ public final class io/sentry/jdbc/BuildConfig { public final class io/sentry/jdbc/DatabaseUtils { public fun ()V public static fun parse (Ljava/lang/String;)Lio/sentry/jdbc/DatabaseUtils$DatabaseDetails; + public static fun readFrom (Lcom/p6spy/engine/common/ConnectionInformation;)Lio/sentry/jdbc/DatabaseUtils$DatabaseDetails; public static fun readFrom (Lcom/p6spy/engine/common/StatementInformation;)Lio/sentry/jdbc/DatabaseUtils$DatabaseDetails; } @@ -19,6 +20,12 @@ public class io/sentry/jdbc/SentryJdbcEventListener : com/p6spy/engine/event/Sim public fun ()V public fun (Lio/sentry/IScopes;)V public fun onAfterAnyExecute (Lcom/p6spy/engine/common/StatementInformation;JLjava/sql/SQLException;)V + public fun onAfterCommit (Lcom/p6spy/engine/common/ConnectionInformation;JLjava/sql/SQLException;)V + public fun onAfterRollback (Lcom/p6spy/engine/common/ConnectionInformation;JLjava/sql/SQLException;)V + public fun onAfterSetAutoCommit (Lcom/p6spy/engine/common/ConnectionInformation;ZZLjava/sql/SQLException;)V public fun onBeforeAnyExecute (Lcom/p6spy/engine/common/StatementInformation;)V + public fun onBeforeCommit (Lcom/p6spy/engine/common/ConnectionInformation;)V + public fun onBeforeRollback (Lcom/p6spy/engine/common/ConnectionInformation;)V + public fun onBeforeSetAutoCommit (Lcom/p6spy/engine/common/ConnectionInformation;ZZ)V } diff --git a/sentry-jdbc/build.gradle.kts b/sentry-jdbc/build.gradle.kts index 0415fd8ccff..e2a7f573138 100644 --- a/sentry-jdbc/build.gradle.kts +++ b/sentry-jdbc/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -35,27 +35,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java b/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java index a7585a88664..4583dc9e6c1 100644 --- a/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java +++ b/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java @@ -20,6 +20,11 @@ public static DatabaseDetails readFrom( final @Nullable ConnectionInformation connectionInformation = statementInformation.getConnectionInformation(); + return readFrom(connectionInformation); + } + + public static DatabaseDetails readFrom( + final @Nullable ConnectionInformation connectionInformation) { if (connectionInformation == null) { return EMPTY; } @@ -126,7 +131,6 @@ public static DatabaseDetails parse(final @Nullable String databaseConnectionUrl String pathWithoutProperties = StringUtils.substringBefore(path, ";"); return new DatabaseDetails(dbSystem, pathWithoutProperties); } catch (Throwable t) { - System.out.println(t.getMessage()); // ignore } return new DatabaseDetails(dbSystem, null); diff --git a/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java b/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java index 4a9085b6dfd..4206de18002 100644 --- a/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java +++ b/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java @@ -4,6 +4,7 @@ import static io.sentry.SpanDataConvention.DB_SYSTEM_KEY; import com.jakewharton.nopen.annotation.Open; +import com.p6spy.engine.common.ConnectionInformation; import com.p6spy.engine.common.StatementInformation; import com.p6spy.engine.event.SimpleJdbcEventListener; import io.sentry.IScopes; @@ -11,7 +12,6 @@ import io.sentry.ISpan; import io.sentry.ScopesAdapter; import io.sentry.SentryIntegrationPackageStorage; -import io.sentry.Span; import io.sentry.SpanOptions; import io.sentry.SpanStatus; import io.sentry.util.AutoClosableReentrantLock; @@ -20,12 +20,12 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -/** P6Spy JDBC event listener that creates {@link Span}s around database queries. */ @Open public class SentryJdbcEventListener extends SimpleJdbcEventListener { private static final String TRACE_ORIGIN = "auto.db.jdbc"; private final @NotNull IScopes scopes; - private static final @NotNull ThreadLocal CURRENT_SPAN = new ThreadLocal<>(); + private static final @NotNull ThreadLocal CURRENT_QUERY_SPAN = new ThreadLocal<>(); + private static final @NotNull ThreadLocal CURRENT_TRANSACTION_SPAN = new ThreadLocal<>(); private volatile @Nullable DatabaseUtils.DatabaseDetails cachedDatabaseDetails = null; protected final @NotNull AutoClosableReentrantLock databaseDetailsLock = @@ -47,13 +47,7 @@ public SentryJdbcEventListener() { @Override public void onBeforeAnyExecute(final @NotNull StatementInformation statementInformation) { - final ISpan parent = scopes.getSpan(); - if (parent != null && !parent.isNoOp()) { - final @NotNull SpanOptions spanOptions = new SpanOptions(); - spanOptions.setOrigin(TRACE_ORIGIN); - final ISpan span = parent.startChild("db.query", statementInformation.getSql(), spanOptions); - CURRENT_SPAN.set(span); - } + startSpan(CURRENT_QUERY_SPAN, "db.query", statementInformation.getSql()); } @Override @@ -61,10 +55,101 @@ public void onAfterAnyExecute( final @NotNull StatementInformation statementInformation, long timeElapsedNanos, final @Nullable SQLException e) { - final ISpan span = CURRENT_SPAN.get(); + finishSpan(CURRENT_QUERY_SPAN, statementInformation.getConnectionInformation(), e); + } + + @Override + public void onBeforeSetAutoCommit( + final @NotNull ConnectionInformation connectionInformation, + boolean newAutoCommit, + boolean currentAutoCommit) { + if (!isDatabaseTransactionTracingEnabled()) { + return; + } + final boolean isSwitchingToManualCommit = !newAutoCommit && currentAutoCommit; + if (isSwitchingToManualCommit) { + startSpan(CURRENT_TRANSACTION_SPAN, "db.sql.transaction.begin", "BEGIN"); + } + } + + @Override + public void onAfterSetAutoCommit( + final @NotNull ConnectionInformation connectionInformation, + final boolean newAutoCommit, + final boolean oldAutoCommit, + final @Nullable SQLException e) { + if (!isDatabaseTransactionTracingEnabled()) { + return; + } + final boolean isSwitchingToManualCommit = !newAutoCommit && oldAutoCommit; + if (isSwitchingToManualCommit) { + finishSpan(CURRENT_TRANSACTION_SPAN, connectionInformation, e); + } + } + + @Override + public void onBeforeCommit(final @NotNull ConnectionInformation connectionInformation) { + if (!isDatabaseTransactionTracingEnabled()) { + return; + } + startSpan(CURRENT_TRANSACTION_SPAN, "db.sql.transaction.commit", "COMMIT"); + } + + @Override + public void onAfterCommit( + final @NotNull ConnectionInformation connectionInformation, + final long timeElapsedNanos, + final @Nullable SQLException e) { + if (!isDatabaseTransactionTracingEnabled()) { + return; + } + finishSpan(CURRENT_TRANSACTION_SPAN, connectionInformation, e); + } + + @Override + public void onBeforeRollback(final @NotNull ConnectionInformation connectionInformation) { + if (!isDatabaseTransactionTracingEnabled()) { + return; + } + startSpan(CURRENT_TRANSACTION_SPAN, "db.sql.transaction.rollback", "ROLLBACK"); + } + + @Override + public void onAfterRollback( + final @NotNull ConnectionInformation connectionInformation, + final long timeElapsedNanos, + final @Nullable SQLException e) { + if (!isDatabaseTransactionTracingEnabled()) { + return; + } + finishSpan(CURRENT_TRANSACTION_SPAN, connectionInformation, e); + } + + private boolean isDatabaseTransactionTracingEnabled() { + return scopes.getOptions().isEnableDatabaseTransactionTracing(); + } + + private void startSpan( + final @NotNull ThreadLocal spanHolder, + final @NotNull String operation, + final @Nullable String description) { + final @Nullable ISpan parent = scopes.getSpan(); + if (parent != null && !parent.isNoOp()) { + final @NotNull SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(TRACE_ORIGIN); + final @NotNull ISpan span = parent.startChild(operation, description, spanOptions); + spanHolder.set(span); + } + } + + private void finishSpan( + final @NotNull ThreadLocal spanHolder, + final @Nullable ConnectionInformation connectionInformation, + final @Nullable SQLException e) { + final @Nullable ISpan span = spanHolder.get(); if (span != null) { - applyDatabaseDetailsToSpan(statementInformation, span); + applyDatabaseDetailsToSpan(connectionInformation, span); if (e != null) { span.setThrowable(e); @@ -73,7 +158,7 @@ public void onAfterAnyExecute( span.setStatus(SpanStatus.OK); } span.finish(); - CURRENT_SPAN.set(null); + spanHolder.remove(); } } @@ -82,9 +167,9 @@ private void addPackageAndIntegrationInfo() { } private void applyDatabaseDetailsToSpan( - final @NotNull StatementInformation statementInformation, final @NotNull ISpan span) { + final @Nullable ConnectionInformation connectionInformation, final @NotNull ISpan span) { final @NotNull DatabaseUtils.DatabaseDetails databaseDetails = - getOrComputeDatabaseDetails(statementInformation); + getOrComputeDatabaseDetails(connectionInformation); if (databaseDetails.getDbSystem() != null) { span.setData(DB_SYSTEM_KEY, databaseDetails.getDbSystem()); @@ -96,11 +181,11 @@ private void applyDatabaseDetailsToSpan( } private @NotNull DatabaseUtils.DatabaseDetails getOrComputeDatabaseDetails( - final @NotNull StatementInformation statementInformation) { + final @Nullable ConnectionInformation connectionInformation) { if (cachedDatabaseDetails == null) { try (final @NotNull ISentryLifecycleToken ignored = databaseDetailsLock.acquire()) { if (cachedDatabaseDetails == null) { - cachedDatabaseDetails = DatabaseUtils.readFrom(statementInformation); + cachedDatabaseDetails = DatabaseUtils.readFrom(connectionInformation); } } } diff --git a/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt b/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt index 605c22ec090..22ee97e5d47 100644 --- a/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt +++ b/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt @@ -1,6 +1,6 @@ package io.sentry.jdbc -import com.p6spy.engine.common.StatementInformation +import com.p6spy.engine.common.ConnectionInformation import com.p6spy.engine.spy.P6DataSource import io.sentry.IScopes import io.sentry.SentryOptions @@ -25,15 +25,22 @@ import org.mockito.kotlin.whenever class SentryJdbcEventListenerTest { class Fixture { + lateinit var options: SentryOptions val scopes = - mock().apply { - whenever(options) - .thenReturn(SentryOptions().apply { sdkVersion = SdkVersion("test", "1.2.3") }) - } + mock().apply { whenever(this.options).thenAnswer { this@Fixture.options } } lateinit var tx: SentryTracer val actualDataSource = JDBCDataSource() - fun getSut(withRunningTransaction: Boolean = true, existingRow: Int? = null): DataSource { + fun getSut( + withRunningTransaction: Boolean = true, + existingRow: Int? = null, + enableDatabaseTransactionTracing: Boolean = false, + ): DataSource { + options = + SentryOptions().apply { + sdkVersion = SdkVersion("test", "1.2.3") + isEnableDatabaseTransactionTracing = enableDatabaseTransactionTracing + } tx = SentryTracer(TransactionContext("name", "op"), scopes) if (withRunningTransaction) { whenever(scopes.span).thenReturn(tx) @@ -146,7 +153,7 @@ class SentryJdbcEventListenerTest { Mockito.mockStatic(DatabaseUtils::class.java).use { utils -> var invocationCount = 0 utils - .`when` { DatabaseUtils.readFrom(any()) } + .`when` { DatabaseUtils.readFrom(any()) } .thenAnswer { invocationCount++ DatabaseDetails("a", "b") @@ -169,4 +176,196 @@ class SentryJdbcEventListenerTest { assertEquals(1, invocationCount) } } + + @Test + fun `creates span for commit when database transaction tracing is enabled`() { + val sut = fixture.getSut(enableDatabaseTransactionTracing = true) + + sut.connection.use { + it.autoCommit = false + it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() + it.commit() + } + + val commitSpans = fixture.tx.children.filter { it.operation == "db.sql.transaction.commit" } + assertEquals(1, commitSpans.size) + assertEquals(SpanStatus.OK, commitSpans[0].status) + assertEquals("auto.db.jdbc", commitSpans[0].spanContext.origin) + } + + @Test + fun `creates span for rollback when database transaction tracing is enabled`() { + val sut = fixture.getSut(enableDatabaseTransactionTracing = true) + + sut.connection.use { + it.autoCommit = false + it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() + it.rollback() + } + + val rollbackSpans = fixture.tx.children.filter { it.operation == "db.sql.transaction.rollback" } + assertEquals(1, rollbackSpans.size) + assertEquals(SpanStatus.OK, rollbackSpans[0].status) + assertEquals("auto.db.jdbc", rollbackSpans[0].spanContext.origin) + } + + @Test + fun `commit span has database details`() { + val sut = fixture.getSut(enableDatabaseTransactionTracing = true) + + sut.connection.use { + it.autoCommit = false + it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() + it.commit() + } + + val commitSpans = fixture.tx.children.filter { it.operation == "db.sql.transaction.commit" } + assertEquals(1, commitSpans.size) + assertEquals("hsqldb", commitSpans[0].data[DB_SYSTEM_KEY]) + assertEquals("testdb", commitSpans[0].data[DB_NAME_KEY]) + } + + @Test + fun `rollback span has database details`() { + val sut = fixture.getSut(enableDatabaseTransactionTracing = true) + + sut.connection.use { + it.autoCommit = false + it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() + it.rollback() + } + + val rollbackSpans = fixture.tx.children.filter { it.operation == "db.sql.transaction.rollback" } + assertEquals(1, rollbackSpans.size) + assertEquals("hsqldb", rollbackSpans[0].data[DB_SYSTEM_KEY]) + assertEquals("testdb", rollbackSpans[0].data[DB_NAME_KEY]) + } + + @Test + fun `does not create commit span when there is no running transaction`() { + val sut = + fixture.getSut(withRunningTransaction = false, enableDatabaseTransactionTracing = true) + + sut.connection.use { + it.autoCommit = false + it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() + it.commit() + } + + val commitSpans = fixture.tx.children.filter { it.operation == "db.sql.transaction.commit" } + assertTrue(commitSpans.isEmpty()) + } + + @Test + fun `does not create rollback span when there is no running transaction`() { + val sut = + fixture.getSut(withRunningTransaction = false, enableDatabaseTransactionTracing = true) + + sut.connection.use { + it.autoCommit = false + it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() + it.rollback() + } + + val rollbackSpans = fixture.tx.children.filter { it.operation == "db.sql.transaction.rollback" } + assertTrue(rollbackSpans.isEmpty()) + } + + @Test + fun `creates span for transaction begin when setAutoCommit false and database transaction tracing is enabled`() { + val sut = fixture.getSut(enableDatabaseTransactionTracing = true) + + sut.connection.use { + it.autoCommit = false + it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() + it.commit() + } + + val beginSpans = fixture.tx.children.filter { it.operation == "db.sql.transaction.begin" } + assertEquals(1, beginSpans.size) + assertEquals(SpanStatus.OK, beginSpans[0].status) + assertEquals("auto.db.jdbc", beginSpans[0].spanContext.origin) + } + + @Test + fun `transaction begin span has database details`() { + val sut = fixture.getSut(enableDatabaseTransactionTracing = true) + + sut.connection.use { + it.autoCommit = false + it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() + it.commit() + } + + val beginSpans = fixture.tx.children.filter { it.operation == "db.sql.transaction.begin" } + assertEquals(1, beginSpans.size) + assertEquals("hsqldb", beginSpans[0].data[DB_SYSTEM_KEY]) + assertEquals("testdb", beginSpans[0].data[DB_NAME_KEY]) + } + + @Test + fun `does not create begin span when already in manual commit mode`() { + val sut = fixture.getSut(enableDatabaseTransactionTracing = true) + + sut.connection.use { + it.autoCommit = false + it.autoCommit = false // setting again should not create another span + it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() + it.commit() + } + + val beginSpans = fixture.tx.children.filter { it.operation == "db.sql.transaction.begin" } + assertEquals(1, beginSpans.size) + } + + @Test + fun `does not create begin span when there is no running transaction`() { + val sut = + fixture.getSut(withRunningTransaction = false, enableDatabaseTransactionTracing = true) + + sut.connection.use { + it.autoCommit = false + it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() + it.commit() + } + + val beginSpans = fixture.tx.children.filter { it.operation == "db.sql.transaction.begin" } + assertTrue(beginSpans.isEmpty()) + } + + @Test + fun `does not create transaction spans when database transaction tracing is disabled`() { + val sut = fixture.getSut(enableDatabaseTransactionTracing = false) + + sut.connection.use { + it.autoCommit = false + it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() + it.commit() + } + + val beginSpans = fixture.tx.children.filter { it.operation == "db.sql.transaction.begin" } + val commitSpans = fixture.tx.children.filter { it.operation == "db.sql.transaction.commit" } + assertTrue(beginSpans.isEmpty()) + assertTrue(commitSpans.isEmpty()) + // Query spans should still be created + val querySpans = fixture.tx.children.filter { it.operation == "db.query" } + assertEquals(1, querySpans.size) + } + + @Test + fun `does not create rollback span when database transaction tracing is disabled`() { + val sut = fixture.getSut(enableDatabaseTransactionTracing = false) + + sut.connection.use { + it.autoCommit = false + it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() + it.rollback() + } + + val rollbackSpans = fixture.tx.children.filter { it.operation == "db.sql.transaction.rollback" } + assertTrue(rollbackSpans.isEmpty()) + // Query spans should still be created + val querySpans = fixture.tx.children.filter { it.operation == "db.query" } + assertEquals(1, querySpans.size) + } } diff --git a/sentry-jul/build.gradle.kts b/sentry-jul/build.gradle.kts index 13bee6418d6..2eec61eb171 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -34,25 +34,7 @@ dependencies { testImplementation(libs.slf4j.api) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } test { // used to test io.sentry.jul.SentryHandler systemProperty( diff --git a/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java b/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java index 812fdf184db..4442052dd41 100644 --- a/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java +++ b/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java @@ -22,6 +22,7 @@ import io.sentry.protocol.Message; import io.sentry.protocol.SdkVersion; import io.sentry.util.CollectionUtils; +import io.sentry.util.LoggerPropertiesUtil; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Date; @@ -160,6 +161,12 @@ protected void captureLog(@NotNull LogRecord loggingEvent) { attributes.add(SentryAttribute.stringAttribute("sentry.message.template", message)); } + final @Nullable Map mdcProperties = MDC.getMDCAdapter().getCopyOfContextMap(); + if (mdcProperties != null) { + final List contextTags = ScopesAdapter.getInstance().getOptions().getContextTags(); + LoggerPropertiesUtil.applyPropertiesToAttributes(attributes, contextTags, mdcProperties); + } + final @NotNull SentryLogParameters params = SentryLogParameters.create(attributes); params.setOrigin("auto.log.jul"); @@ -312,20 +319,7 @@ SentryEvent createEvent(final @NotNull LogRecord record) { // get tags from ScopesAdapter options to allow getting the correct tags if Sentry has been // initialized somewhere else final List contextTags = ScopesAdapter.getInstance().getOptions().getContextTags(); - if (!contextTags.isEmpty()) { - for (final String contextTag : contextTags) { - // if mdc tag is listed in SentryOptions, apply as event tag - if (mdcProperties.containsKey(contextTag)) { - event.setTag(contextTag, mdcProperties.get(contextTag)); - // remove from all tags applied to logging event - mdcProperties.remove(contextTag); - } - } - } - // put the rest of mdc tags in contexts - if (!mdcProperties.isEmpty()) { - event.getContexts().put("MDC", mdcProperties); - } + LoggerPropertiesUtil.applyPropertiesToEvent(event, contextTags, mdcProperties); } } event.setExtra(THREAD_ID, record.getThreadID()); diff --git a/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt b/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt index a002c986407..ab160faac40 100644 --- a/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt +++ b/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt @@ -7,6 +7,10 @@ import io.sentry.SentryLogLevel import io.sentry.SentryOptions import io.sentry.checkEvent import io.sentry.checkLogs +import io.sentry.logger.ILoggerBatchProcessorFactory +import io.sentry.logger.LoggerBatchProcessor +import io.sentry.test.ImmediateExecutorService +import io.sentry.test.applyTestOptions import io.sentry.test.initForTest import io.sentry.transport.ITransport import java.time.Instant @@ -44,6 +48,10 @@ class SentryHandlerTest { val options = SentryOptions() options.dsn = "http://key@localhost/proj" options.setTransportFactory { _, _ -> transport } + options.logs.loggerBatchProcessorFactory = ILoggerBatchProcessorFactory { options, client -> + LoggerBatchProcessor(options, client, ImmediateExecutorService()) + } + applyTestOptions(options) contextTags?.forEach { options.addContextTag(it) } logger = Logger.getLogger("jul.SentryHandlerTest") handler = SentryHandler(options, configureWithLogManager, true) @@ -415,7 +423,7 @@ class SentryHandlerTest { fixture = Fixture(minimumLevel = Level.FINEST) fixture.logger.finest("testing trace level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send( @@ -431,7 +439,7 @@ class SentryHandlerTest { fixture = Fixture(minimumLevel = Level.FINE) fixture.logger.fine("testing trace level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { event -> assertEquals(SentryLogLevel.DEBUG, event.items.first().level) }) @@ -442,7 +450,7 @@ class SentryHandlerTest { fixture = Fixture(minimumLevel = Level.CONFIG) fixture.logger.config("testing debug level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { event -> assertEquals(SentryLogLevel.DEBUG, event.items.first().level) }) @@ -453,7 +461,7 @@ class SentryHandlerTest { fixture = Fixture(minimumLevel = Level.INFO) fixture.logger.info("testing info level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { event -> assertEquals(SentryLogLevel.INFO, event.items.first().level) }) @@ -464,7 +472,7 @@ class SentryHandlerTest { fixture = Fixture(minimumLevel = Level.WARNING) fixture.logger.warning("testing warn level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { event -> assertEquals(SentryLogLevel.WARN, event.items.first().level) }) @@ -475,7 +483,7 @@ class SentryHandlerTest { fixture = Fixture(minimumLevel = Level.SEVERE) fixture.logger.severe("testing error level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { event -> assertEquals(SentryLogLevel.ERROR, event.items.first().level) }) @@ -555,4 +563,26 @@ class SentryHandlerTest { } ) } + + @Test + fun `sets properties from MDC as attributes on logs`() { + fixture = Fixture(minimumLevel = Level.INFO, contextTags = listOf("someTag")) + + MDC.put("someTag", "someValue") + MDC.put("otherTag", "otherValue") + fixture.logger.info("testing MDC properties in logs") + + Sentry.flush(1000) + + verify(fixture.transport) + .send( + checkLogs { logs -> + val log = logs.items.first() + assertEquals("testing MDC properties in logs", log.body) + val attributes = log.attributes!! + assertEquals("someValue", attributes["mdc.someTag"]?.value) + assertNull(attributes["otherTag"]) + } + ) + } } diff --git a/sentry-kafka/README.md b/sentry-kafka/README.md new file mode 100644 index 00000000000..1b1b69238e5 --- /dev/null +++ b/sentry-kafka/README.md @@ -0,0 +1,5 @@ +# sentry-kafka + +This module provides Kafka-native queue instrumentation for applications using `kafka-clients` directly. + +Spring users should use the Sentry Spring (Boot) SDKs, which provide higher-fidelity consumer instrumentation via Spring Kafka hooks. diff --git a/sentry-kafka/api/sentry-kafka.api b/sentry-kafka/api/sentry-kafka.api new file mode 100644 index 00000000000..00649245845 --- /dev/null +++ b/sentry-kafka/api/sentry-kafka.api @@ -0,0 +1,19 @@ +public final class io/sentry/kafka/BuildConfig { + public static final field SENTRY_KAFKA_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/kafka/SentryKafkaConsumerTracing { + public static final field TRACE_ORIGIN Ljava/lang/String; + public static fun withTracing (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Ljava/lang/Runnable;)V + public static fun withTracing (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Ljava/util/concurrent/Callable;)Ljava/lang/Object; +} + +public final class io/sentry/kafka/SentryKafkaProducer { + public static final field SENTRY_ENQUEUED_TIME_HEADER Ljava/lang/String; + public static final field TRACE_ORIGIN Ljava/lang/String; + public static fun wrap (Lorg/apache/kafka/clients/producer/Producer;)Lorg/apache/kafka/clients/producer/Producer; + public static fun wrap (Lorg/apache/kafka/clients/producer/Producer;Lio/sentry/IScopes;)Lorg/apache/kafka/clients/producer/Producer; + public static fun wrap (Lorg/apache/kafka/clients/producer/Producer;Lio/sentry/IScopes;Ljava/lang/String;)Lorg/apache/kafka/clients/producer/Producer; +} + diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts new file mode 100644 index 00000000000..0d543bad270 --- /dev/null +++ b/sentry-kafka/build.gradle.kts @@ -0,0 +1,62 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + +dependencies { + api(projects.sentry) + compileOnly(libs.kafka.clients) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.kafka.clients) +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.kafka") + buildConfigField("String", "SENTRY_KAFKA_SDK_NAME", "\"${Config.Sentry.SENTRY_KAFKA_SDK_NAME}\"") + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_KAFKA_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-kafka", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaConsumerTracing.java b/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaConsumerTracing.java new file mode 100644 index 00000000000..dbce760de99 --- /dev/null +++ b/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaConsumerTracing.java @@ -0,0 +1,280 @@ +package io.sentry.kafka; + +import io.sentry.BaggageHeader; +import io.sentry.DateUtils; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ITransaction; +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; +import io.sentry.SpanStatus; +import io.sentry.TransactionContext; +import io.sentry.TransactionOptions; +import io.sentry.util.SpanUtils; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Header; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Helper methods for instrumenting raw Kafka consumer record processing. */ +@ApiStatus.Experimental +public final class SentryKafkaConsumerTracing { + + public static final @NotNull String TRACE_ORIGIN = "manual.queue.kafka.consumer"; + + private static final @NotNull String CREATOR = "SentryKafkaConsumerTracing"; + private static final @NotNull String DELIVERY_ATTEMPT_HEADER = "kafka_deliveryAttempt"; + private static final @NotNull String MESSAGE_ID_HEADER = "messaging.message.id"; + + private final @NotNull IScopes scopes; + + SentryKafkaConsumerTracing(final @NotNull IScopes scopes) { + this.scopes = scopes; + } + + /** + * Runs the provided {@link Callable} with a Kafka consumer processing transaction for the given + * record. + * + * @param record the Kafka record being processed + * @param callable the processing callback + * @return the return value of the callback + * @param the Kafka record key type + * @param the Kafka record value type + * @param the callback return type + */ + public static U withTracing( + final @NotNull ConsumerRecord record, final @NotNull Callable callable) + throws Exception { + return new SentryKafkaConsumerTracing(ScopesAdapter.getInstance()) + .withTracingImpl(record, callable); + } + + /** + * Runs the provided {@link Runnable} with a Kafka consumer processing transaction for the given + * record. + * + * @param record the Kafka record being processed + * @param runnable the processing callback + * @param the Kafka record key type + * @param the Kafka record value type + */ + public static void withTracing( + final @NotNull ConsumerRecord record, final @NotNull Runnable runnable) { + new SentryKafkaConsumerTracing(ScopesAdapter.getInstance()).withTracingImpl(record, runnable); + } + + U withTracingImpl( + final @NotNull ConsumerRecord record, final @NotNull Callable callable) + throws Exception { + if (!scopes.getOptions().isEnableQueueTracing() || isIgnored()) { + return callable.call(); + } + + final @NotNull IScopes forkedScopes; + final @NotNull ISentryLifecycleToken lifecycleToken; + try { + forkedScopes = scopes.forkedRootScopes(CREATOR); + lifecycleToken = forkedScopes.makeCurrent(); + } catch (Throwable t) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to fork scopes for Kafka consumer tracing.", t); + return callable.call(); + } + + try (final @NotNull ISentryLifecycleToken ignored = lifecycleToken) { + final @Nullable ITransaction transaction = startTransaction(forkedScopes, record); + boolean didError = false; + @Nullable Throwable callbackThrowable = null; + + try { + return callable.call(); + } catch (Throwable t) { + didError = true; + callbackThrowable = t; + throw t; + } finally { + finishTransaction( + transaction, didError ? SpanStatus.INTERNAL_ERROR : SpanStatus.OK, callbackThrowable); + } + } + } + + void withTracingImpl( + final @NotNull ConsumerRecord record, final @NotNull Runnable runnable) { + try { + withTracingImpl( + record, + () -> { + runnable.run(); + return null; + }); + } catch (Throwable t) { + throwUnchecked(t); + } + } + + @SuppressWarnings("unchecked") + private static void throwUnchecked(final @NotNull Throwable throwable) + throws T { + throw (T) throwable; + } + + private boolean isIgnored() { + return SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), TRACE_ORIGIN); + } + + private @Nullable ITransaction startTransaction( + final @NotNull IScopes forkedScopes, final @NotNull ConsumerRecord record) { + try { + final @Nullable TransactionContext continued = continueTrace(forkedScopes, record); + if (!forkedScopes.getOptions().isTracingEnabled()) { + return null; + } + + final @NotNull TransactionContext txContext = + continued != null ? continued : new TransactionContext(record.topic(), "queue.process"); + txContext.setName(record.topic()); + txContext.setOperation("queue.process"); + + final @NotNull TransactionOptions txOptions = new TransactionOptions(); + txOptions.setOrigin(TRACE_ORIGIN); + txOptions.setBindToScope(true); + + final @NotNull ITransaction transaction = forkedScopes.startTransaction(txContext, txOptions); + if (transaction.isNoOp()) { + return null; + } + + transaction.setData(SpanDataConvention.MESSAGING_SYSTEM, "kafka"); + transaction.setData(SpanDataConvention.MESSAGING_DESTINATION_NAME, record.topic()); + + final @Nullable String messageId = headerValue(record, MESSAGE_ID_HEADER); + if (messageId != null) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_ID, messageId); + } + + final int bodySize = record.serializedValueSize(); + if (bodySize >= 0) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE, bodySize); + } + + final @Nullable Integer retryCount = retryCount(record); + if (retryCount != null) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_RETRY_COUNT, retryCount); + } + + final @Nullable Long receiveLatency = receiveLatency(record); + if (receiveLatency != null) { + transaction.setData(SpanDataConvention.MESSAGING_MESSAGE_RECEIVE_LATENCY, receiveLatency); + } + + return transaction; + } catch (Throwable t) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to start Kafka consumer tracing transaction.", t); + return null; + } + } + + private void finishTransaction( + final @Nullable ITransaction transaction, + final @NotNull SpanStatus status, + final @Nullable Throwable throwable) { + if (transaction == null || transaction.isNoOp()) { + return; + } + + try { + transaction.setStatus(status); + if (throwable != null) { + transaction.setThrowable(throwable); + } + transaction.finish(); + } catch (Throwable t) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to finish Kafka consumer tracing transaction.", t); + } + } + + private @Nullable TransactionContext continueTrace( + final @NotNull IScopes forkedScopes, final @NotNull ConsumerRecord record) { + final @Nullable String sentryTrace = headerValue(record, SentryTraceHeader.SENTRY_TRACE_HEADER); + final @Nullable List baggageHeaders = + headerValues(record, BaggageHeader.BAGGAGE_HEADER); + return forkedScopes.continueTrace(sentryTrace, baggageHeaders); + } + + private @Nullable Integer retryCount(final @NotNull ConsumerRecord record) { + final @Nullable Header header = record.headers().lastHeader(DELIVERY_ATTEMPT_HEADER); + if (header == null) { + return null; + } + + final byte[] value = header.value(); + if (value == null || value.length != Integer.BYTES) { + return null; + } + + final int attempt = ByteBuffer.wrap(value).getInt(); + if (attempt <= 0) { + return null; + } + + return attempt - 1; + } + + private @Nullable Long receiveLatency(final @NotNull ConsumerRecord record) { + final @Nullable String enqueuedTimeStr = + headerValue(record, SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER); + if (enqueuedTimeStr == null) { + return null; + } + + try { + final double enqueuedTimeSeconds = Double.parseDouble(enqueuedTimeStr); + final double nowSeconds = DateUtils.millisToSeconds(System.currentTimeMillis()); + final long latencyMs = (long) ((nowSeconds - enqueuedTimeSeconds) * 1000); + return latencyMs >= 0 ? latencyMs : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + private @Nullable String headerValue( + final @NotNull ConsumerRecord record, final @NotNull String headerName) { + final @Nullable Header header = record.headers().lastHeader(headerName); + if (header == null || header.value() == null) { + return null; + } + return new String(header.value(), StandardCharsets.UTF_8); + } + + private @Nullable List headerValues( + final @NotNull ConsumerRecord record, final @NotNull String headerName) { + @Nullable List values = null; + for (final @NotNull Header header : record.headers().headers(headerName)) { + if (header.value() != null) { + if (values == null) { + values = new ArrayList<>(); + } + values.add(new String(header.value(), StandardCharsets.UTF_8)); + } + } + return values; + } +} diff --git a/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaProducer.java b/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaProducer.java new file mode 100644 index 00000000000..bcc538e339c --- /dev/null +++ b/sentry-kafka/src/main/java/io/sentry/kafka/SentryKafkaProducer.java @@ -0,0 +1,265 @@ +package io.sentry.kafka; + +import io.sentry.BaggageHeader; +import io.sentry.DateUtils; +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import io.sentry.util.SpanUtils; +import io.sentry.util.TracingUtils; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.apache.kafka.clients.producer.Callback; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.Headers; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Wraps a Kafka {@link Producer} to record a {@code queue.publish} span around each {@code send} + * and to inject Sentry trace propagation headers into the produced record. + * + *

    For raw Kafka usage: + * + *

    {@code
    + * Producer producer =
    + *     SentryKafkaProducer.wrap(new KafkaProducer<>(props));
    + * }
    + * + *

    For Spring Kafka, the {@code SentryKafkaProducerBeanPostProcessor} installs this wrapper + * automatically. + */ +@ApiStatus.Experimental +public final class SentryKafkaProducer { + + public static final @NotNull String TRACE_ORIGIN = "auto.queue.kafka.producer"; + public static final @NotNull String SENTRY_ENQUEUED_TIME_HEADER = "sentry-task-enqueued-time"; + + private SentryKafkaProducer() {} + + /** + * Wraps the given producer with Sentry instrumentation. + * + * @param delegate the Kafka producer to wrap + * @return an instrumented producer that records {@code queue.publish} spans + * @param the Kafka record key type + * @param the Kafka record value type + */ + public static @NotNull Producer wrap(final @NotNull Producer delegate) { + return wrap(delegate, ScopesAdapter.getInstance(), TRACE_ORIGIN); + } + + /** + * Wraps the given producer with Sentry instrumentation using the provided scopes. + * + * @param delegate the Kafka producer to wrap + * @param scopes the Sentry scopes to use for span creation and header injection + * @return an instrumented producer that records {@code queue.publish} spans + * @param the Kafka record key type + * @param the Kafka record value type + */ + public static @NotNull Producer wrap( + final @NotNull Producer delegate, final @NotNull IScopes scopes) { + return wrap(delegate, scopes, TRACE_ORIGIN); + } + + /** + * Wraps the given producer with Sentry instrumentation. + * + * @param delegate the Kafka producer to wrap + * @param scopes the Sentry scopes to use for span creation and header injection + * @param traceOrigin the trace origin to set on created spans + * @return an instrumented producer that records {@code queue.publish} spans + * @param the Kafka record key type + * @param the Kafka record value type + */ + @SuppressWarnings("unchecked") + public static @NotNull Producer wrap( + final @NotNull Producer delegate, + final @NotNull IScopes scopes, + final @NotNull String traceOrigin) { + return (Producer) + Proxy.newProxyInstance( + delegate.getClass().getClassLoader(), + new Class[] {Producer.class}, + new SentryProducerHandler<>(delegate, scopes, traceOrigin)); + } + + static final class SentryProducerHandler implements InvocationHandler { + + final @NotNull Producer delegate; + private final @NotNull IScopes scopes; + private final @NotNull String traceOrigin; + + SentryProducerHandler( + final @NotNull Producer delegate, + final @NotNull IScopes scopes, + final @NotNull String traceOrigin) { + this.delegate = delegate; + this.scopes = scopes; + this.traceOrigin = traceOrigin; + } + + @Override + @SuppressWarnings("unchecked") + public @Nullable Object invoke( + final @NotNull Object proxy, final @NotNull Method method, final @Nullable Object[] args) + throws Throwable { + if ("send".equals(method.getName()) && args != null) { + if (args.length == 1) { + return instrumentedSend((ProducerRecord) args[0], null); + } else if (args.length == 2) { + return instrumentedSend((ProducerRecord) args[0], (Callback) args[1]); + } + } + + if ("equals".equals(method.getName()) + && args != null + && args.length == 1 + && proxy == args[0]) { + return true; + } + + if ("toString".equals(method.getName()) && (args == null || args.length == 0)) { + return "SentryKafkaProducer[delegate=" + delegate + "]"; + } + + try { + return method.invoke(delegate, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + @SuppressWarnings("unchecked") + private @NotNull Object instrumentedSend( + final @NotNull ProducerRecord record, final @Nullable Callback callback) { + if (!scopes.getOptions().isEnableQueueTracing() || isIgnored()) { + return delegate.send(record, callback); + } + + final @Nullable ISpan activeSpan = scopes.getSpan(); + if (activeSpan == null || activeSpan.isNoOp()) { + maybeInjectHeaders(record.headers(), null); + return delegate.send(record, callback); + } + + final @NotNull SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(traceOrigin); + final @NotNull ISpan span = + activeSpan.startChild("queue.publish", record.topic(), spanOptions); + + span.setData(SpanDataConvention.MESSAGING_SYSTEM, "kafka"); + span.setData(SpanDataConvention.MESSAGING_DESTINATION_NAME, record.topic()); + maybeInjectHeaders(record.headers(), span); + + try { + return delegate.send(record, wrapCallback(callback, span)); + } catch (Throwable t) { + finishWithError(span, t); + throw t; + } + } + + private @NotNull Callback wrapCallback( + final @Nullable Callback userCallback, final @NotNull ISpan span) { + return (metadata, exception) -> { + try { + if (exception != null) { + span.setThrowable(exception); + span.setStatus(SpanStatus.INTERNAL_ERROR); + } else { + span.setStatus(SpanStatus.OK); + } + } catch (Throwable t) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to set status on Kafka producer span.", t); + } finally { + try { + span.finish(); + } finally { + if (userCallback != null) { + userCallback.onCompletion(metadata, exception); + } + } + } + }; + } + + private void finishWithError(final @NotNull ISpan span, final @NotNull Throwable t) { + span.setThrowable(t); + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.finish(); + } + + private boolean isIgnored() { + return SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), traceOrigin); + } + + private void maybeInjectHeaders(final @NotNull Headers headers, final @Nullable ISpan span) { + try { + final @Nullable List existingBaggageHeaders = + readHeaderValues(headers, BaggageHeader.BAGGAGE_HEADER); + final @Nullable TracingUtils.TracingHeaders tracingHeaders = + TracingUtils.trace(scopes, existingBaggageHeaders, span); + if (tracingHeaders != null) { + final @NotNull SentryTraceHeader sentryTraceHeader = + tracingHeaders.getSentryTraceHeader(); + headers.remove(sentryTraceHeader.getName()); + headers.add( + sentryTraceHeader.getName(), + sentryTraceHeader.getValue().getBytes(StandardCharsets.UTF_8)); + + final @Nullable BaggageHeader baggageHeader = tracingHeaders.getBaggageHeader(); + if (baggageHeader != null) { + headers.remove(baggageHeader.getName()); + headers.add( + baggageHeader.getName(), baggageHeader.getValue().getBytes(StandardCharsets.UTF_8)); + } + } + + headers.remove(SENTRY_ENQUEUED_TIME_HEADER); + headers.add( + SENTRY_ENQUEUED_TIME_HEADER, + DateUtils.doubleToBigDecimal(DateUtils.millisToSeconds(System.currentTimeMillis())) + .toString() + .getBytes(StandardCharsets.UTF_8)); + } catch (Throwable t) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to inject Sentry headers into Kafka record.", t); + } + } + + private static @Nullable List readHeaderValues( + final @NotNull Headers headers, final @NotNull String name) { + @Nullable List values = null; + for (final @NotNull Header header : headers.headers(name)) { + final byte @Nullable [] value = header.value(); + if (value != null) { + if (values == null) { + values = new ArrayList<>(); + } + values.add(new String(value, StandardCharsets.UTF_8)); + } + } + return values; + } + } +} diff --git a/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaConsumerTracingTest.kt b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaConsumerTracingTest.kt new file mode 100644 index 00000000000..5529e42c715 --- /dev/null +++ b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaConsumerTracingTest.kt @@ -0,0 +1,254 @@ +package io.sentry.kafka + +import io.sentry.BaggageHeader +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.ITransaction +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.util.Optional +import java.util.concurrent.Callable +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.common.header.internals.RecordHeaders +import org.apache.kafka.common.record.TimestampType +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.check +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryKafkaConsumerTracingTest { + + private lateinit var scopes: IScopes + private lateinit var forkedScopes: IScopes + private lateinit var options: SentryOptions + private lateinit var lifecycleToken: ISentryLifecycleToken + private lateinit var transaction: ITransaction + private lateinit var tracing: SentryKafkaConsumerTracing + + @BeforeTest + fun setup() { + scopes = mock() + forkedScopes = mock() + lifecycleToken = mock() + transaction = mock() + tracing = SentryKafkaConsumerTracing(scopes) + + options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + isEnableQueueTracing = true + tracesSampleRate = 1.0 + } + + whenever(scopes.options).thenReturn(options) + whenever(scopes.forkedRootScopes(any())).thenReturn(forkedScopes) + whenever(forkedScopes.options).thenReturn(options) + whenever(forkedScopes.makeCurrent()).thenReturn(lifecycleToken) + whenever(forkedScopes.startTransaction(any(), any())) + .thenReturn(transaction) + whenever(transaction.isNoOp).thenReturn(false) + } + + @Test + fun `withTracing creates queue process transaction with record metadata`() { + val sentryTraceValue = "2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1" + val baggageValue = "sentry-sample_rate=1" + val record = + createRecord( + sentryTrace = sentryTraceValue, + baggage = baggageValue, + messageId = "message-123", + deliveryAttempt = 3, + enqueuedTime = (System.currentTimeMillis() / 1000.0 - 1.0).toString(), + serializedValueSize = 5, + ) + + val txContextCaptor = argumentCaptor() + val txOptionsCaptor = argumentCaptor() + + val result = tracing.withTracingImpl(record, Callable { "done" }) + + assertEquals("done", result) + verify(scopes).forkedRootScopes("SentryKafkaConsumerTracing") + verify(forkedScopes).makeCurrent() + verify(forkedScopes).continueTrace(eq(sentryTraceValue), eq(listOf(baggageValue))) + verify(forkedScopes).startTransaction(txContextCaptor.capture(), txOptionsCaptor.capture()) + + assertEquals("my-topic", txContextCaptor.firstValue.name) + assertEquals("queue.process", txContextCaptor.firstValue.operation) + assertEquals(SentryKafkaConsumerTracing.TRACE_ORIGIN, txOptionsCaptor.firstValue.origin) + assertTrue(txOptionsCaptor.firstValue.isBindToScope) + + verify(transaction).setData(SpanDataConvention.MESSAGING_SYSTEM, "kafka") + verify(transaction).setData(SpanDataConvention.MESSAGING_DESTINATION_NAME, "my-topic") + verify(transaction).setData(SpanDataConvention.MESSAGING_MESSAGE_ID, "message-123") + verify(transaction).setData(SpanDataConvention.MESSAGING_MESSAGE_BODY_SIZE, 5) + verify(transaction).setData(SpanDataConvention.MESSAGING_MESSAGE_RETRY_COUNT, 2) + verify(transaction) + .setData( + eq(SpanDataConvention.MESSAGING_MESSAGE_RECEIVE_LATENCY), + check { assertTrue(it >= 0) }, + ) + verify(transaction).setStatus(SpanStatus.OK) + verify(transaction).finish() + verify(lifecycleToken).close() + } + + @Test + fun `withTracing passes all baggage headers to continueTrace`() { + val sentryTraceValue = "2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1" + val record = + createRecord( + sentryTrace = sentryTraceValue, + baggageHeaders = listOf("third=party", "sentry-sample_rate=1"), + ) + + tracing.withTracingImpl(record, Callable { "done" }) + + verify(forkedScopes) + .continueTrace(eq(sentryTraceValue), eq(listOf("third=party", "sentry-sample_rate=1"))) + } + + @Test + fun `withTracing skips scope forking when queue tracing is disabled`() { + options.isEnableQueueTracing = false + val record = createRecord() + + val result = tracing.withTracingImpl(record, Callable { "done" }) + + assertEquals("done", result) + verify(scopes, never()).forkedRootScopes(any()) + } + + @Test + fun `withTracing skips scope forking when origin is ignored`() { + options.setIgnoredSpanOrigins(listOf(SentryKafkaConsumerTracing.TRACE_ORIGIN)) + val record = createRecord() + + val result = tracing.withTracingImpl(record, Callable { "done" }) + + assertEquals("done", result) + verify(scopes, never()).forkedRootScopes(any()) + } + + @Test + fun `withTracing marks transaction as error when callback throws`() { + val record = createRecord() + val exception = RuntimeException("boom") + + val thrown = + assertFailsWith { + tracing.withTracingImpl(record, Callable { throw exception }) + } + + assertEquals(exception, thrown) + verify(transaction).setStatus(SpanStatus.INTERNAL_ERROR) + verify(transaction).setThrowable(exception) + verify(transaction).finish() + verify(lifecycleToken).close() + } + + @Test + fun `withTracing falls back to direct callback execution when instrumentation setup fails`() { + whenever(scopes.forkedRootScopes(any())) + .thenThrow(RuntimeException("broken instrumentation")) + val record = createRecord() + + val result = tracing.withTracingImpl(record, Callable { "done" }) + + assertEquals("done", result) + verify(forkedScopes, never()).makeCurrent() + verify(transaction, never()).finish() + } + + @Test + fun `withTracing runnable overload executes callback`() { + val record = createRecord() + val didRun = AtomicBoolean(false) + + tracing.withTracingImpl(record, Runnable { didRun.set(true) }) + + assertTrue(didRun.get()) + verify(transaction).setStatus(SpanStatus.OK) + verify(transaction).finish() + } + + @Test + fun `withTracing runnable overload preserves original throwable`() { + val record = createRecord() + val exception = IOException("boom") + + val thrown = + assertFailsWith { tracing.withTracingImpl(record, Runnable { throw exception }) } + + assertEquals(exception, thrown) + verify(transaction).setStatus(SpanStatus.INTERNAL_ERROR) + verify(transaction).setThrowable(exception) + verify(transaction).finish() + } + + private fun createRecord( + topic: String = "my-topic", + sentryTrace: String? = null, + baggage: String? = null, + baggageHeaders: List? = null, + messageId: String? = null, + deliveryAttempt: Int? = null, + enqueuedTime: String? = null, + serializedValueSize: Int = -1, + ): ConsumerRecord { + val headers = RecordHeaders() + sentryTrace?.let { + headers.add(SentryTraceHeader.SENTRY_TRACE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + baggage?.let { + headers.add(BaggageHeader.BAGGAGE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + baggageHeaders?.forEach { + headers.add(BaggageHeader.BAGGAGE_HEADER, it.toByteArray(StandardCharsets.UTF_8)) + } + messageId?.let { + headers.add(SpanDataConvention.MESSAGING_MESSAGE_ID, it.toByteArray(StandardCharsets.UTF_8)) + } + deliveryAttempt?.let { + headers.add("kafka_deliveryAttempt", ByteBuffer.allocate(Int.SIZE_BYTES).putInt(it).array()) + } + enqueuedTime?.let { + headers.add( + SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER, + it.toByteArray(StandardCharsets.UTF_8), + ) + } + + return ConsumerRecord( + topic, + 0, + 0L, + System.currentTimeMillis(), + TimestampType.CREATE_TIME, + 3, + serializedValueSize, + "key", + "value", + headers, + Optional.empty(), + ) + } +} diff --git a/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaProducerTest.kt b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaProducerTest.kt new file mode 100644 index 00000000000..a4ba5254c36 --- /dev/null +++ b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaProducerTest.kt @@ -0,0 +1,375 @@ +package io.sentry.kafka + +import io.sentry.BaggageHeader +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.ISpan +import io.sentry.NoOpSpan +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer +import io.sentry.SpanOptions +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.test.initForTest +import java.nio.charset.StandardCharsets +import java.util.concurrent.CompletableFuture +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.apache.kafka.clients.producer.Callback +import org.apache.kafka.clients.producer.Producer +import org.apache.kafka.clients.producer.ProducerRecord +import org.apache.kafka.clients.producer.RecordMetadata +import org.apache.kafka.common.TopicPartition +import org.apache.kafka.common.header.Header +import org.apache.kafka.common.header.Headers +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryKafkaProducerTest { + + private lateinit var scopes: IScopes + private lateinit var options: SentryOptions + private lateinit var delegate: Producer + + @BeforeTest + fun setup() { + initForTest { + it.dsn = "https://key@sentry.io/proj" + it.isEnableQueueTracing = true + it.tracesSampleRate = 1.0 + } + scopes = mock() + options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + isEnableQueueTracing = true + } + whenever(scopes.options).thenReturn(options) + doAnswer { (it.arguments[0] as ScopeCallback).run(Scope(options)) } + .whenever(scopes) + .configureScope(any()) + delegate = mock() + whenever(delegate.send(any(), any())).thenReturn(CompletableFuture.completedFuture(null)) + } + + @AfterTest + fun teardown() { + Sentry.close() + } + + @Test + fun `creates queue publish span and injects headers`() { + val tx = createTransaction() + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("queue.publish", span.operation) + assertEquals("my-topic", span.description) + assertEquals("kafka", span.data["messaging.system"]) + assertEquals("my-topic", span.data["messaging.destination.name"]) + assertEquals(SentryKafkaProducer.TRACE_ORIGIN, span.spanContext.origin) + + val sentryTraceHeader = record.headers().lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER) + assertNotNull(sentryTraceHeader) + + val enqueuedTimeHeader = + record.headers().lastHeader(SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER) + assertNotNull(enqueuedTimeHeader) + val enqueuedTimeRaw = String(enqueuedTimeHeader.value(), StandardCharsets.UTF_8) + // Cross-SDK consumers (e.g. sentry-python) parse this as a plain decimal — must not use + // scientific notation. + assertFalse(enqueuedTimeRaw.contains('E') || enqueuedTimeRaw.contains('e')) + assertTrue(enqueuedTimeRaw.matches(Regex("""^\d+\.\d{6}$"""))) + } + + @Test + fun `delegates send and does not finish span synchronously`() { + val tx = createTransaction() + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + verify(delegate).send(eq(record), any()) + val span = tx.spans.first() + assertFalse(span.isFinished, "span should be open until callback fires") + } + + @Test + fun `finishes span as OK when broker ack callback succeeds`() { + val tx = createTransaction() + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + val captor = argumentCaptor() + verify(delegate).send(eq(record), captor.capture()) + val metadata = RecordMetadata(TopicPartition("my-topic", 0), 0L, 0, 0L, 0, 0) + captor.firstValue.onCompletion(metadata, null) + + val span = tx.spans.first() + assertTrue(span.isFinished) + assertEquals(SpanStatus.OK, span.status) + } + + @Test + fun `finishes span as INTERNAL_ERROR when broker ack callback fails`() { + val tx = createTransaction() + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + val exception = RuntimeException("boom") + + producer.send(record) + + val captor = argumentCaptor() + verify(delegate).send(eq(record), captor.capture()) + captor.firstValue.onCompletion(null, exception) + + val span = tx.spans.first() + assertTrue(span.isFinished) + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertSame(exception, span.throwable) + } + + @Test + fun `forwards user callback after finishing span`() { + createTransaction() + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + val userCallback = mock() + + producer.send(record, userCallback) + + val captor = argumentCaptor() + verify(delegate).send(eq(record), captor.capture()) + val metadata = RecordMetadata(TopicPartition("my-topic", 0), 0L, 0, 0L, 0, 0) + captor.firstValue.onCompletion(metadata, null) + + verify(userCallback).onCompletion(metadata, null) + } + + @Test + fun `finishes span with error when delegate send throws synchronously`() { + val tx = createTransaction() + val exception = RuntimeException("kaboom") + whenever(delegate.send(any(), any())).thenThrow(exception) + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + val thrown = runCatching { producer.send(record) }.exceptionOrNull() + + assertSame(exception, thrown) + val span = tx.spans.first() + assertTrue(span.isFinished) + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertSame(exception, span.throwable) + } + + @Test + fun `delegates send without span when queue tracing is disabled`() { + createTransaction() + options.isEnableQueueTracing = false + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + verify(delegate).send(eq(record), isNull()) + } + + @Test + fun `delegates send without span when trace origin is ignored`() { + val tx = createTransaction() + options.setIgnoredSpanOrigins(listOf(SentryKafkaProducer.TRACE_ORIGIN)) + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + assertEquals(0, tx.spans.size) + verify(delegate).send(eq(record), isNull()) + assertEquals(null, record.headers().lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) + } + + @Test + fun `injects headers but creates no span when no active span`() { + whenever(scopes.span).thenReturn(null) + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + verify(delegate).send(eq(record), isNull()) + // Headers should still be injected from PropagationContext + assertNotNull(record.headers().lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) + assertNotNull(record.headers().lastHeader(BaggageHeader.BAGGAGE_HEADER)) + assertNotNull(record.headers().lastHeader(SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER)) + } + + @Test + fun `injects headers but creates no span when active span is no-op`() { + whenever(scopes.span).thenReturn(NoOpSpan.getInstance()) + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + verify(delegate).send(eq(record), isNull()) + // Headers should still be injected from PropagationContext + assertNotNull(record.headers().lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) + assertNotNull(record.headers().lastHeader(BaggageHeader.BAGGAGE_HEADER)) + assertNotNull(record.headers().lastHeader(SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER)) + } + + @Test + fun `preserves pre-existing third-party baggage header entries`() { + createTransaction() + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + record + .headers() + .add( + BaggageHeader.BAGGAGE_HEADER, + "othervendor=someValue,another=thing".toByteArray(StandardCharsets.UTF_8), + ) + + producer.send(record) + + val baggageHeaders = record.headers().headers(BaggageHeader.BAGGAGE_HEADER).toList() + assertEquals(1, baggageHeaders.size) + val baggageValue = String(baggageHeaders.first().value(), StandardCharsets.UTF_8) + assertTrue(baggageValue.contains("othervendor=someValue")) + assertTrue(baggageValue.contains("another=thing")) + assertTrue(baggageValue.contains("sentry-")) + } + + @Test + fun `header injection failure does not prevent send`() { + val activeSpan = mock() + val span = mock() + val headers = mock() + val record = mock>() + whenever(scopes.span).thenReturn(activeSpan) + whenever(activeSpan.startChild(eq("queue.publish"), eq("my-topic"), any())) + .thenReturn(span) + whenever(span.isNoOp).thenReturn(false) + whenever(span.isFinished).thenReturn(false) + whenever(span.toSentryTrace()) + .thenReturn(SentryTraceHeader("2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1")) + whenever(span.toBaggageHeader(null)).thenReturn(null) + whenever(record.topic()).thenReturn("my-topic") + whenever(record.headers()).thenReturn(headers) + whenever(headers.headers(BaggageHeader.BAGGAGE_HEADER)).thenReturn(emptyList

    ()) + whenever(headers.remove(SentryTraceHeader.SENTRY_TRACE_HEADER)) + .thenThrow(RuntimeException("boom")) + + val producer = SentryKafkaProducer.wrap(delegate, scopes) + producer.send(record) + + // Header injection failed silently; send still proceeds with wrapped callback for span + // lifecycle. + verify(delegate).send(eq(record), any()) + } + + @Test + fun `delegates non-send methods to underlying producer`() { + val producer = SentryKafkaProducer.wrap(delegate, scopes) + + producer.flush() + producer.partitionsFor("my-topic") + producer.metrics() + producer.close() + + verify(delegate).flush() + verify(delegate).partitionsFor("my-topic") + verify(delegate).metrics() + verify(delegate).close() + } + + @Test + fun `default wrap uses current scopes`() { + val transaction = Sentry.startTransaction("tx", "op") + val record = ProducerRecord("my-topic", "key", "value") + + try { + val token: ISentryLifecycleToken = transaction.makeCurrent() + try { + val producer = SentryKafkaProducer.wrap(delegate) + producer.send(record) + } finally { + token.close() + } + } finally { + transaction.finish() + } + + assertNotNull(record.headers().lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) + assertNotNull(record.headers().lastHeader(SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER)) + verify(delegate).send(eq(record), any()) + } + + @Test + fun `wraps callback even when child span is no-op`() { + val tx = createTransaction() + // Set max spans to 0 so the child span is no-op (over limit) + options.maxSpans = 0 + val producer = SentryKafkaProducer.wrap(delegate, scopes) + val record = ProducerRecord("my-topic", "key", "value") + + producer.send(record) + + // Callback is still wrapped (no-op span finish is harmless) + verify(delegate).send(eq(record), any()) + // Headers should still be injected from PropagationContext + assertNotNull(record.headers().lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) + assertNotNull(record.headers().lastHeader(BaggageHeader.BAGGAGE_HEADER)) + assertNotNull(record.headers().lastHeader(SentryKafkaProducer.SENTRY_ENQUEUED_TIME_HEADER)) + } + + @Test + fun `wrapped producer equals itself`() { + val producer = SentryKafkaProducer.wrap(delegate, scopes) + + assertTrue(producer.equals(producer)) + } + + @Test + fun `wrapped producer keeps delegate hashCode`() { + val producer = SentryKafkaProducer.wrap(delegate, scopes) + + assertEquals(delegate.hashCode(), producer.hashCode()) + } + + @Test + fun `toString includes delegate`() { + val producer = SentryKafkaProducer.wrap(delegate, scopes) + assertTrue(producer.toString().startsWith("SentryKafkaProducer[delegate=")) + } + + private fun createTransaction(): SentryTracer { + val tx = SentryTracer(TransactionContext("tx", "op"), scopes) + whenever(scopes.span).thenReturn(tx) + return tx + } +} diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index 55aca007130..101761b2a82 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -5,11 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -33,31 +32,6 @@ dependencies { testImplementation(libs.kotlinx.coroutines) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.kotlin) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") -} - -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } } tasks.withType().configureEach { diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index 2965e81ebd3..fefcdbfebaf 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -4,12 +4,11 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` alias(libs.plugins.kotlin.jvm) - jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -40,31 +39,6 @@ dependencies { testImplementation(libs.ktor.client.core) testImplementation(libs.ktor.client.java) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") -} - -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } } buildConfig { diff --git a/sentry-launchdarkly-android/api/sentry-launchdarkly-android.api b/sentry-launchdarkly-android/api/sentry-launchdarkly-android.api new file mode 100644 index 00000000000..4c0f5f262a1 --- /dev/null +++ b/sentry-launchdarkly-android/api/sentry-launchdarkly-android.api @@ -0,0 +1,15 @@ +public final class io/sentry/launchdarkly/android/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field SENTRY_LAUNCHDARKLY_ANDROID_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHook : com/launchdarkly/sdk/android/integrations/Hook { + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun afterEvaluation (Lcom/launchdarkly/sdk/android/integrations/EvaluationSeriesContext;Ljava/util/Map;Lcom/launchdarkly/sdk/EvaluationDetail;)Ljava/util/Map; +} + diff --git a/sentry-launchdarkly-android/build.gradle.kts b/sentry-launchdarkly-android/build.gradle.kts new file mode 100644 index 00000000000..f201c57b97d --- /dev/null +++ b/sentry-launchdarkly-android/build.gradle.kts @@ -0,0 +1,74 @@ +plugins { + id("com.android.library") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.gradle.versions) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "io.sentry.launchdarkly.android" + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // for AGP 4.1 + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + buildConfigField( + "String", + "SENTRY_LAUNCHDARKLY_ANDROID_SDK_NAME", + "\"${Config.Sentry.SENTRY_LAUNCHDARKLY_ANDROID_SDK_NAME}\"", + ) + } + + buildTypes { + getByName("debug") { consumerProguardFiles("proguard-rules.pro") } + getByName("release") { consumerProguardFiles("proguard-rules.pro") } + } + + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + + testOptions { + animationsDisabled = true + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + checkDependencies = true + + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. + checkReleaseBuilds = false + } + + buildFeatures { buildConfig = true } + + androidComponents.beforeVariants { + it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + } +} + +dependencies { + api(projects.sentry) + + compileOnly(libs.launchdarkly.android) + compileOnly(libs.jetbrains.annotations) + + // tests + testImplementation(projects.sentry) + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid)) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.launchdarkly.android) +} diff --git a/sentry-launchdarkly-android/proguard-rules.pro b/sentry-launchdarkly-android/proguard-rules.pro new file mode 100644 index 00000000000..fbb30d1d080 --- /dev/null +++ b/sentry-launchdarkly-android/proguard-rules.pro @@ -0,0 +1,9 @@ +##---------------Begin: proguard configuration for LaunchDarkly Android ---------- + +# To ensure that stack traces is unambiguous +# https://developer.android.com/studio/build/shrink-code#decode-stack-trace +-keepattributes LineNumberTable,SourceFile + +##---------------End: proguard configuration for LaunchDarkly Android ---------- + + diff --git a/sentry-launchdarkly-android/src/main/java/io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHook.java b/sentry-launchdarkly-android/src/main/java/io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHook.java new file mode 100644 index 00000000000..0d4f5192d13 --- /dev/null +++ b/sentry-launchdarkly-android/src/main/java/io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHook.java @@ -0,0 +1,70 @@ +package io.sentry.launchdarkly.android; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.android.integrations.Hook; +import io.sentry.IScopes; +import io.sentry.ScopesAdapter; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryLevel; +import io.sentry.util.IntegrationUtils; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentryLaunchDarklyAndroidHook extends Hook { + private final IScopes scopes; + + static { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-launchdarkly-android", BuildConfig.VERSION_NAME); + } + + public SentryLaunchDarklyAndroidHook() { + this(ScopesAdapter.getInstance()); + } + + public SentryLaunchDarklyAndroidHook(final @NotNull IScopes scopes) { + super("SentryLaunchDarklyAndroidHook"); + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); + addPackageAndIntegrationInfo(); + } + + private void addPackageAndIntegrationInfo() { + IntegrationUtils.addIntegrationToSdkVersion("LaunchDarkly-Android"); + } + + @Override + public Map afterEvaluation( + final EvaluationSeriesContext seriesContext, + final Map seriesData, + final EvaluationDetail evaluationDetail) { + if (evaluationDetail == null || seriesContext == null) { + return seriesData; + } + + try { + final @Nullable String flagKey = seriesContext.flagKey; + final @Nullable LDValue value = evaluationDetail.getValue(); + + if (flagKey == null || value == null) { + return seriesData; + } + + if (LDValueType.BOOLEAN.equals(value.getType())) { + final boolean flagValue = value.booleanValue(); + scopes.addFeatureFlag(flagKey, flagValue); + } + } catch (final Exception e) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to capture feature flag evaluation", e); + } + + return seriesData; + } +} diff --git a/sentry-launchdarkly-android/src/test/java/io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHookTest.kt b/sentry-launchdarkly-android/src/test/java/io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHookTest.kt new file mode 100644 index 00000000000..3e7174af436 --- /dev/null +++ b/sentry-launchdarkly-android/src/test/java/io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHookTest.kt @@ -0,0 +1,232 @@ +package io.sentry.launchdarkly.android + +import com.launchdarkly.sdk.EvaluationDetail +import com.launchdarkly.sdk.LDValue +import com.launchdarkly.sdk.LDValueType +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryLaunchDarklyAndroidHookTest { + + private lateinit var mockScopes: IScopes + private lateinit var mockOptions: SentryOptions + private lateinit var mockLogger: ILogger + private lateinit var hook: SentryLaunchDarklyAndroidHook + + @BeforeTest + fun setUp() { + mockScopes = mock() + mockOptions = mock() + mockLogger = mock() + whenever(mockScopes.options).thenReturn(mockOptions) + whenever(mockOptions.logger).thenReturn(mockLogger) + hook = SentryLaunchDarklyAndroidHook(mockScopes) + } + + @Test + fun `afterEvaluation with boolean value calls addFeatureFlag`() { + val flagKey = "test-flag" + val flagValue = true + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + whenever(ldValue.booleanValue()).thenReturn(flagValue) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes).addFeatureFlag(eq(flagKey), eq(flagValue)) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with false boolean value calls addFeatureFlag`() { + val flagKey = "test-flag" + val flagValue = false + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + whenever(ldValue.booleanValue()).thenReturn(flagValue) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes).addFeatureFlag(eq(flagKey), eq(flagValue)) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with non-boolean value does not call addFeatureFlag`() { + val flagKey = "test-flag" + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.STRING) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null seriesContext returns seriesData`() { + val evaluationDetail = mock>() + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(null, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null evaluationDetail returns seriesData`() { + val seriesContext = mock() + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, null) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null flagKey returns seriesData`() { + val seriesContext = createSeriesContext(null) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null value returns seriesData`() { + val flagKey = "test-flag" + + val seriesContext = createSeriesContext(flagKey) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(null) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with exception logs error`() { + val flagKey = "test-flag" + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenThrow(RuntimeException("Test exception")) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockLogger) + .log(eq(SentryLevel.ERROR), eq("Failed to capture feature flag evaluation"), any()) + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation returns original seriesData`() { + val flagKey = "test-flag" + val flagValue = true + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + whenever(ldValue.booleanValue()).thenReturn(flagValue) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["key"] = "value" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes).addFeatureFlag(eq(flagKey), eq(flagValue)) + assertEquals(seriesData, result) + assertEquals("value", result["key"]) + } + + private fun createSeriesContext(flagKey: String?): EvaluationSeriesContext { + val seriesContext = mock() + try { + val field = EvaluationSeriesContext::class.java.getField("flagKey") + field.isAccessible = true + field.set(seriesContext, flagKey) + } catch (e: Exception) { + throw RuntimeException("Failed to set flagKey field", e) + } + return seriesContext + } +} diff --git a/sentry-launchdarkly-server/api/sentry-launchdarkly-server.api b/sentry-launchdarkly-server/api/sentry-launchdarkly-server.api new file mode 100644 index 00000000000..8a42a12d9e7 --- /dev/null +++ b/sentry-launchdarkly-server/api/sentry-launchdarkly-server.api @@ -0,0 +1,10 @@ +public final class io/sentry/launchdarkly/server/BuildConfig { + public static final field SENTRY_LAUNCHDARKLY_SERVER_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/launchdarkly/server/SentryLaunchDarklyServerHook : com/launchdarkly/sdk/server/integrations/Hook { + public fun ()V + public fun afterEvaluation (Lcom/launchdarkly/sdk/server/integrations/EvaluationSeriesContext;Ljava/util/Map;Lcom/launchdarkly/sdk/EvaluationDetail;)Ljava/util/Map; +} + diff --git a/sentry-launchdarkly-server/build.gradle.kts b/sentry-launchdarkly-server/build.gradle.kts new file mode 100644 index 00000000000..95aba9faaf5 --- /dev/null +++ b/sentry-launchdarkly-server/build.gradle.kts @@ -0,0 +1,70 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 +} + +dependencies { + api(projects.sentry) + + compileOnly(libs.launchdarkly.server) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(projects.sentry) + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.launchdarkly.server) +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.launchdarkly.server") + buildConfigField( + "String", + "SENTRY_LAUNCHDARKLY_SERVER_SDK_NAME", + "\"${Config.Sentry.SENTRY_LAUNCHDARKLY_SERVER_SDK_NAME}\"", + ) + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_LAUNCHDARKLY_SERVER_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-launchdarkly-server", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-launchdarkly-server/src/main/java/io/sentry/launchdarkly/server/SentryLaunchDarklyServerHook.java b/sentry-launchdarkly-server/src/main/java/io/sentry/launchdarkly/server/SentryLaunchDarklyServerHook.java new file mode 100644 index 00000000000..daa4940fc76 --- /dev/null +++ b/sentry-launchdarkly-server/src/main/java/io/sentry/launchdarkly/server/SentryLaunchDarklyServerHook.java @@ -0,0 +1,73 @@ +package io.sentry.launchdarkly.server; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.server.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.server.integrations.Hook; +import io.sentry.IScopes; +import io.sentry.ScopesAdapter; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryLevel; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; + +public final class SentryLaunchDarklyServerHook extends Hook { + private final IScopes scopes; + + static { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-launchdarkly-server", BuildConfig.VERSION_NAME); + } + + public SentryLaunchDarklyServerHook() { + this(ScopesAdapter.getInstance()); + } + + @VisibleForTesting + SentryLaunchDarklyServerHook(@NotNull IScopes scopes) { + super("SentryLaunchDarklyServerHook"); + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); + addPackageAndIntegrationInfo(); + } + + private void addPackageAndIntegrationInfo() { + addIntegrationToSdkVersion("LaunchDarkly-Server"); + } + + @Override + public Map afterEvaluation( + EvaluationSeriesContext seriesContext, + Map seriesData, + EvaluationDetail evaluationDetail) { + if (evaluationDetail == null || seriesContext == null) { + return seriesData; + } + + try { + final @Nullable String flagKey = seriesContext.flagKey; + final @Nullable LDValue value = evaluationDetail.getValue(); + + if (flagKey == null || value == null) { + return seriesData; + } + + if (LDValueType.BOOLEAN.equals(value.getType())) { + final boolean flagValue = value.booleanValue(); + scopes.addFeatureFlag(flagKey, flagValue); + } + } catch (Exception e) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to capture feature flag evaluation", e); + } + + return seriesData; + } +} diff --git a/sentry-launchdarkly-server/src/test/java/io/sentry/launchdarkly/server/SentryLaunchDarklyServerHookTest.kt b/sentry-launchdarkly-server/src/test/java/io/sentry/launchdarkly/server/SentryLaunchDarklyServerHookTest.kt new file mode 100644 index 00000000000..b82f75c75f0 --- /dev/null +++ b/sentry-launchdarkly-server/src/test/java/io/sentry/launchdarkly/server/SentryLaunchDarklyServerHookTest.kt @@ -0,0 +1,238 @@ +package io.sentry.launchdarkly.server + +import com.launchdarkly.sdk.EvaluationDetail +import com.launchdarkly.sdk.LDValue +import com.launchdarkly.sdk.LDValueType +import com.launchdarkly.sdk.server.integrations.EvaluationSeriesContext +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryLaunchDarklyServerHookTest { + + private lateinit var mockScopes: IScopes + private lateinit var mockOptions: SentryOptions + private lateinit var mockLogger: ILogger + private lateinit var hook: SentryLaunchDarklyServerHook + + @BeforeTest + fun setUp() { + mockScopes = mock() + mockOptions = mock() + mockLogger = mock() + whenever(mockScopes.options).thenReturn(mockOptions) + whenever(mockOptions.logger).thenReturn(mockLogger) + hook = SentryLaunchDarklyServerHook(mockScopes) + } + + @AfterTest + fun tearDown() { + // Cleanup if needed + } + + private fun createSeriesContext(flagKey: String?): EvaluationSeriesContext { + val seriesContext = mock() + try { + val field = EvaluationSeriesContext::class.java.getField("flagKey") + field.isAccessible = true + field.set(seriesContext, flagKey) + } catch (e: Exception) { + throw RuntimeException("Failed to set flagKey field", e) + } + return seriesContext + } + + @Test + fun `afterEvaluation with boolean value calls addFeatureFlag`() { + val flagKey = "test-flag" + val flagValue = true + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + whenever(ldValue.booleanValue()).thenReturn(flagValue) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes).addFeatureFlag(eq(flagKey), eq(flagValue)) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with false boolean value calls addFeatureFlag`() { + val flagKey = "test-flag" + val flagValue = false + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + whenever(ldValue.booleanValue()).thenReturn(flagValue) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes).addFeatureFlag(eq(flagKey), eq(flagValue)) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with non-boolean value does not call addFeatureFlag`() { + val flagKey = "test-flag" + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.STRING) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null seriesContext returns seriesData`() { + val evaluationDetail = mock>() + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(null, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null evaluationDetail returns seriesData`() { + val seriesContext = mock() + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, null) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null flagKey returns seriesData`() { + val seriesContext = createSeriesContext(null) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null value returns seriesData`() { + val flagKey = "test-flag" + + val seriesContext = createSeriesContext(flagKey) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(null) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with exception logs error`() { + val flagKey = "test-flag" + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenThrow(RuntimeException("Test exception")) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockLogger) + .log(eq(SentryLevel.ERROR), eq("Failed to capture feature flag evaluation"), any()) + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation returns original seriesData`() { + val flagKey = "test-flag" + val flagValue = true + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + whenever(ldValue.booleanValue()).thenReturn(flagValue) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["key"] = "value" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes).addFeatureFlag(eq(flagKey), eq(flagValue)) + assertEquals(seriesData, result) + assertEquals("value", result["key"]) + } +} diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 68ebd90b1e8..6e5250ece50 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -36,27 +36,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.log4j2") diff --git a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java index 7c35febd169..0218b53518d 100644 --- a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java +++ b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java @@ -25,6 +25,7 @@ import io.sentry.protocol.Message; import io.sentry.protocol.SdkVersion; import io.sentry.util.CollectionUtils; +import io.sentry.util.LoggerPropertiesUtil; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -40,7 +41,6 @@ import org.apache.logging.log4j.core.config.plugins.PluginAttribute; import org.apache.logging.log4j.core.config.plugins.PluginElement; import org.apache.logging.log4j.core.config.plugins.PluginFactory; -import org.apache.logging.log4j.core.impl.ThrowableProxy; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -163,27 +163,42 @@ public SentryAppender( @Override public void start() { + start(getOptionsConfiguration(null)); + } + + @NotNull + Sentry.OptionsConfiguration getOptionsConfiguration( + final @Nullable Sentry.OptionsConfiguration additionalOptionsConfiguration) { + return options -> { + options.setEnableExternalConfiguration(true); + options.setInitPriority(InitPriority.LOWEST); + options.setDsn(dsn); + if (debug != null) { + options.setDebug(debug); + } + options.setSentryClientName( + BuildConfig.SENTRY_LOG4J2_SDK_NAME + "/" + BuildConfig.VERSION_NAME); + options.setSdkVersion(createSdkVersion(options)); + if (contextTags != null) { + for (final String contextTag : contextTags) { + options.addContextTag(contextTag); + } + } + Optional.ofNullable(transportFactory).ifPresent(options::setTransportFactory); + if (additionalOptionsConfiguration != null) { + additionalOptionsConfiguration.configure(options); + } + }; + } + + void start(final @NotNull Sentry.OptionsConfiguration optionsConfiguration) { try { - Sentry.init( - options -> { - options.setEnableExternalConfiguration(true); - options.setInitPriority(InitPriority.LOWEST); - options.setDsn(dsn); - if (debug != null) { - options.setDebug(debug); - } - options.setSentryClientName( - BuildConfig.SENTRY_LOG4J2_SDK_NAME + "/" + BuildConfig.VERSION_NAME); - options.setSdkVersion(createSdkVersion(options)); - if (contextTags != null) { - for (final String contextTag : contextTags) { - options.addContextTag(contextTag); - } - } - Optional.ofNullable(transportFactory).ifPresent(options::setTransportFactory); - }); + Sentry.init(optionsConfiguration); } catch (IllegalArgumentException e) { - LOGGER.warn("Failed to init Sentry during appender initialization: " + e.getMessage()); + final @Nullable String errorMessage = e.getMessage(); + if (errorMessage == null || !errorMessage.startsWith("DSN is required.")) { + LOGGER.warn("Failed to init Sentry during appender initialization: " + errorMessage); + } } addPackageAndIntegrationInfo(); super.start(); @@ -230,10 +245,14 @@ protected void captureLog(@NotNull LogEvent loggingEvent) { SentryAttribute.stringAttribute("sentry.message.template", nonFormattedMessage)); } + final @NotNull Map contextData = loggingEvent.getContextData().toMap(); + final @NotNull List contextTags = scopes.getOptions().getContextTags(); + LoggerPropertiesUtil.applyPropertiesToAttributes(attributes, contextTags, contextData); + final @NotNull SentryLogParameters params = SentryLogParameters.create(attributes); params.setOrigin("auto.log.log4j2"); - Sentry.logger().log(sentryLevel, params, formattedMessage, arguments); + scopes.logger().log(sentryLevel, params, formattedMessage, arguments); } /** @@ -254,13 +273,12 @@ protected void captureLog(@NotNull LogEvent loggingEvent) { event.setLogger(loggingEvent.getLoggerName()); event.setLevel(formatLevel(loggingEvent.getLevel())); - final ThrowableProxy throwableInformation = loggingEvent.getThrownProxy(); - if (throwableInformation != null) { + final @Nullable Throwable thrown = loggingEvent.getThrown(); + if (thrown != null) { final Mechanism mechanism = new Mechanism(); mechanism.setType(MECHANISM_TYPE); final Throwable mechanismException = - new ExceptionMechanismException( - mechanism, throwableInformation.getThrowable(), Thread.currentThread()); + new ExceptionMechanismException(mechanism, thrown, Thread.currentThread()); event.setThrowable(mechanismException); } @@ -279,20 +297,7 @@ protected void captureLog(@NotNull LogEvent loggingEvent) { // get tags from ScopesAdapter options to allow getting the correct tags if Sentry has been // initialized somewhere else final List contextTags = scopes.getOptions().getContextTags(); - if (contextTags != null && !contextTags.isEmpty()) { - for (final String contextTag : contextTags) { - // if mdc tag is listed in SentryOptions, apply as event tag - if (contextData.containsKey(contextTag)) { - event.setTag(contextTag, contextData.get(contextTag)); - // remove from all tags applied to logging event - contextData.remove(contextTag); - } - } - } - // put the rest of mdc tags in contexts - if (!contextData.isEmpty()) { - event.getContexts().put("Context Data", contextData); - } + LoggerPropertiesUtil.applyPropertiesToEvent(event, contextTags, contextData, "Context Data"); } return event; diff --git a/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderTest.kt b/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderTest.kt index 185972df905..c32459ea022 100644 --- a/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderTest.kt +++ b/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderTest.kt @@ -8,6 +8,9 @@ import io.sentry.SentryLevel import io.sentry.SentryLogLevel import io.sentry.checkEvent import io.sentry.checkLogs +import io.sentry.logger.ILoggerBatchProcessorFactory +import io.sentry.logger.LoggerBatchProcessor +import io.sentry.test.ImmediateExecutorService import io.sentry.test.initForTest import io.sentry.transport.ITransport import java.time.Instant @@ -93,7 +96,14 @@ class SentryAppenderTest { loggerContext.updateLoggers(config) - appender.start() + appender.start( + appender.getOptionsConfiguration { options -> + options.logs.loggerBatchProcessorFactory = + ILoggerBatchProcessorFactory { options, client -> + LoggerBatchProcessor(options, client, ImmediateExecutorService()) + } + } + ) loggerContext.start() return LogManager.getContext().getLogger(SentryAppenderTest::class.java.name) @@ -248,7 +258,7 @@ class SentryAppenderTest { val logger = fixture.getSut(minimumLevel = Level.TRACE) logger.trace("testing trace level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send( @@ -267,7 +277,7 @@ class SentryAppenderTest { val logger = fixture.getSut(minimumLevel = Level.DEBUG) logger.debug("testing debug level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { event -> assertEquals(SentryLogLevel.DEBUG, event.items.first().level) }) @@ -278,7 +288,7 @@ class SentryAppenderTest { val logger = fixture.getSut(minimumLevel = Level.INFO) logger.info("testing info level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { event -> assertEquals(SentryLogLevel.INFO, event.items.first().level) }) @@ -289,7 +299,7 @@ class SentryAppenderTest { val logger = fixture.getSut(minimumLevel = Level.WARN) logger.warn("testing warn level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { event -> assertEquals(SentryLogLevel.WARN, event.items.first().level) }) @@ -300,7 +310,7 @@ class SentryAppenderTest { val logger = fixture.getSut(minimumLevel = Level.ERROR) logger.error("testing error level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { event -> assertEquals(SentryLogLevel.ERROR, event.items.first().level) }) @@ -311,7 +321,7 @@ class SentryAppenderTest { val logger = fixture.getSut(minimumLevel = Level.FATAL) logger.fatal("testing fatal level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { event -> assertEquals(SentryLogLevel.FATAL, event.items.first().level) }) @@ -591,4 +601,26 @@ class SentryAppenderTest { } ) } + + @Test + fun `sets properties from ThreadContext as attributes on logs`() { + val logger = fixture.getSut(minimumLevel = Level.INFO, contextTags = listOf("someTag")) + + ThreadContext.put("someTag", "someValue") + ThreadContext.put("otherTag", "otherValue") + logger.info("testing MDC properties in logs") + + Sentry.flush(1000) + + verify(fixture.transport) + .send( + checkLogs { logs -> + val log = logs.items.first() + assertEquals("testing MDC properties in logs", log.body) + val attributes = log.attributes!! + assertEquals("someValue", attributes["mdc.someTag"]?.value) + assertNull(attributes["otherTag"]) + } + ) + } } diff --git a/sentry-log4j2/src/test/resources/sentry.properties b/sentry-log4j2/src/test/resources/sentry.properties index 0163b4f2f84..9845650aace 100644 --- a/sentry-log4j2/src/test/resources/sentry.properties +++ b/sentry-log4j2/src/test/resources/sentry.properties @@ -1,2 +1,4 @@ release=release from sentry.properties logs.enabled=true +shutdown-timeout-millis=0 +session-flush-timeout-millis=0 diff --git a/sentry-logback/build.gradle.kts b/sentry-logback/build.gradle.kts index 385209e8c49..5fd6c975231 100644 --- a/sentry-logback/build.gradle.kts +++ b/sentry-logback/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -33,27 +33,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.logback") diff --git a/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java b/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java index 65a849d72bc..20fdf304bda 100644 --- a/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java +++ b/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java @@ -29,6 +29,7 @@ import io.sentry.protocol.Message; import io.sentry.protocol.SdkVersion; import io.sentry.util.CollectionUtils; +import io.sentry.util.LoggerPropertiesUtil; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; @@ -70,7 +71,10 @@ public void start() { try { Sentry.init(options); } catch (IllegalArgumentException e) { - addWarn("Failed to init Sentry during appender initialization: " + e.getMessage()); + final @Nullable String errorMessage = e.getMessage(); + if (errorMessage == null || !errorMessage.startsWith("DSN is required.")) { + addWarn("Failed to init Sentry during appender initialization: " + errorMessage); + } } } else if (!Sentry.isEnabled()) { options @@ -152,20 +156,7 @@ protected void append(@NotNull ILoggingEvent eventObject) { // get tags from ScopesAdapter options to allow getting the correct tags if Sentry has been // initialized somewhere else final List contextTags = ScopesAdapter.getInstance().getOptions().getContextTags(); - if (!contextTags.isEmpty()) { - for (final String contextTag : contextTags) { - // if mdc tag is listed in SentryOptions, apply as event tag - if (mdcProperties.containsKey(contextTag)) { - event.setTag(contextTag, mdcProperties.get(contextTag)); - // remove from all tags applied to logging event - mdcProperties.remove(contextTag); - } - } - } - // put the rest of mdc tags in contexts - if (!mdcProperties.isEmpty()) { - event.getContexts().put("MDC", mdcProperties); - } + LoggerPropertiesUtil.applyPropertiesToEvent(event, contextTags, mdcProperties); } return event; @@ -195,6 +186,11 @@ protected void captureLog(@NotNull ILoggingEvent loggingEvent) { arguments = loggingEvent.getArgumentArray(); } + final @NotNull Map mdcProperties = loggingEvent.getMDCPropertyMap(); + final @NotNull List contextTags = + ScopesAdapter.getInstance().getOptions().getContextTags(); + LoggerPropertiesUtil.applyPropertiesToAttributes(attributes, contextTags, mdcProperties); + final @NotNull SentryLogParameters params = SentryLogParameters.create(attributes); params.setOrigin("auto.log.logback"); diff --git a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt index 8c7acbc5725..877d2a23d75 100644 --- a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt +++ b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt @@ -18,6 +18,10 @@ import io.sentry.SentryLogLevel import io.sentry.SentryOptions import io.sentry.checkEvent import io.sentry.checkLogs +import io.sentry.logger.ILoggerBatchProcessorFactory +import io.sentry.logger.LoggerBatchProcessor +import io.sentry.test.ImmediateExecutorService +import io.sentry.test.applyTestOptions import io.sentry.test.initForTest import io.sentry.transport.ITransport import java.time.Instant @@ -68,6 +72,10 @@ class SentryAppenderTest { options.dsn = dsn options.isSendDefaultPii = sendDefaultPii options.logs.isEnabled = enableLogs + options.logs.loggerBatchProcessorFactory = ILoggerBatchProcessorFactory { options, client -> + LoggerBatchProcessor(options, client, ImmediateExecutorService()) + } + applyTestOptions(options) contextTags?.forEach { options.addContextTag(it) } appender.setOptions(options) appender.setMinimumBreadcrumbLevel(minimumBreadcrumbLevel) @@ -113,7 +121,9 @@ class SentryAppenderTest { Fixture( startLater = true, options = - SentryOptions().also { it.setTag("only-present-if-logger-init-was-run", "another-value") }, + SentryOptions().also { + it.setTag("only-present-if-logger-init-was-run", "another-value") + }, ) initForTest { it.dsn = "http://key@localhost/proj" @@ -317,7 +327,7 @@ class SentryAppenderTest { fixture = Fixture(minimumLevel = Level.TRACE, enableLogs = true) fixture.logger.trace("testing trace level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { logs -> assertEquals(SentryLogLevel.TRACE, logs.items.first().level) }) @@ -328,7 +338,7 @@ class SentryAppenderTest { fixture = Fixture(minimumLevel = Level.DEBUG, enableLogs = true) fixture.logger.debug("testing debug level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { logs -> assertEquals(SentryLogLevel.DEBUG, logs.items.first().level) }) @@ -339,7 +349,7 @@ class SentryAppenderTest { fixture = Fixture(minimumLevel = Level.INFO, enableLogs = true) fixture.logger.info("testing info level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { logs -> assertEquals(SentryLogLevel.INFO, logs.items.first().level) }) @@ -350,7 +360,7 @@ class SentryAppenderTest { fixture = Fixture(minimumLevel = Level.WARN, enableLogs = true) fixture.logger.warn("testing warn level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { logs -> assertEquals(SentryLogLevel.WARN, logs.items.first().level) }) @@ -361,7 +371,7 @@ class SentryAppenderTest { fixture = Fixture(minimumLevel = Level.ERROR, enableLogs = true) fixture.logger.error("testing error level") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send(checkLogs { logs -> assertEquals(SentryLogLevel.ERROR, logs.items.first().level) }) @@ -372,7 +382,7 @@ class SentryAppenderTest { fixture = Fixture(minimumLevel = Level.TRACE, enableLogs = true) fixture.logger.trace("Testing {} level", "TRACE") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send( @@ -394,7 +404,7 @@ class SentryAppenderTest { fixture = Fixture(minimumLevel = Level.TRACE, enableLogs = true, encoder = encoder) fixture.logger.trace("Testing {} level", "TRACE") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send( @@ -420,7 +430,7 @@ class SentryAppenderTest { ) fixture.logger.trace("Testing {} level", "TRACE") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send( @@ -447,7 +457,7 @@ class SentryAppenderTest { ) fixture.logger.trace("Testing {} level", "TRACE") - Sentry.flush(1000) + Sentry.flush(10) verify(fixture.transport) .send( @@ -821,4 +831,25 @@ class SentryAppenderTest { } ) } + + @Test + fun `sets properties from MDC as attributes on logs`() { + fixture = Fixture(minimumLevel = Level.INFO, enableLogs = true, contextTags = listOf("someTag")) + MDC.put("someTag", "someValue") + MDC.put("otherTag", "otherValue") + fixture.logger.info("testing MDC properties in logs") + + Sentry.flush(1000) + + verify(fixture.transport) + .send( + checkLogs { logs -> + val log = logs.items.first() + assertEquals("testing MDC properties in logs", log.body) + val attributes = log.attributes!! + assertEquals("someValue", attributes["mdc.someTag"]?.value) + assertNull(attributes["otherTag"]) + } + ) + } } diff --git a/sentry-okhttp/build.gradle.kts b/sentry-okhttp/build.gradle.kts index f7178cf1dfe..47b8bfe5b15 100644 --- a/sentry-okhttp/build.gradle.kts +++ b/sentry-okhttp/build.gradle.kts @@ -4,12 +4,11 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` alias(libs.plugins.kotlin.jvm) - jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -39,31 +38,6 @@ dependencies { testImplementation(libs.mockito.inline) testImplementation(libs.okhttp) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") -} - -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } } buildConfig { diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt index fc894ec3768..7475f09443b 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt @@ -10,6 +10,7 @@ import io.sentry.TypeCheckHint import io.sentry.transport.CurrentDateProvider import io.sentry.util.Platform import io.sentry.util.UrlUtils +import io.sentry.util.network.NetworkRequestData import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean @@ -27,6 +28,7 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques internal val callSpan: ISpan? private var response: Response? = null private var clientErrorResponse: Response? = null + private var networkDetails: NetworkRequestData? = null internal val isEventFinished = AtomicBoolean(false) private var url: String private var method: String @@ -135,6 +137,11 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques } } + /** Sets the [NetworkRequestData] for network detail capture. */ + fun setNetworkDetails(networkRequestData: NetworkRequestData?) { + this.networkDetails = networkRequestData + } + /** Record event start if the callRootSpan is not null. */ fun onEventStart(event: String) { callSpan ?: return @@ -163,6 +170,9 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques hint.set(TypeCheckHint.OKHTTP_REQUEST, request) response?.let { hint.set(TypeCheckHint.OKHTTP_RESPONSE, it) } + // Include network details in the hint for session replay + networkDetails?.let { hint.set(TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS, it) } + // needs this as unix timestamp for rrweb breadcrumb.setData( SpanDataConvention.HTTP_END_TIMESTAMP, diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index be1ee1caf37..7031be3b0b3 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -4,15 +4,18 @@ import io.sentry.BaggageHeader import io.sentry.Breadcrumb import io.sentry.Hint import io.sentry.HttpStatusCodeRange +import io.sentry.ILogger import io.sentry.IScopes import io.sentry.ISpan import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS +import io.sentry.SentryReplayOptions import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TypeCheckHint.OKHTTP_REQUEST import io.sentry.TypeCheckHint.OKHTTP_RESPONSE +import io.sentry.TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS import io.sentry.okhttp.SentryOkHttpInterceptor.BeforeSpanCallback import io.sentry.transport.CurrentDateProvider import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion @@ -21,10 +24,16 @@ import io.sentry.util.PropagationTargetsUtils import io.sentry.util.SpanUtils import io.sentry.util.TracingUtils import io.sentry.util.UrlUtils +import io.sentry.util.network.NetworkBody +import io.sentry.util.network.NetworkBodyParser +import io.sentry.util.network.NetworkDetailCaptureUtils +import io.sentry.util.network.NetworkRequestData import java.io.IOException import okhttp3.Interceptor import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import org.jetbrains.annotations.VisibleForTesting /** * The Sentry's [SentryOkHttpInterceptor], it will automatically add a breadcrumb and start a span @@ -49,6 +58,8 @@ public open class SentryOkHttpInterceptor( private val failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS), ) : Interceptor { private companion object { + private const val HTTP_GATEWAY_TIMEOUT = 504 + init { SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-okhttp", BuildConfig.VERSION_NAME) @@ -66,6 +77,7 @@ public open class SentryOkHttpInterceptor( } @Suppress("LongMethod") + @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { var request = chain.request() @@ -97,6 +109,14 @@ public open class SentryOkHttpInterceptor( var response: Response? = null var code: Int? = null + val networkDetailData = + NetworkDetailCaptureUtils.initializeForUrl( + request.url.toString(), + request.method, + scopes.options.sessionReplay.networkDetailAllowUrls, + scopes.options.sessionReplay.networkDetailDenyUrls, + ) + try { val requestBuilder = request.newBuilder() @@ -120,6 +140,32 @@ public open class SentryOkHttpInterceptor( } } + val requestContentLength = request.body?.contentLength() + + networkDetailData?.setRequestDetails( + NetworkDetailCaptureUtils.createRequest( + request, + requestContentLength, + scopes.options.sessionReplay.isNetworkCaptureBodies, + { req -> + req.body?.let { originalBody -> + val buffer = okio.Buffer() + originalBody.writeTo(buffer) + val bodyBytes = buffer.readByteArray() + + // Create fresh RequestBody and update the request being built + val newRequestBody = bodyBytes.toRequestBody(originalBody.contentType()) + requestBuilder.method(request.method, newRequestBody) + + // Parse the buffered bytes into NetworkBody for capture + safeExtractRequestBody(bodyBytes, originalBody.contentType(), scopes.options.logger) + } + }, + scopes.options.sessionReplay.networkRequestHeaders, + { req: Request -> req.headers.toMap() }, + ) + ) + request = requestBuilder.build() response = chain.proceed(request) code = response.code @@ -153,11 +199,28 @@ public open class SentryOkHttpInterceptor( // this only works correctly if SentryOkHttpInterceptor is the last one in the chain okHttpEvent?.setRequest(request) + response?.let { + networkDetailData?.setResponseDetails( + it.code, + NetworkDetailCaptureUtils.createResponse( + it, + it.body?.contentLength(), + scopes.options.sessionReplay.isNetworkCaptureBodies, + { resp: Response -> resp.extractResponseBody(scopes.options.logger) }, + scopes.options.sessionReplay.networkResponseHeaders, + { resp: Response -> resp.headers.toMap() }, + ), + ) + } + + // Set network details on the OkHttpEvent so it can include them in the breadcrumb hint + okHttpEvent?.setNetworkDetails(networkDetailData) + finishSpan(span, request, response, isFromEventListener, okHttpEvent) // The SentryOkHttpEventListener will send the breadcrumb itself if used for this call if (!isFromEventListener) { - sendBreadcrumb(request, code, response, startTimestamp) + sendBreadcrumb(request, code, response, startTimestamp, networkDetailData) } } } @@ -170,20 +233,29 @@ public open class SentryOkHttpInterceptor( code: Int?, response: Response?, startTimestamp: Long, + networkDetailData: NetworkRequestData?, ) { val breadcrumb = Breadcrumb.http(request.url.toString(), request.method, code) + + // Track request and response body sizes for the breadcrumb request.body?.contentLength().ifHasValidLength { breadcrumb.setData("http.request_content_length", it) } - val hint = Hint().also { it.set(OKHTTP_REQUEST, request) } - response?.let { - it.body?.contentLength().ifHasValidLength { responseBodySize -> - breadcrumb.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, responseBodySize) + response?.body?.contentLength().ifHasValidLength { + breadcrumb.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, it) + } + + val hint = + Hint().also { + it.set(OKHTTP_REQUEST, request) + response?.let { resp -> it[OKHTTP_RESPONSE] = resp } + + if (networkDetailData != null) { + it.set(SENTRY_REPLAY_NETWORK_DETAILS, networkDetailData) + } } - hint[OKHTTP_RESPONSE] = it - } // needs this as unix timestamp for rrweb breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, startTimestamp) breadcrumb.setData( @@ -194,6 +266,84 @@ public open class SentryOkHttpInterceptor( scopes.addBreadcrumb(breadcrumb, hint) } + /** Extracts headers from OkHttp Headers object into a map */ + @VisibleForTesting + internal fun okhttp3.Headers.toMap(): Map { + val headers = linkedMapOf() + for (i in 0 until size) { + val name = name(i) + val value = value(i) + val existingValue = headers[name] + if (existingValue != null) { + // Concatenate duplicate headers with semicolon separator + headers[name] = "$existingValue; $value" + } else { + headers[name] = value + } + } + return headers + } + + /** Extracts NetworkBody from already buffered request body data. */ + private fun safeExtractRequestBody( + bufferedBody: ByteArray?, + contentType: okhttp3.MediaType?, + logger: ILogger, + ): NetworkBody? { + if (bufferedBody == null) { + return null + } + + try { + val contentTypeString = contentType?.toString() + val maxBodySize = SentryReplayOptions.MAX_NETWORK_BODY_SIZE + val charset = contentType?.charset(Charsets.UTF_8)?.name() ?: "UTF-8" + + return NetworkBodyParser.fromBytes( + bufferedBody, + contentTypeString, + charset, + maxBodySize, + logger, + ) + } catch (e: Exception) { + logger.log(io.sentry.SentryLevel.ERROR, "Failed to parse buffered request body: ${e.message}") + return null + } + } + + /** Extracts the body content from an OkHttp Response safely */ + private fun Response.extractResponseBody(logger: ILogger): NetworkBody? { + return body?.let { responseBody -> + try { + val contentType = responseBody.contentType() + val contentTypeString = contentType?.toString() + val maxBodySize = SentryReplayOptions.MAX_NETWORK_BODY_SIZE + + // Peek at the body (doesn't consume it) + // We +1 here in order to properly truncate within NetworkBodyParser.fromBytes + // and be able to distinguish from an oversized request and a request matching maxBodySize + val peekBody = peekBody(maxBodySize.toLong() + 1) + val bodyBytes = peekBody.bytes() + + val charset = contentType?.charset(Charsets.UTF_8)?.name() ?: "UTF-8" + return NetworkBodyParser.fromBytes( + bodyBytes, + contentTypeString, + charset, + maxBodySize, + logger, + ) + } catch (e: Exception) { + logger.log( + io.sentry.SentryLevel.ERROR, + "Failed to read http response body for Network Details: ${e.message}", + ) + null + } + } + } + private fun finishSpan( span: ISpan?, request: Request, @@ -239,6 +389,13 @@ public open class SentryOkHttpInterceptor( return false } + // A 504 on an only-if-cached (e.g. CacheControl.FORCE_CACHE) request is a synthetic + // cache-miss response generated by OkHttp's CacheInterceptor, not a real server error, + // so don't report it. See https://square.github.io/okhttp/recipes/#response-caching-kt-java + if (response.code == HTTP_GATEWAY_TIMEOUT && request.cacheControl.onlyIfCached) { + return false + } + return true } diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt index ff671ac153f..5570e37787b 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt @@ -15,6 +15,7 @@ import io.sentry.TransactionContext import io.sentry.TypeCheckHint import io.sentry.exception.SentryHttpClientException import io.sentry.test.getProperty +import io.sentry.util.network.NetworkRequestData import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -425,6 +426,34 @@ class SentryOkHttpEventTest { verify(fixture.scopes, never()).captureEvent(any(), any()) } + @Test + fun `when finish is called, the breadcrumb sent includes network details data on its hint`() { + val sut = fixture.getSut() + val networkRequestData = NetworkRequestData("GET") + + sut.setNetworkDetails(networkRequestData) + sut.finish() + + verify(fixture.scopes) + .addBreadcrumb( + any(), + check { assertEquals(networkRequestData, it[TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS]) }, + ) + } + + @Test + fun `when setNetworkDetails is not called, no network details data is captured`() { + val sut = fixture.getSut() + + sut.finish() + + verify(fixture.scopes) + .addBreadcrumb( + any(), + check { assertNull(it[TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS]) }, + ) + } + /** Retrieve all the spans started in the event using reflection. */ private fun SentryOkHttpEvent.getEventDates() = getProperty>("eventDates") diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index bbdb3a86516..9f7d8bc18fb 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -31,6 +31,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail +import okhttp3.CacheControl import okhttp3.EventListener import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType @@ -541,6 +542,30 @@ class SentryOkHttpInterceptorTest { ) } + @Test + fun `does not capture a synthetic 504 from OkHttp for a FORCE_CACHE cache miss`() { + // No cache is configured on the client, so FORCE_CACHE is guaranteed to miss and + // OkHttp's CacheInterceptor will synthesize a 504 "Unsatisfiable Request" response. + val sut = fixture.getSut(captureFailedRequests = true) + val request = + Request.Builder() + .url(fixture.server.url("/hello")) + .cacheControl(CacheControl.FORCE_CACHE) + .build() + val response = sut.newCall(request).execute() + + assertEquals(504, response.code) + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `captures a real 504 when onlyIfCached is not set`() { + val sut = fixture.getSut(captureFailedRequests = true, httpStatusCode = 504) + sut.newCall(getRequest()).execute() + + verify(fixture.scopes).captureEvent(any(), any()) + } + @SuppressWarnings("SwallowedException") @Test fun `does not capture an error even if it throws`() { @@ -680,4 +705,39 @@ class SentryOkHttpInterceptorTest { assertNotNull(recordedRequest.getHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) assertNull(recordedRequest.getHeader(W3CTraceparentHeader.TRACEPARENT_HEADER)) } + + @Test + fun `toMap handles duplicate headers correctly`() { + // Create a response with duplicate headers + val mockResponse = + MockResponse() + .setResponseCode(200) + .setBody("test") + .addHeader("Set-Cookie", "sessionId=123") + .addHeader("Set-Cookie", "userId=456") + .addHeader("Set-Cookie", "theme=dark") + .addHeader("Accept", "text/html") + .addHeader("Accept", "application/json") + .addHeader("Single-Header", "value") + + fixture.server.enqueue(mockResponse) + + // Execute request to get response with headers + val sut = fixture.getSut() + val response = sut.newCall(getRequest()).execute() + val headers = response.headers + + // Optional: verify OkHttp preserves duplicate headers + assertEquals(3, headers.values("Set-Cookie").size) + assertEquals(2, headers.values("Accept").size) + assertEquals(1, headers.values("Single-Header").size) + + val interceptor = SentryOkHttpInterceptor(fixture.scopes) + val headerMap = with(interceptor) { headers.toMap() } + + // Duplicate headers will be collapsed into 1 concatenated entry with "; " separator + assertEquals("sessionId=123; userId=456; theme=dark", headerMap["Set-Cookie"]) + assertEquals("text/html; application/json", headerMap["Accept"]) + assertEquals("value", headerMap["Single-Header"]) + } } diff --git a/sentry-openfeature/api/sentry-openfeature.api b/sentry-openfeature/api/sentry-openfeature.api new file mode 100644 index 00000000000..5fe3402b593 --- /dev/null +++ b/sentry-openfeature/api/sentry-openfeature.api @@ -0,0 +1,10 @@ +public final class io/sentry/openfeature/BuildConfig { + public static final field SENTRY_OPENFEATURE_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/openfeature/SentryOpenFeatureHook : dev/openfeature/sdk/BooleanHook { + public fun ()V + public fun after (Ldev/openfeature/sdk/HookContext;Ldev/openfeature/sdk/FlagEvaluationDetails;Ljava/util/Map;)V +} + diff --git a/sentry-openfeature/build.gradle.kts b/sentry-openfeature/build.gradle.kts new file mode 100644 index 00000000000..b079ead1fc5 --- /dev/null +++ b/sentry-openfeature/build.gradle.kts @@ -0,0 +1,70 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 +} + +dependencies { + api(projects.sentry) + + compileOnly(libs.openfeature) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(projects.sentry) + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.openfeature) +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.openfeature") + buildConfigField( + "String", + "SENTRY_OPENFEATURE_SDK_NAME", + "\"${Config.Sentry.SENTRY_OPENFEATURE_SDK_NAME}\"", + ) + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_OPENFEATURE_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-openfeature", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-openfeature/src/main/java/io/sentry/openfeature/SentryOpenFeatureHook.java b/sentry-openfeature/src/main/java/io/sentry/openfeature/SentryOpenFeatureHook.java new file mode 100644 index 00000000000..683417c33c1 --- /dev/null +++ b/sentry-openfeature/src/main/java/io/sentry/openfeature/SentryOpenFeatureHook.java @@ -0,0 +1,75 @@ +package io.sentry.openfeature; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import dev.openfeature.sdk.BooleanHook; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.HookContext; +import io.sentry.IScopes; +import io.sentry.ScopesAdapter; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryLevel; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; + +public final class SentryOpenFeatureHook implements BooleanHook { + private final IScopes scopes; + + static { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-openfeature", BuildConfig.VERSION_NAME); + } + + public SentryOpenFeatureHook() { + this(ScopesAdapter.getInstance()); + addPackageAndIntegrationInfo(); + } + + private void addPackageAndIntegrationInfo() { + addIntegrationToSdkVersion("OpenFeature"); + } + + @VisibleForTesting + SentryOpenFeatureHook(@NotNull IScopes scopes) { + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); + } + + @Override + public void after( + final @Nullable HookContext context, + final @Nullable FlagEvaluationDetails details, + final @Nullable Map hints) { + if (context == null || details == null) { + return; + } + try { + final @Nullable String flagKey = details.getFlagKey(); + final @Nullable FlagValueType type = context.getType(); + final @Nullable Object value = details.getValue(); + + if (flagKey == null || type == null || value == null) { + return; + } + + if (!FlagValueType.BOOLEAN.equals(type)) { + return; + } + + if (!(value instanceof Boolean)) { + return; + } + final @NotNull Boolean flagValue = (Boolean) value; + + scopes.addFeatureFlag(flagKey, flagValue); + } catch (Exception e) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to capture feature flag evaluation", e); + } + } +} diff --git a/sentry-openfeign/build.gradle.kts b/sentry-openfeign/build.gradle.kts index 40119987f72..3baa85dee26 100644 --- a/sentry-openfeign/build.gradle.kts +++ b/sentry-openfeign/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -35,27 +35,6 @@ dependencies { testImplementation(libs.okhttp.mockwebserver) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt index 571a2339326..c25a81f9501 100644 --- a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt +++ b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt @@ -286,11 +286,10 @@ class SentryFeignClientTest { @Test fun `customizer modifies span`() { - val sut = - fixture.getSut { span, _, _ -> - span.description = "overwritten description" - span - } + val sut = fixture.getSut { span, _, _ -> + span.description = "overwritten description" + span + } sut.getOk() assertEquals(1, fixture.sentryTracer.children.size) val httpClientSpan = fixture.sentryTracer.children.first() @@ -299,13 +298,12 @@ class SentryFeignClientTest { @Test fun `customizer receives request and response`() { - val sut = - fixture.getSut { span, request, response -> - assertEquals(request.url(), request.url()) - assertEquals(request.httpMethod().name, request.httpMethod().name) - assertNotNull(response) { assertEquals(201, it.status()) } - span - } + val sut = fixture.getSut { span, request, response -> + assertEquals(request.url(), request.url()) + assertEquals(request.httpMethod().name, request.httpMethod().name) + assertNotNull(response) { assertEquals(201, it.status()) } + span + } sut.getOk() } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts index 7ee17c09385..054db790dc2 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts @@ -2,8 +2,9 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") - id("com.gradleup.shadow") version "8.3.6" + alias(libs.plugins.shadow) } fun relocatePackages(shadowJar: ShadowJar) { @@ -133,7 +134,7 @@ tasks { // each CopySpec has // its own duplicatesStrategy register("isolateJavaagentLibs", Copy::class.java) { - dependsOn(findByName("relocateJavaagentLibs")) + findByName("relocateJavaagentLibs")?.let { task -> dependsOn(task) } with(isolateClasses(findByName("relocateJavaagentLibs")!!.outputs.files)) into(project.layout.buildDirectory.file("isolated/javaagentLibs").get().asFile) @@ -145,14 +146,26 @@ tasks { named("shadowJar", ShadowJar::class) { configurations = listOf(bootstrapLibs) + listOf(upstreamAgent) - dependsOn(findByName("isolateJavaagentLibs")) + findByName("isolateJavaagentLibs")?.let { task -> dependsOn(task) } from(findByName("isolateJavaagentLibs")!!.outputs) archiveClassifier.set("") duplicatesStrategy = DuplicatesStrategy.FAIL - mergeServiceFiles { include("inst/META-INF/services/*") } + filesMatching("META-INF/services/**") { duplicatesStrategy = DuplicatesStrategy.INCLUDE } + filesMatching("inst/META-INF/services/**") { duplicatesStrategy = DuplicatesStrategy.INCLUDE } + + // Shadow 9.x only applies relocations to service files handled by a ServiceFileTransformer. + // We need two mergeServiceFiles calls: + // 1. Default path (META-INF/services) — ensures bootstrap service files get relocated + // (e.g., ContextStorageProvider → shaded path). Without this, Shadow 9.x skips + // relocation for service file names/contents not claimed by a transformer. + // 2. inst/ path — merges isolated agent service files from both the upstream agent + // and the distro libs. Uses `path` instead of `include` filter because Shadow 9.x's + // include() strips the `inst/` prefix on output. + mergeServiceFiles() + mergeServiceFiles { path = "inst/META-INF/services" } exclude("**/module-info.class") relocatePackages(this) diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts index b4a84300efd..087568d03ee 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts @@ -3,9 +3,9 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -41,27 +41,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts index c02ca0ca468..5a94dcd4422 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } dependencies { diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts index 43e87d53beb..72508d73737 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } dependencies { diff --git a/sentry-opentelemetry/sentry-opentelemetry-bom/README.md b/sentry-opentelemetry/sentry-opentelemetry-bom/README.md new file mode 100644 index 00000000000..c7522772d21 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-bom/README.md @@ -0,0 +1,84 @@ +# sentry-opentelemetry-bom + +This BOM aligns Sentry OpenTelemetry modules with the OpenTelemetry artifacts tested by Sentry. + +Use this BOM only when you want Sentry to manage OpenTelemetry dependency versions for Sentry's OpenTelemetry integrations. Do not import it for regular Sentry usage unless you also want this OpenTelemetry version alignment. + +The BOM intentionally manages stable and `-alpha` OpenTelemetry artifacts, including incubator artifacts used by the OpenTelemetry instrumentation stack. It makes Sentry's tested OpenTelemetry versions authoritative, so verify dependency resolution before importing it if your application already uses newer OpenTelemetry versions. + +This BOM is primarily for classpath-based OpenTelemetry integrations such as `sentry-opentelemetry-agentless`, `sentry-opentelemetry-agentless-spring`, `sentry-opentelemetry-otlp`, and `sentry-opentelemetry-otlp-spring`. It does not change the OpenTelemetry dependencies shaded into the `sentry-opentelemetry-agent` Java agent JAR. + +## Dependency management ordering + +Ordering matters when another BOM, such as Spring Boot's dependency management, also manages OpenTelemetry versions. + +### Gradle + +With Gradle's native dependency management, import the BOM as a platform and omit versions from Sentry OpenTelemetry and OpenTelemetry dependencies: + +```kotlin +dependencies { + implementation(platform("io.sentry:sentry-opentelemetry-bom:")) + + implementation("io.sentry:sentry-opentelemetry-agentless") + implementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure") +} +``` + +If another imported platform also manages OpenTelemetry versions, Gradle's normal version conflict resolution applies. Use `enforcedPlatform(...)` only when you need Sentry's tested OpenTelemetry versions to override other platforms. + +When using Gradle with the Spring dependency management plugin, the last imported BOM wins. Import this BOM after Spring Boot's dependency management so its OpenTelemetry versions take precedence: + +```kotlin +dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:") + mavenBom("io.sentry:sentry-opentelemetry-bom:") + } +} +``` + +If the Spring Boot Gradle plugin imports Spring Boot dependency management implicitly, add the Sentry BOM in your `dependencyManagement` block; explicit imports are applied after the implicit Spring Boot import. + +### Maven + +Maven uses different precedence rules: when multiple BOMs are imported in the same `` block, the first declaration wins. + +When using `spring-boot-starter-parent`, declare `sentry-opentelemetry-bom` in the child POM's `` block. Dependency management in the child POM takes precedence over the parent: + +```xml + + + + io.sentry + sentry-opentelemetry-bom + ${sentry.version} + pom + import + + + +``` + +When importing `spring-boot-dependencies` manually in the same POM, import `sentry-opentelemetry-bom` first so Sentry's OpenTelemetry versions win: + +```xml + + + + io.sentry + sentry-opentelemetry-bom + ${sentry.version} + pom + import + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + +``` diff --git a/sentry-opentelemetry/sentry-opentelemetry-bom/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bom/build.gradle.kts new file mode 100644 index 00000000000..d945002b716 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-bom/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + `java-platform` + `maven-publish` +} + +javaPlatform.allowDependencies() + +dependencies { + api(platform(libs.otel.bom)) + api(platform(libs.otel.alpha.bom)) + api(platform(libs.otel.instrumentation.bom)) + api(platform(libs.otel.instrumentation.alpha.bom)) + + constraints { + api(projects.sentryOpentelemetry.sentryOpentelemetryAgent) + api(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) + api(projects.sentryOpentelemetry.sentryOpentelemetryAgentless) + api(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) + api(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) + api(projects.sentryOpentelemetry.sentryOpentelemetryCore) + api(projects.sentryOpentelemetry.sentryOpentelemetryOtlp) + api(projects.sentryOpentelemetry.sentryOpentelemetryOtlpSpring) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api index 3a63bf04d98..1f81e4324d4 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api @@ -43,6 +43,7 @@ public final class io/sentry/opentelemetry/OtelSpanFactory : io/sentry/ISpanFact public final class io/sentry/opentelemetry/OtelStrongRefSpanWrapper : io/sentry/opentelemetry/IOtelSpanWrapper { public fun (Lio/opentelemetry/api/trace/Span;Lio/sentry/opentelemetry/IOtelSpanWrapper;)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V @@ -84,6 +85,7 @@ public final class io/sentry/opentelemetry/OtelStrongRefSpanWrapper : io/sentry/ public fun startChild (Lio/sentry/SpanContext;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; @@ -96,6 +98,7 @@ public final class io/sentry/opentelemetry/OtelStrongRefSpanWrapper : io/sentry/ public final class io/sentry/opentelemetry/OtelTransactionSpanForwarder : io/sentry/ITransaction { public fun (Lio/sentry/opentelemetry/IOtelSpanWrapper;)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts index 64db4096bb9..3585aa40d4a 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -3,9 +3,9 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -28,6 +28,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) @@ -36,27 +37,6 @@ dependencies { testImplementation(libs.otel.semconv.incubating) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java index a4008a01283..907d71a278b 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java @@ -125,6 +125,12 @@ public void setTransactionName(@NotNull String name, @NotNull TransactionNameSou return delegate.startChild(spanContext, spanOptions); } + @Override + public @NotNull ISpan startChild( + @NotNull String operation, @Nullable String description, @Nullable SentryDate timestamp) { + return delegate.startChild(operation, description, timestamp); + } + @Override public @NotNull ISpan startChild( @NotNull String operation, @@ -310,4 +316,9 @@ public void setContext(@Nullable String key, @Nullable Object context) { public @Nullable Attributes getOpenTelemetrySpanAttributes() { return delegate.getOpenTelemetrySpanAttributes(); } + + @Override + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { + delegate.addFeatureFlag(flag, result); + } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java index 7d0618af040..e3cdfc4be3b 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java @@ -309,4 +309,9 @@ public void setName(@NotNull String name, @NotNull TransactionNameSource nameSou } return name; } + + @Override + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { + rootSpan.addFeatureFlag(flag, result); + } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryContextStorage.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryContextStorage.java index 5a916a9ecab..c97e40ea62d 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryContextStorage.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryContextStorage.java @@ -41,6 +41,9 @@ public Context current() { @Override public Context root() { - return SentryContextWrapper.wrap(ContextStorage.super.root()); + // Don't wrap() here — it triggers scope.clone() which acquires locks. Under virtual + // threads, lock.unlock() can re-enter here via OpenTelemetry executor instrumentation, causing + // a deadlock. + return ContextStorage.super.root(); } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/test/kotlin/io/sentry/opentelemetry/OtelStrongRefSpanWrapperTest.kt b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/test/kotlin/io/sentry/opentelemetry/OtelStrongRefSpanWrapperTest.kt new file mode 100644 index 00000000000..c8547d8220f --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/test/kotlin/io/sentry/opentelemetry/OtelStrongRefSpanWrapperTest.kt @@ -0,0 +1,26 @@ +package io.sentry.opentelemetry + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.api.trace.Span +import io.sentry.ISpan +import io.sentry.SentryLongDate +import kotlin.test.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class OtelStrongRefSpanWrapperTest { + @Test + fun `startChild with timestamp forwards to delegate`() { + val delegate = mock() + val wrapper = OtelStrongRefSpanWrapper(mock(), delegate) + val timestamp = SentryLongDate(1234) + val expectedChild = mock() + whenever(delegate.startChild("child-op", "description", timestamp)).thenReturn(expectedChild) + + val child = wrapper.startChild("child-op", "description", timestamp) + + verify(delegate).startChild("child-op", "description", timestamp) + assertThat(child).isSameInstanceAs(expectedChild) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api index 4c231317af3..3ed25d1a9cf 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api +++ b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api @@ -57,6 +57,7 @@ public final class io/sentry/opentelemetry/OtelSpanUtils { public final class io/sentry/opentelemetry/OtelSpanWrapper : io/sentry/opentelemetry/IOtelSpanWrapper { public fun (Lio/opentelemetry/sdk/trace/ReadWriteSpan;Lio/sentry/IScopes;Lio/sentry/SentryDate;Lio/sentry/TracesSamplingDecision;Lio/sentry/opentelemetry/IOtelSpanWrapper;Lio/sentry/SpanId;Lio/sentry/Baggage;)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V @@ -98,6 +99,7 @@ public final class io/sentry/opentelemetry/OtelSpanWrapper : io/sentry/opentelem public fun startChild (Lio/sentry/SpanContext;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; @@ -148,7 +150,7 @@ public final class io/sentry/opentelemetry/SentrySpanProcessor : io/opentelemetr public final class io/sentry/opentelemetry/SpanDescriptionExtractor { public fun ()V - public fun extractSpanInfo (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/opentelemetry/IOtelSpanWrapper;)Lio/sentry/opentelemetry/OtelSpanInfo; + public fun extractSpanInfo (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/opentelemetry/IOtelSpanWrapper;Lio/sentry/SentryOptions;)Lio/sentry/opentelemetry/OtelSpanInfo; } public final class io/sentry/opentelemetry/SpanNode { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts index 2ab3d4988d5..a252628c1a2 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -3,9 +3,9 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -38,6 +38,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) @@ -46,27 +47,6 @@ dependencies { testImplementation(libs.otel.semconv.incubating) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java index 56e87c67896..e87af070748 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java @@ -113,6 +113,14 @@ public Context extract( final @Nullable String baggageString = getter.get(carrier, BaggageHeader.BAGGAGE_HEADER); final Baggage baggage = Baggage.fromHeader(baggageString); + if (!TracingUtils.shouldContinueTrace(scopes.getOptions(), baggage)) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, "Not continuing trace due to strict org ID validation failure."); + return context; + } final @NotNull TraceState traceState = TraceState.getDefault(); SpanContext otelSpanContext = diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java index bb374f4a517..1cf6fa5d833 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java @@ -125,7 +125,7 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri private IScopes forkScopes(final @NotNull Context context, final @NotNull SpanData span) { final @Nullable IScopes scopesFromContext = context.get(SENTRY_SCOPES_KEY); - if (scopesFromContext == null) { + if (scopesFromContext == null || scopesFromContext.isNoOp()) { return Sentry.forkedRootScopes("spanprocessor.new"); } if (isRootSpan(span)) { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java index bc78643fb9b..80da51f9db7 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java @@ -141,6 +141,12 @@ public OtelSpanWrapper( return childSpan; } + @Override + public @NotNull ISpan startChild( + @NotNull String operation, @Nullable String description, @Nullable SentryDate timestamp) { + return startChild(operation, description, timestamp, Instrumenter.SENTRY); + } + @Override public @NotNull ISpan startChild( @NotNull String operation, @@ -505,6 +511,11 @@ public Map getMeasurements() { return scopes; } + @Override + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { + context.addFeatureFlag(flag, result); + } + @Override public @NotNull Context storeInContext(Context context) { final @Nullable ReadWriteSpan otelSpan = getSpan(); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentryPropagator.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentryPropagator.java index ffcab9ed541..4016debf2bb 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentryPropagator.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentryPropagator.java @@ -16,6 +16,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryTraceHeader; import io.sentry.exception.InvalidSentryTraceHeaderException; +import io.sentry.util.TracingUtils; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -98,6 +99,17 @@ public Context extract( try { SentryTraceHeader sentryTraceHeader = new SentryTraceHeader(sentryTraceString); + final @Nullable String baggageString = getter.get(carrier, BaggageHeader.BAGGAGE_HEADER); + Baggage baggage = Baggage.fromHeader(baggageString); + if (!TracingUtils.shouldContinueTrace(scopes.getOptions(), baggage)) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, "Not continuing trace due to strict org ID validation failure."); + return context; + } + SpanContext otelSpanContext = SpanContext.createFromRemoteParent( sentryTraceHeader.getTraceId().toString(), @@ -107,9 +119,6 @@ public Context extract( @NotNull Context modifiedContext = context.with(SentryOtelKeys.SENTRY_TRACE_KEY, sentryTraceHeader); - - final @Nullable String baggageString = getter.get(carrier, BaggageHeader.BAGGAGE_HEADER); - Baggage baggage = Baggage.fromHeader(baggageString); modifiedContext = modifiedContext.with(SentryOtelKeys.SENTRY_BAGGAGE_KEY, baggage); Span wrappedSpan = Span.wrap(otelSpanContext); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java index 5493ba033cb..1a9e8724ca6 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java @@ -91,7 +91,8 @@ public SamplingResult shouldSample( final @NotNull PropagationContext propagationContext = sentryTraceHeader == null ? new PropagationContext(new SentryId(traceId), randomSpanId, null, baggage, null) - : PropagationContext.fromHeaders(sentryTraceHeader, baggage, randomSpanId); + : PropagationContext.fromHeaders( + sentryTraceHeader, baggage, randomSpanId, scopes.getOptions()); final @NotNull TransactionContext transactionContext = TransactionContext.fromPropagationContext(propagationContext); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java index 268b8231a81..2583f4a0469 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java @@ -12,6 +12,7 @@ import io.opentelemetry.sdk.trace.data.StatusData; import io.opentelemetry.sdk.trace.export.SpanExporter; import io.opentelemetry.semconv.HttpAttributes; +import io.opentelemetry.semconv.incubating.MessagingIncubatingAttributes; import io.opentelemetry.semconv.incubating.ProcessIncubatingAttributes; import io.opentelemetry.semconv.incubating.ThreadIncubatingAttributes; import io.sentry.Baggage; @@ -33,7 +34,10 @@ import io.sentry.SpanStatus; import io.sentry.TransactionContext; import io.sentry.TransactionOptions; +import io.sentry.featureflags.IFeatureFlagBuffer; import io.sentry.protocol.Contexts; +import io.sentry.protocol.FeatureFlag; +import io.sentry.protocol.FeatureFlags; import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import java.util.Arrays; @@ -197,7 +201,7 @@ private void createAndFinishSpanForOtelSpan( final @Nullable IOtelSpanWrapper sentrySpanMaybe = spanStorage.getSentrySpan(spanData.getSpanContext()); final @NotNull OtelSpanInfo spanInfo = - spanDescriptionExtractor.extractSpanInfo(spanData, sentrySpanMaybe); + spanDescriptionExtractor.extractSpanInfo(spanData, sentrySpanMaybe, scopes.getOptions()); scopes .getOptions() @@ -260,6 +264,16 @@ private void transferSpanDetails( targetSpan.setData(entry.getKey(), entry.getValue()); } + final @NotNull SpanContext spanContext = sourceSpan.getSpanContext(); + final @NotNull IFeatureFlagBuffer featureFlagBuffer = spanContext.getFeatureFlagBuffer(); + final @Nullable FeatureFlags featureFlags = featureFlagBuffer.getFeatureFlags(); + if (featureFlags != null) { + for (FeatureFlag featureFlag : featureFlags.getValues()) { + targetSpan.setData( + FeatureFlag.DATA_PREFIX + featureFlag.getFlag(), featureFlag.getResult()); + } + } + final @NotNull Map tags = sourceSpan.getTags(); for (Map.Entry entry : tags.entrySet()) { targetSpan.setTag(entry.getKey(), entry.getValue()); @@ -281,7 +295,7 @@ private void transferSpanDetails( final @NotNull IScopes scopesToUse = scopesToUseBeforeForking.forkedCurrentScope("SentrySpanExporter.createTransaction"); final @NotNull OtelSpanInfo spanInfo = - spanDescriptionExtractor.extractSpanInfo(span, sentrySpanMaybe); + spanDescriptionExtractor.extractSpanInfo(span, sentrySpanMaybe, scopesToUse.getOptions()); scopesToUse .getOptions() @@ -297,6 +311,7 @@ private void transferSpanDetails( @NotNull TransactionNameSource transactionNameSource = spanInfo.getTransactionNameSource(); @Nullable SpanId parentSpanId = null; @Nullable Baggage baggage = null; + @NotNull SentryId profilerId = SentryId.EMPTY_ID; if (sentrySpanMaybe != null) { final @NotNull IOtelSpanWrapper sentrySpan = sentrySpanMaybe; @@ -312,6 +327,7 @@ private void transferSpanDetails( final @NotNull SpanContext spanContext = sentrySpan.getSpanContext(); parentSpanId = spanContext.getParentSpanId(); baggage = spanContext.getBaggage(); + profilerId = spanContext.getProfilerId(); } final @NotNull TransactionContext transactionContext = @@ -324,6 +340,7 @@ private void transferSpanDetails( transactionContext.setTransactionNameSource(transactionNameSource); transactionContext.setOperation(spanInfo.getOp()); transactionContext.setInstrumenter(Instrumenter.SENTRY); + transactionContext.setProfilerId(profilerId); if (sentrySpanMaybe != null) { transactionContext.setSamplingDecision(sentrySpanMaybe.getSamplingDecision()); transactionOptions.setOrigin(sentrySpanMaybe.getSpanContext().getOrigin()); @@ -345,6 +362,19 @@ private void transferSpanDetails( maybeTransferOtelAttribute(span, sentryTransaction, ThreadIncubatingAttributes.THREAD_ID); maybeTransferOtelAttribute(span, sentryTransaction, ThreadIncubatingAttributes.THREAD_NAME); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_SYSTEM); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_MESSAGE_ID); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_MESSAGE_BODY_SIZE); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_MESSAGE_ENVELOPE_SIZE); + scopesToUse.configureScope( ScopeType.CURRENT, scope -> attributesExtractor.extract(span, scope, scopesToUse.getOptions())); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java index 2b650ef9dd2..31bd6368318 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java @@ -127,7 +127,10 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri new SentryId(traceData.getTraceId()), spanId, null, null, null) : TransactionContext.fromPropagationContext( PropagationContext.fromHeaders( - traceData.getSentryTraceHeader(), traceData.getBaggage(), spanId)); + traceData.getSentryTraceHeader(), + traceData.getBaggage(), + spanId, + scopes.getOptions())); ; transactionContext.setName(transactionName); transactionContext.setTransactionNameSource(transactionNameSource); @@ -294,7 +297,7 @@ private boolean isSentryRequest(final @NotNull ReadableSpan otelSpan) { private void updateTransactionWithOtelData( final @NotNull ITransaction sentryTransaction, final @NotNull ReadableSpan otelSpan) { final @NotNull OtelSpanInfo otelSpanInfo = - spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null); + spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null, scopes.getOptions()); sentryTransaction.setOperation(otelSpanInfo.getOp()); String transactionName = otelSpanInfo.getDescription(); sentryTransaction.setName( @@ -331,7 +334,7 @@ private void updateSpanWithOtelData( }); final @NotNull OtelSpanInfo otelSpanInfo = - spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null); + spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null, scopes.getOptions()); sentrySpan.setOperation(otelSpanInfo.getOp()); sentrySpan.setDescription(otelSpanInfo.getDescription()); } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java index b66555d68c9..3af3d8f96f0 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java @@ -7,6 +7,8 @@ import io.opentelemetry.semconv.UrlAttributes; import io.opentelemetry.semconv.incubating.DbIncubatingAttributes; import io.opentelemetry.semconv.incubating.HttpIncubatingAttributes; +import io.opentelemetry.semconv.incubating.MessagingIncubatingAttributes; +import io.sentry.SentryOptions; import io.sentry.protocol.TransactionNameSource; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -17,9 +19,19 @@ public final class SpanDescriptionExtractor { @SuppressWarnings("deprecation") public @NotNull OtelSpanInfo extractSpanInfo( - final @NotNull SpanData otelSpan, final @Nullable IOtelSpanWrapper sentrySpan) { + final @NotNull SpanData otelSpan, + final @Nullable IOtelSpanWrapper sentrySpan, + final @NotNull SentryOptions options) { final @NotNull Attributes attributes = otelSpan.getAttributes(); + if (options.isEnableQueueTracing()) { + final @Nullable String messagingSystem = + attributes.get(MessagingIncubatingAttributes.MESSAGING_SYSTEM); + if (messagingSystem != null) { + return descriptionForMessagingSystem(otelSpan); + } + } + final @Nullable String httpMethod = attributes.get(HttpAttributes.HTTP_REQUEST_METHOD); if (httpMethod != null) { return descriptionForHttpMethod(otelSpan, httpMethod); @@ -91,6 +103,53 @@ private static boolean isRootSpan(SpanData otelSpan) { return !otelSpan.getParentSpanContext().isValid() || otelSpan.getParentSpanContext().isRemote(); } + @SuppressWarnings("deprecation") + private OtelSpanInfo descriptionForMessagingSystem(final @NotNull SpanData otelSpan) { + final @NotNull Attributes attributes = otelSpan.getAttributes(); + final @NotNull String op = opForMessaging(otelSpan); + final @Nullable String destination = + attributes.get(MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME); + final @NotNull String description = destination != null ? destination : otelSpan.getName(); + return new OtelSpanInfo(op, description, TransactionNameSource.TASK); + } + + @SuppressWarnings("deprecation") + private @NotNull String opForMessaging(final @NotNull SpanData otelSpan) { + final @NotNull Attributes attributes = otelSpan.getAttributes(); + @Nullable + String operationType = attributes.get(MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE); + if (operationType == null) { + operationType = attributes.get(MessagingIncubatingAttributes.MESSAGING_OPERATION); + } + if (operationType != null) { + switch (operationType) { + case "publish": + case "send": + return "queue.publish"; + case "create": + return "queue.create"; + case "receive": + return "queue.receive"; + case "process": + case "deliver": + return "queue.process"; + case "settle": + return "queue.settle"; + default: + break; + } + } + + final @NotNull SpanKind kind = otelSpan.getKind(); + if (SpanKind.PRODUCER.equals(kind)) { + return "queue.publish"; + } + if (SpanKind.CONSUMER.equals(kind)) { + return "queue.process"; + } + return "queue"; + } + @SuppressWarnings("deprecation") private OtelSpanInfo descriptionForDbSystem(final @NotNull SpanData otelSpan) { final @NotNull Attributes attributes = otelSpan.getAttributes(); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt index 5cc37d80f9c..6d37240f0b2 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt @@ -1,8 +1,7 @@ package io.sentry.opentelemetry import io.opentelemetry.api.common.AttributeKey -import io.opentelemetry.sdk.internal.AttributesMap -import io.opentelemetry.sdk.trace.SpanLimits +import io.opentelemetry.api.common.Attributes import io.opentelemetry.sdk.trace.data.SpanData import io.opentelemetry.semconv.HttpAttributes import io.opentelemetry.semconv.ServerAttributes @@ -22,12 +21,12 @@ import org.mockito.kotlin.whenever class OpenTelemetryAttributesExtractorTest { private class Fixture { val spanData = mock() - val attributes = AttributesMap.create(100, SpanLimits.getDefault().maxAttributeValueLength) + var attributes: Attributes = Attributes.empty() val options = SentryOptions.empty() val scope = Scope(options) init { - whenever(spanData.attributes).thenReturn(attributes) + whenever(spanData.attributes).thenAnswer { attributes } } } @@ -346,7 +345,22 @@ class OpenTelemetryAttributesExtractorTest { } private fun givenAttributes(map: Map, Any>) { - map.forEach { k, v -> fixture.attributes.put(k, v) } + fixture.attributes = buildAttributes(map) + } + + private fun buildAttributes(map: Map, Any>): Attributes { + val builder = Attributes.builder() + map.forEach { (key, value) -> putAttribute(builder, key, value) } + return builder.build() + } + + @Suppress("UNCHECKED_CAST") + private fun putAttribute( + builder: io.opentelemetry.api.common.AttributesBuilder, + key: AttributeKey, + value: Any, + ) { + builder.put(key as AttributeKey, value) } private fun whenExtractingAttributes() { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt index bc453be6c1a..63a6c77c520 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt @@ -1,8 +1,8 @@ package io.sentry.opentelemetry import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.SpanKind -import io.opentelemetry.sdk.internal.AttributesMap import io.opentelemetry.semconv.HttpAttributes import io.opentelemetry.semconv.ServerAttributes import io.opentelemetry.semconv.UrlAttributes @@ -17,7 +17,7 @@ import org.mockito.kotlin.whenever class OtelInternalSpanDetectionUtilTest { private class Fixture { val scopes = mock() - val attributes = AttributesMap.create(100, 100) + var attributes: Attributes = Attributes.empty() val options = SentryOptions.empty() var spanKind: SpanKind = SpanKind.INTERNAL @@ -152,7 +152,22 @@ class OtelInternalSpanDetectionUtilTest { } private fun givenAttributes(map: Map, Any>) { - map.forEach { k, v -> fixture.attributes.put(k, v) } + fixture.attributes = buildAttributes(map) + } + + private fun buildAttributes(map: Map, Any>): Attributes { + val builder = Attributes.builder() + map.forEach { (key, value) -> putAttribute(builder, key, value) } + return builder.build() + } + + @Suppress("UNCHECKED_CAST") + private fun putAttribute( + builder: io.opentelemetry.api.common.AttributesBuilder, + key: AttributeKey, + value: Any, + ) { + builder.put(key as AttributeKey, value) } private fun givenDsn(dsn: String) { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSentryPropagatorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSentryPropagatorTest.kt index 2315412fd46..6b9e733157d 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSentryPropagatorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSentryPropagatorTest.kt @@ -19,6 +19,7 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertSame @@ -38,6 +39,8 @@ class OtelSentryPropagatorTest { @AfterTest fun cleanup() { spanStorage.clear() + Sentry.close() + Context.root().makeCurrent() } @Test @@ -69,6 +72,26 @@ class OtelSentryPropagatorTest { assertSame(scopeInContext, scopes) } + @Test + fun `ignores incoming headers when strict continuation rejects org id`() { + Sentry.init { options -> + options.dsn = "https://key@o2.ingest.sentry.io/123" + options.isStrictTraceContinuation = true + } + val propagator = OtelSentryPropagator() + val carrier: Map = + mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + "baggage" to "sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-org_id=1", + ) + + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + assertFalse(Span.fromContext(newContext).spanContext.isValid) + assertNull(newContext.get(SENTRY_TRACE_KEY)) + assertNull(newContext.get(SENTRY_BAGGAGE_KEY)) + } + @Test fun `uses incoming headers`() { val propagator = OtelSentryPropagator() diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSpanWrapperTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSpanWrapperTest.kt new file mode 100644 index 00000000000..98d3989aaaf --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSpanWrapperTest.kt @@ -0,0 +1,60 @@ +package io.sentry.opentelemetry + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.TraceFlags +import io.opentelemetry.api.trace.TraceState +import io.opentelemetry.sdk.trace.ReadWriteSpan +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.ISpanFactory +import io.sentry.Instrumenter +import io.sentry.SentryLongDate +import io.sentry.SentryOptions +import io.sentry.SpanOptions +import kotlin.test.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class OtelSpanWrapperTest { + @Test + fun `startChild with timestamp forwards timestamp and Sentry instrumenter`() { + val otelSpan = mock() + whenever(otelSpan.spanContext) + .thenReturn( + SpanContext.create( + "2722d9f6ec019ade60c776169d9a8904", + "cedf5b7571cb4972", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + ) + whenever(otelSpan.name).thenReturn("parent") + + val spanFactory = mock() + val options = SentryOptions().apply { this.spanFactory = spanFactory } + val scopes = mock() + whenever(scopes.options).thenReturn(options) + + val parent = OtelSpanWrapper(otelSpan, scopes, SentryLongDate(0), null, null, null, null) + val expectedChild = mock() + whenever(spanFactory.createSpan(eq(scopes), any(), any(), eq(parent))).thenReturn(expectedChild) + val timestamp = SentryLongDate(1234) + + val child = parent.startChild("child-op", "description", timestamp) + + val spanOptions = argumentCaptor() + val spanContext = argumentCaptor() + verify(spanFactory) + .createSpan(eq(scopes), spanOptions.capture(), spanContext.capture(), eq(parent)) + assertThat(child).isSameInstanceAs(expectedChild) + assertThat(spanOptions.firstValue.startTimestamp).isSameInstanceAs(timestamp) + assertThat(spanContext.firstValue.operation).isEqualTo("child-op") + assertThat(spanContext.firstValue.description).isEqualTo("description") + assertThat(spanContext.firstValue.instrumenter).isEqualTo(Instrumenter.SENTRY) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentryPropagatorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentryPropagatorTest.kt new file mode 100644 index 00000000000..29e820acff0 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentryPropagatorTest.kt @@ -0,0 +1,48 @@ +package io.sentry.opentelemetry + +import io.opentelemetry.api.trace.Span +import io.opentelemetry.context.Context +import io.sentry.Sentry +import io.sentry.opentelemetry.SentryOtelKeys.SENTRY_BAGGAGE_KEY +import io.sentry.opentelemetry.SentryOtelKeys.SENTRY_TRACE_KEY +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class SentryPropagatorTest { + + @BeforeTest + fun setup() { + Sentry.init("https://key@sentry.io/proj") + } + + @AfterTest + fun teardown() { + Sentry.close() + Context.root().makeCurrent() + } + + @Suppress("DEPRECATION") + @Test + fun `ignores incoming headers when strict continuation rejects org id`() { + Sentry.init { options -> + options.dsn = "https://key@o2.ingest.sentry.io/123" + options.isStrictTraceContinuation = true + } + + val propagator = SentryPropagator() + val carrier: Map = + mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + "baggage" to "sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-org_id=1", + ) + + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + assertFalse(Span.fromContext(newContext).spanContext.isValid) + assertNull(newContext.get(SENTRY_TRACE_KEY)) + assertNull(newContext.get(SENTRY_BAGGAGE_KEY)) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt index af04914e278..a43afb849e6 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt @@ -1,16 +1,18 @@ package io.sentry.opentelemetry import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.SpanContext import io.opentelemetry.api.trace.SpanKind import io.opentelemetry.api.trace.TraceFlags import io.opentelemetry.api.trace.TraceState -import io.opentelemetry.sdk.internal.AttributesMap import io.opentelemetry.sdk.trace.data.SpanData import io.opentelemetry.semconv.HttpAttributes import io.opentelemetry.semconv.UrlAttributes import io.opentelemetry.semconv.incubating.DbIncubatingAttributes import io.opentelemetry.semconv.incubating.HttpIncubatingAttributes +import io.opentelemetry.semconv.incubating.MessagingIncubatingAttributes +import io.sentry.SentryOptions import io.sentry.protocol.TransactionNameSource import kotlin.test.Test import kotlin.test.assertEquals @@ -22,14 +24,14 @@ class SpanDescriptionExtractorTest { private class Fixture { val sentrySpan = mock() val otelSpan = mock() - val attributes = AttributesMap.create(100, 100) + var attributes: Attributes = Attributes.empty() var parentSpanContext = SpanContext.getInvalid() var spanKind = SpanKind.INTERNAL var spanName: String? = null var spanDescription: String? = null fun setup() { - whenever(otelSpan.attributes).thenReturn(attributes) + whenever(otelSpan.attributes).thenAnswer { attributes } whenever(otelSpan.parentSpanContext).thenReturn(parentSpanContext) whenever(otelSpan.kind).thenReturn(spanKind) spanName?.let { whenever(otelSpan.name).thenReturn(it) } @@ -228,6 +230,250 @@ class SpanDescriptionExtractorTest { assertEquals(TransactionNameSource.TASK, info.transactionNameSource) } + @Test + fun `ignores messaging system when queue tracing disabled`() { + givenSpanName("my-topic publish") + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = false) + + assertEquals("my-topic publish", info.op) + assertEquals("my-topic publish", info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `maps messaging publish operation type to queue publish op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging send operation type to queue publish op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "send", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging process operation type to queue process op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "process", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.process", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging deliver operation type to queue process op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "deliver", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.process", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging create operation type to queue create op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "create", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.create", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging receive operation type to queue receive op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "receive", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.receive", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging settle operation type to queue settle op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "rabbitmq", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-queue", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "settle", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.settle", info.op) + assertEquals("my-queue", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `falls back to legacy messaging operation attribute`() { + @Suppress("DEPRECATION") + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "rabbitmq", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "queue-name", + MessagingIncubatingAttributes.MESSAGING_OPERATION to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("queue-name", info.description) + } + + @Test + fun `falls back to PRODUCER span kind when no operation attribute`() { + givenSpanKind(SpanKind.PRODUCER) + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-topic", info.description) + } + + @Test + fun `falls back to CONSUMER span kind when no operation attribute`() { + givenSpanKind(SpanKind.CONSUMER) + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.process", info.op) + assertEquals("my-topic", info.description) + } + + @Test + fun `falls back to span name as description when destination missing`() { + givenSpanName("my-topic publish") + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-topic publish", info.description) + } + + @Test + fun `messaging mapping wins over http when both attributes present and queue tracing enabled`() { + // Some OTel instrumentations (e.g. aws-sdk-2.2 SQS) attach both messaging and http + // attributes to the same span. Messaging is more specific and must win. + givenSpanKind(SpanKind.PRODUCER) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "POST", + UrlAttributes.URL_FULL to "https://sqs.us-east-1.amazonaws.com/", + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "aws.sqs", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-queue", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-queue", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `http mapping wins over messaging when queue tracing disabled`() { + givenSpanKind(SpanKind.CLIENT) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "POST", + UrlAttributes.URL_FULL to "https://sqs.us-east-1.amazonaws.com/", + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "aws.sqs", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-queue", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = false) + + assertEquals("http.client", info.op) + assertEquals("POST https://sqs.us-east-1.amazonaws.com/", info.description) + assertEquals(TransactionNameSource.URL, info.transactionNameSource) + } + @Test fun `uses span name as op and description if no relevant attributes`() { givenSpanName("span name") @@ -271,12 +517,28 @@ class SpanDescriptionExtractorTest { } private fun givenAttributes(map: Map, Any>) { - map.forEach { k, v -> fixture.attributes.put(k, v) } + fixture.attributes = buildAttributes(map) + } + + private fun buildAttributes(map: Map, Any>): Attributes { + val builder = Attributes.builder() + map.forEach { (key, value) -> putAttribute(builder, key, value) } + return builder.build() + } + + @Suppress("UNCHECKED_CAST") + private fun putAttribute( + builder: io.opentelemetry.api.common.AttributesBuilder, + key: AttributeKey, + value: Any, + ) { + builder.put(key as AttributeKey, value) } - private fun whenExtractingSpanInfo(): OtelSpanInfo { + private fun whenExtractingSpanInfo(queueTracingEnabled: Boolean = false): OtelSpanInfo { fixture.setup() - return SpanDescriptionExtractor().extractSpanInfo(fixture.otelSpan, fixture.sentrySpan) + val options = SentryOptions().apply { isEnableQueueTracing = queueTracingEnabled } + return SpanDescriptionExtractor().extractSpanInfo(fixture.otelSpan, fixture.sentrySpan, options) } private fun givenParentContext(parentContext: SpanContext) { diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/README.md b/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/README.md new file mode 100644 index 00000000000..1ce3ced134a --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/README.md @@ -0,0 +1,7 @@ +# sentry-opentelemetry-otlp-spring + +This module combines `sentry-opentelemetry-otlp` with the OpenTelemetry Spring Boot Starter for a simpler setup in Spring Boot applications. + +It is intended for setups where OpenTelemetry handles tracing (with spans exported via OTLP to Sentry) while Sentry handles errors, logs, metrics, and other products. + +Please consult the documentation on how to install and use this integration in the [Sentry Docs for Java](https://docs.sentry.io/platforms/java/opentelemetry/setup/otlp/). diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts new file mode 100644 index 00000000000..8a5093a6570 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `java-library` + id("io.sentry.animalsniffer") + id("io.sentry.javadoc") +} + +dependencies { + api(projects.sentryOpentelemetry.sentryOpentelemetryOtlp) + implementation(libs.springboot3.otel) +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_OPENTELEMETRY_OTLP_SPRING_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-opentelemetry-otlp-spring", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/README.md b/sentry-opentelemetry/sentry-opentelemetry-otlp/README.md new file mode 100644 index 00000000000..c729ce27629 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/README.md @@ -0,0 +1,7 @@ +# sentry-opentelemetry-otlp + +This module provides a lightweight integration for using OpenTelemetry alongside the Sentry SDK. It reads trace and span IDs from the OpenTelemetry `Context` so that Sentry events (errors, logs, metrics) are correlated with OpenTelemetry traces. + +Unlike the other `sentry-opentelemetry-*` modules, this module does not rely on OpenTelemetry for scope storage or span creation. It is intended for setups where OpenTelemetry handles performance/tracing and Sentry handles errors, logs, metrics, and other products. + +Please consult the documentation on how to install and use this integration in the [Sentry Docs for Java](https://docs.sentry.io/platforms/java/). diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-otlp.api b/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-otlp.api new file mode 100644 index 00000000000..56e80e60ae6 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-otlp.api @@ -0,0 +1,22 @@ +public final class io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor : io/sentry/EventProcessor { + public fun ()V + 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 final class io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator : io/opentelemetry/context/propagation/TextMapPropagator { + public static final field SENTRY_BAGGAGE_KEY Lio/opentelemetry/context/ContextKey; + public fun ()V + public fun extract (Lio/opentelemetry/context/Context;Ljava/lang/Object;Lio/opentelemetry/context/propagation/TextMapGetter;)Lio/opentelemetry/context/Context; + public fun fields ()Ljava/util/Collection; + public fun inject (Lio/opentelemetry/context/Context;Ljava/lang/Object;Lio/opentelemetry/context/propagation/TextMapSetter;)V +} + +public final class io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagatorProvider : io/opentelemetry/sdk/autoconfigure/spi/ConfigurablePropagatorProvider { + public fun ()V + public fun getName ()Ljava/lang/String; + public fun getPropagator (Lio/opentelemetry/sdk/autoconfigure/spi/ConfigProperties;)Lio/opentelemetry/context/propagation/TextMapPropagator; +} + diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts new file mode 100644 index 00000000000..ec240c681ae --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -0,0 +1,63 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + id("io.sentry.animalsniffer") + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + +dependencies { + api(projects.sentry) + + api(libs.otel) + api(libs.otel.extension.autoconfigure) + api(libs.otel.exporter.otlp) + compileOnly(libs.otel.extension.autoconfigure.spi) + implementation(libs.otel.semconv) + // compileOnly(libs.otel.semconv.incubating) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.awaitility.kotlin) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + + testImplementation(libs.otel) + // testImplementation(libs.otel.semconv) + // testImplementation(libs.otel.semconv.incubating) +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_OPENTELEMETRY_OTLP_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-opentelemetry-otlp", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java new file mode 100644 index 00000000000..ad8b672c7de --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java @@ -0,0 +1,120 @@ +package io.sentry.opentelemetry.otlp; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanId; +import io.opentelemetry.api.trace.TraceId; +import io.sentry.EventProcessor; +import io.sentry.Hint; +import io.sentry.IScopes; +import io.sentry.ScopesAdapter; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryLogEvent; +import io.sentry.SentryMetricsEvent; +import io.sentry.SpanContext; +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +public final class OpenTelemetryOtlpEventProcessor implements EventProcessor { + + private final @NotNull IScopes scopes; + + public OpenTelemetryOtlpEventProcessor() { + this(ScopesAdapter.getInstance()); + } + + @TestOnly + OpenTelemetryOtlpEventProcessor(final @NotNull IScopes scopes) { + this.scopes = scopes; + } + + @Override + public @Nullable SentryEvent process(final @NotNull SentryEvent event, final @NotNull Hint hint) { + @NotNull final Span otelSpan = Span.current(); + @NotNull final String traceId = otelSpan.getSpanContext().getTraceId(); + @NotNull final String spanId = otelSpan.getSpanContext().getSpanId(); + + if (TraceId.isValid(traceId) && SpanId.isValid(spanId)) { + final @NotNull SpanContext spanContext = + new SpanContext( + new SentryId(traceId), new io.sentry.SpanId(spanId), "opentelemetry", null, null); + + event.getContexts().setTrace(spanContext); + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Linking Sentry event %s to span %s created via OpenTelemetry (trace %s).", + event.getEventId(), + spanId, + traceId); + } else { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not linking Sentry event %s to any transaction created via OpenTelemetry as traceId %s or spanId %s are invalid.", + event.getEventId(), + traceId, + spanId); + } + + return event; + } + + @Override + public @Nullable SentryLogEvent process(@NotNull SentryLogEvent event) { + @NotNull final Span otelSpan = Span.current(); + @NotNull final String traceId = otelSpan.getSpanContext().getTraceId(); + @NotNull final String spanId = otelSpan.getSpanContext().getSpanId(); + + if (TraceId.isValid(traceId) && SpanId.isValid(spanId)) { + event.setTraceId(new SentryId(traceId)); + event.setSpanId(new io.sentry.SpanId(spanId)); + } else { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not linking Sentry event to any transaction created via OpenTelemetry as traceId %s or spanId %s are invalid.", + traceId, + spanId); + } + + return event; + } + + @Override + public @Nullable SentryMetricsEvent process( + @NotNull SentryMetricsEvent event, @NotNull Hint hint) { + @NotNull final Span otelSpan = Span.current(); + @NotNull final String traceId = otelSpan.getSpanContext().getTraceId(); + @NotNull final String spanId = otelSpan.getSpanContext().getSpanId(); + + if (TraceId.isValid(traceId) && SpanId.isValid(spanId)) { + event.setTraceId(new SentryId(traceId)); + event.setSpanId(new io.sentry.SpanId(spanId)); + } else { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not linking Sentry event to any transaction created via OpenTelemetry as traceId %s or spanId %s are invalid.", + traceId, + spanId); + } + + return event; + } + + @Override + public @Nullable Long getOrder() { + return 6000L; + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java new file mode 100644 index 00000000000..4cdf1b0ed09 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java @@ -0,0 +1,194 @@ +package io.sentry.opentelemetry.otlp; + +import static io.sentry.SentryTraceHeader.SENTRY_TRACE_HEADER; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.ContextKey; +import io.opentelemetry.context.propagation.TextMapGetter; +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.context.propagation.TextMapSetter; +import io.opentelemetry.sdk.trace.ReadableSpan; +import io.opentelemetry.semconv.ServerAttributes; +import io.opentelemetry.semconv.UrlAttributes; +import io.sentry.Baggage; +import io.sentry.BaggageHeader; +import io.sentry.IScopes; +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.SentryTraceHeader; +import io.sentry.exception.InvalidSentryTraceHeaderException; +import io.sentry.util.PropagationTargetsUtils; +import io.sentry.util.TracingUtils; +import java.net.URL; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class OpenTelemetryOtlpPropagator implements TextMapPropagator { + + private static final @NotNull List FIELDS = + Arrays.asList(SENTRY_TRACE_HEADER, BaggageHeader.BAGGAGE_HEADER); + + public static final @NotNull ContextKey SENTRY_BAGGAGE_KEY = + ContextKey.named("sentry.baggage"); + private final @NotNull IScopes scopes; + + public OpenTelemetryOtlpPropagator() { + this(ScopesAdapter.getInstance()); + } + + OpenTelemetryOtlpPropagator(final @NotNull IScopes scopes) { + this.scopes = scopes; + } + + @Override + public Collection fields() { + return FIELDS; + } + + @Override + public void inject(final Context context, final C carrier, final TextMapSetter setter) { + final @NotNull Span otelSpan = Span.fromContext(context); + final @NotNull SpanContext otelSpanContext = otelSpan.getSpanContext(); + if (!otelSpanContext.isValid()) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not injecting Sentry tracing information for invalid OpenTelemetry span."); + return; + } + + if (!shouldInjectTracingHeaders(otelSpan)) { + return; + } + + setter.set( + carrier, + SENTRY_TRACE_HEADER, + otelSpanContext.getTraceId() + + "-" + + otelSpanContext.getSpanId() + + "-" + + (otelSpanContext.isSampled() ? "1" : "0")); + + final @Nullable Baggage baggage = context.get(SENTRY_BAGGAGE_KEY); + if (baggage != null) { + setter.set(carrier, BaggageHeader.BAGGAGE_HEADER, baggage.toHeaderString(null)); + } + } + + private boolean shouldInjectTracingHeaders(final @NotNull Span otelSpan) { + final @NotNull SentryOptions options = scopes.getOptions(); + final @Nullable String url = extractUrl(otelSpan, options); + + return url == null + || PropagationTargetsUtils.contain(options.getTracePropagationTargets(), url); + } + + private @Nullable String extractUrl( + final @NotNull Span otelSpan, final @NotNull SentryOptions options) { + if (!(otelSpan instanceof ReadableSpan)) { + return null; + } + + final @NotNull Attributes attributes = ((ReadableSpan) otelSpan).getAttributes(); + final @Nullable String urlFull = attributes.get(UrlAttributes.URL_FULL); + if (urlFull != null) { + return urlFull; + } + + final @Nullable String scheme = attributes.get(UrlAttributes.URL_SCHEME); + final @Nullable String serverAddress = attributes.get(ServerAttributes.SERVER_ADDRESS); + final @Nullable Long serverPort = attributes.get(ServerAttributes.SERVER_PORT); + final @Nullable String path = attributes.get(UrlAttributes.URL_PATH); + + if (scheme == null || serverAddress == null) { + return null; + } + + try { + final @NotNull String pathToUse = path == null ? "" : path; + if (serverPort == null) { + return new URL(scheme, serverAddress, pathToUse).toString(); + } else { + return new URL(scheme, serverAddress, serverPort.intValue(), pathToUse).toString(); + } + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.WARNING, "Unable to combine URL span attributes into one.", t); + return null; + } + } + + @Override + public Context extract( + final Context context, final C carrier, final TextMapGetter getter) { + final @Nullable String sentryTraceString = getter.get(carrier, SENTRY_TRACE_HEADER); + if (sentryTraceString == null) { + return context; + } + + try { + SentryTraceHeader sentryTraceHeader = new SentryTraceHeader(sentryTraceString); + + final @Nullable String baggageString = getter.get(carrier, BaggageHeader.BAGGAGE_HEADER); + final @Nullable Baggage baggage = + baggageString == null ? null : Baggage.fromHeader(baggageString); + if (!TracingUtils.shouldContinueTrace(scopes.getOptions(), baggage)) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, "Not continuing trace due to strict org ID validation failure."); + return context; + } + final @NotNull TraceState traceState = TraceState.getDefault(); + + final @NotNull TraceFlags traceFlags = + Boolean.FALSE.equals(sentryTraceHeader.isSampled()) + ? TraceFlags.getDefault() + : TraceFlags.getSampled(); + + SpanContext otelSpanContext = + SpanContext.createFromRemoteParent( + sentryTraceHeader.getTraceId().toString(), + sentryTraceHeader.getSpanId().toString(), + traceFlags, + traceState); + + Span wrappedSpan = Span.wrap(otelSpanContext); + + @NotNull Context modifiedContext = context.with(wrappedSpan); + if (baggage != null) { + modifiedContext = modifiedContext.with(SENTRY_BAGGAGE_KEY, baggage); + } + + scopes + .getOptions() + .getLogger() + .log(SentryLevel.DEBUG, "Continuing Sentry trace %s", sentryTraceHeader.getTraceId()); + + return modifiedContext; + } catch (InvalidSentryTraceHeaderException e) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.ERROR, + "Unable to extract Sentry tracing information from invalid header.", + e); + return context; + } + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagatorProvider.java b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagatorProvider.java new file mode 100644 index 00000000000..503729a750d --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagatorProvider.java @@ -0,0 +1,17 @@ +package io.sentry.opentelemetry.otlp; + +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider; + +public final class OpenTelemetryOtlpPropagatorProvider implements ConfigurablePropagatorProvider { + @Override + public TextMapPropagator getPropagator(ConfigProperties config) { + return new OpenTelemetryOtlpPropagator(); + } + + @Override + public String getName() { + return "sentry"; + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider new file mode 100644 index 00000000000..0bd359e1395 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider @@ -0,0 +1 @@ +io.sentry.opentelemetry.otlp.OpenTelemetryOtlpPropagatorProvider diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt new file mode 100644 index 00000000000..afa728c7ff0 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt @@ -0,0 +1,323 @@ +package io.sentry.opentelemetry.otlp + +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.TraceFlags +import io.opentelemetry.api.trace.TraceState +import io.opentelemetry.context.Context +import io.opentelemetry.context.propagation.TextMapGetter +import io.opentelemetry.context.propagation.TextMapSetter +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.sentry.Baggage +import io.sentry.Sentry +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OpenTelemetryOtlpPropagatorTest { + + @BeforeTest + fun setup() { + Sentry.init("https://key@sentry.io/proj") + } + + @AfterTest + fun teardown() { + Sentry.close() + Context.root().makeCurrent() + } + + @Test + fun `propagator registers for sentry-trace and baggage`() { + val propagator = OpenTelemetryOtlpPropagator() + assertEquals(listOf("sentry-trace", "baggage"), propagator.fields()) + } + + @Test + fun `invalid sentry trace header returns context without modification`() { + val propagator = OpenTelemetryOtlpPropagator() + val carrier: Map = + mapOf( + "sentry-trace" to "wrong", + "baggage" to + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + ) + val scopeInContext = Sentry.forkedRootScopes("test") + + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + val baggage = newContext.get(OpenTelemetryOtlpPropagator.SENTRY_BAGGAGE_KEY) + assertNull(baggage) + } + + @Test + fun `ignores incoming headers when strict continuation rejects org id`() { + Sentry.init { options -> + options.dsn = "https://key@o2.ingest.sentry.io/123" + options.isStrictTraceContinuation = true + } + val propagator = OpenTelemetryOtlpPropagator() + val carrier: Map = + mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + "baggage" to "sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-org_id=1", + ) + + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + assertFalse(Span.fromContext(newContext).spanContext.isValid) + assertNull(newContext.get(OpenTelemetryOtlpPropagator.SENTRY_BAGGAGE_KEY)) + } + + @Test + fun `uses incoming headers`() { + val propagator = OpenTelemetryOtlpPropagator() + val carrier: Map = + mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + "baggage" to + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + ) + + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + val span = Span.fromContext(newContext) + assertTrue(span.spanContext.isValid) + assertEquals("f9118105af4a2d42b4124532cd1065ff", span.spanContext.traceId) + assertEquals("424cffc8f94feeee", span.spanContext.spanId) + + val baggage = newContext.get(OpenTelemetryOtlpPropagator.SENTRY_BAGGAGE_KEY) + assertEquals("production", baggage?.environment) + } + + @Test + fun `extract sets sampled trace flag when sentry-trace has sampled=0`() { + val propagator = OpenTelemetryOtlpPropagator() + val carrier: Map = + mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-0", + "baggage" to + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + ) + + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + val span = Span.fromContext(newContext) + assertTrue(span.spanContext.isValid) + assertFalse(span.spanContext.traceFlags.isSampled) + } + + @Test + fun `extract sets sampled trace flag when sentry-trace has no sampling decision`() { + val propagator = OpenTelemetryOtlpPropagator() + val carrier: Map = + mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee", + "baggage" to + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + ) + + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + val span = Span.fromContext(newContext) + assertTrue(span.spanContext.isValid) + assertTrue(span.spanContext.traceFlags.isSampled) + } + + @Test + fun `extract does not store baggage in context when baggage header is missing`() { + val propagator = OpenTelemetryOtlpPropagator() + val carrier: Map = + mapOf("sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1") + + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + val span = Span.fromContext(newContext) + assertTrue(span.spanContext.isValid) + + val baggage = newContext.get(OpenTelemetryOtlpPropagator.SENTRY_BAGGAGE_KEY) + assertNull(baggage) + } + + @Test + fun `injects headers`() { + val propagator = OpenTelemetryOtlpPropagator() + val carrier = mutableMapOf() + + val otelSpanContext = + SpanContext.create( + "f9118105af4a2d42b4124532cd1065ff", + "424cffc8f94feeee", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + val otelSpan = Span.wrap(otelSpanContext) + val baggage = + Baggage.fromHeader( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + val context = + Context.root().with(otelSpan).with(OpenTelemetryOtlpPropagator.SENTRY_BAGGAGE_KEY, baggage) + + propagator.inject(context, carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertEquals( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + carrier["baggage"], + ) + } + + @Test + fun `injects headers if URL in span attributes matches tracePropagationTargets`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("sentry.io")) + } + val propagator = OpenTelemetryOtlpPropagator() + val carrier = mutableMapOf() + val tracerProvider = SdkTracerProvider.builder().build() + val otelSpan = + tracerProvider + .get("test") + .spanBuilder("test") + .setAttribute("url.full", "https://sentry.io/api/0/") + .startSpan() + val baggage = + Baggage.fromHeader( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + + try { + val context = + Context.root().with(otelSpan).with(OpenTelemetryOtlpPropagator.SENTRY_BAGGAGE_KEY, baggage) + + propagator.inject(context, carrier, MapSetter()) + } finally { + otelSpan.end() + tracerProvider.shutdown() + } + + assertEquals( + "${otelSpan.spanContext.traceId}-${otelSpan.spanContext.spanId}-1", + carrier["sentry-trace"], + ) + assertEquals( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + carrier["baggage"], + ) + } + + @Test + fun `does not inject headers if URL in span attributes does not match tracePropagationTargets`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("github.com")) + } + val propagator = OpenTelemetryOtlpPropagator() + val carrier = mutableMapOf() + val tracerProvider = SdkTracerProvider.builder().build() + val otelSpan = + tracerProvider + .get("test") + .spanBuilder("test") + .setAttribute("url.full", "https://sentry.io/api/0/") + .startSpan() + + try { + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + } finally { + otelSpan.end() + tracerProvider.shutdown() + } + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `injects headers if tracePropagationTargets is restricted and URL is unavailable`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("sentry.io")) + } + val propagator = OpenTelemetryOtlpPropagator() + val carrier = mutableMapOf() + + val otelSpanContext = + SpanContext.create( + "f9118105af4a2d42b4124532cd1065ff", + "424cffc8f94feeee", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + val otelSpan = Span.wrap(otelSpanContext) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers when no span in context`() { + val propagator = OpenTelemetryOtlpPropagator() + val carrier = mutableMapOf() + + propagator.inject(Context.root(), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers when span is invalid`() { + val propagator = OpenTelemetryOtlpPropagator() + val carrier = mutableMapOf() + + propagator.inject(Context.root().with(Span.getInvalid()), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject baggage header when baggage is missing from context`() { + val propagator = OpenTelemetryOtlpPropagator() + val carrier = mutableMapOf() + + val otelSpanContext = + SpanContext.create( + "f9118105af4a2d42b4124532cd1065ff", + "424cffc8f94feeee", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + val otelSpan = Span.wrap(otelSpanContext) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } +} + +class MapGetter : TextMapGetter> { + override fun keys(carrier: Map): MutableIterable { + return carrier.keys.toMutableList() + } + + override fun get(carrier: Map?, key: String): String? { + return carrier?.get(key) + } +} + +class MapSetter : TextMapSetter> { + override fun set(carrier: MutableMap?, key: String, value: String) { + carrier?.put(key, value) + } +} diff --git a/sentry-quartz/build.gradle.kts b/sentry-quartz/build.gradle.kts index f81254f110f..f4f0d9d07d2 100644 --- a/sentry-quartz/build.gradle.kts +++ b/sentry-quartz/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -36,27 +36,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-reactor/build.gradle.kts b/sentry-reactor/build.gradle.kts index 9e8b6e74be9..615ce38ecc5 100644 --- a/sentry-reactor/build.gradle.kts +++ b/sentry-reactor/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -44,27 +43,6 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter") } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.reactor") @@ -84,8 +62,6 @@ tasks.withType().configureEach { } } -repositories { mavenCentral() } - tasks.jar { manifest { attributes( diff --git a/sentry-reactor/src/main/java/io/sentry/reactor/SentryReactorThreadLocalAccessor.java b/sentry-reactor/src/main/java/io/sentry/reactor/SentryReactorThreadLocalAccessor.java index 7ef4bb9bd1e..d2b841abe70 100644 --- a/sentry-reactor/src/main/java/io/sentry/reactor/SentryReactorThreadLocalAccessor.java +++ b/sentry-reactor/src/main/java/io/sentry/reactor/SentryReactorThreadLocalAccessor.java @@ -16,7 +16,7 @@ public Object key() { @Override public IScopes getValue() { - return Sentry.getCurrentScopes(); + return Sentry.getCurrentScopes(false); } @Override diff --git a/sentry-samples/sentry-samples-android/README.md b/sentry-samples/sentry-samples-android/README.md new file mode 100644 index 00000000000..99d0edcd1c3 --- /dev/null +++ b/sentry-samples/sentry-samples-android/README.md @@ -0,0 +1,72 @@ +# Sentry Sample Android App + +Sample application demonstrating how to use the Sentry Android SDK, including core functionality (error reporting, tracing, session replay, +profiling) and integrations (Compose, OkHttp, SQLite, etc.). + +## How to run it? + +Install the app on your device or emulator: + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug +``` + +or simply open the project in Android Studio and run the `sentry-samples-android` configuration. + +You can also apply the [Sentry Android Gradle Plugin](https://github.com/getsentry/sentry-android-gradle-plugin) (SAGP) when building (not applied by default): + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp +``` + +In Android Studio, add `useSagp=` (empty value) to `gradle.properties`, or pass `-PuseSagp` as a Gradle project property. + +## Build modes + +### With or without SAGP + +The sample app can be built with or without the SAGP. + +| Gradle Property | Required | Purpose | +|-----------------|----------|-------------------------------------------------------------------------------------------------| +| `useSagp` | No | When present, apply SAGP when building the sample app. Omit the property to build without SAGP. | + +You can configure SAGP properties via the lambda passed to `extensions.configure("sentry")` in the sample app's +`build.gradle.kts` file. + +### Testing an unpublished SAGP build + +`-PuseSagp` builds check `mavenLocal()` first when resolving SAGP. To test a local SAGP branch: + +1. In your `sentry-android-gradle-plugin` checkout, temporarily set a unique local version in `plugin-build/gradle.properties` (e.g. + `6.10.0-LOCAL`) and publish to Maven Local: + +``` +./gradlew -p plugin-build publishToMavenLocal +``` + +Re-run `publishToMavenLocal` after each SAGP change. + +2. Temporarily bump the `sagp` pin in `gradle/libs.versions.toml` to match that version. + +Then build from sentry-java: + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp +``` + +## Viewing SDK output + +### Locally + +Debug builds enable SDK debug logging, so captured envelopes are printed to logcat (tag `Sentry`): + +``` +adb logcat -s Sentry +``` + +### On Sentry UI + +By default, SDK output produced by the sample app appears under the [sentry-sdk test project](https://sentry-sdks.sentry.io/issues/?project=5428559). +To redirect them to your own project, replace the test DSN (i.e., the `io.sentry.dsn` `meta-data` value in `src/main/AndroidManifest.xml` +with your own. diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 48ac6dda5ce..31009f6dbb9 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -1,5 +1,8 @@ import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.variant.BuildConfigField import com.android.build.api.variant.impl.VariantImpl +import io.sentry.android.gradle.extensions.InstrumentationFeature +import io.sentry.android.gradle.extensions.SentryPluginExtension import org.apache.tools.ant.taskdefs.condition.Os import org.gradle.internal.extensions.stdlib.capitalized @@ -7,6 +10,35 @@ plugins { id("com.android.application") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.sentry) apply false + alias(libs.plugins.sqldelight) +} + +if (providers.gradleProperty("useSagp").isPresent) { + apply(plugin = "io.sentry.android.gradle") +} + +plugins.withId("io.sentry.android.gradle") { + // Extension configs match non-SAGP builds. Update locally to test your feature. + extensions.configure("sentry") { + autoInstallation.enabled.set(false) + includeProguardMapping.set(false) + includeDependenciesReport.set(false) + telemetry.set(false) + tracingInstrumentation { + features.set( + setOf( + // FILE_IO is disabled for non-SAGP builds. + InstrumentationFeature.COMPOSE, + InstrumentationFeature.DATABASE, + InstrumentationFeature.OKHTTP, + ) + ) + logcat.enabled.set(false) + appStart.enabled.set(false) + } + } } android { @@ -15,7 +47,8 @@ android { defaultConfig { applicationId = "io.sentry.samples.android" - minSdk = libs.versions.minSdk.get().toInt() + // androidx.sqlite 2.6+ require minSdk 23; the Sentry SDK still supports 21. + minSdk = 23 targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 2 versionName = project.version.toString() @@ -78,7 +111,12 @@ android { buildTypes { getByName("debug") { + // Suffix the id so debug and release builds can be installed side by side. + applicationIdSuffix = ".debug" addManifestPlaceholders(mapOf("sentryDebug" to true, "sentryEnvironment" to "debug")) + // The SDK modules only publish a release variant, so fall back to it for the + // debug build of the sample. + matchingFallbacks += "release" } getByName("release") { isMinifyEnabled = true @@ -90,13 +128,26 @@ android { } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } - - androidComponents.beforeVariants { - it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + // Java 11 b/c androidx.room3 requires it. + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 } + androidComponents.onVariants { variant -> + variant.buildConfigFields?.put( + "USE_SAGP", + providers.provider { + BuildConfigField( + type = "boolean", + value = providers.gradleProperty("useSagp").isPresent.toString(), + comment = "Whether the Sentry Android Gradle Plugin was applied", + ) + }, + ) + val taskName = "toggle${variant.name.capitalized()}NativeLogging" val toggleNativeLoggingTask = project.tasks.register(taskName) { @@ -116,6 +167,17 @@ android { @Suppress("UnstableApiUsage") packagingOptions { jniLibs { useLegacyPackaging = true } } } +sqldelight { + databases { + create("SampleSQLDelightDatabase") { + packageName.set("io.sentry.samples.android.sqlite") + // Keep .sq files next to the hand-written Kotlin (src/main/java/.../sqlite) instead of the + // default src/main/sqldelight source root. + srcDirs("src/main/java") + } + } +} + dependencies { implementation( kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION) @@ -123,10 +185,12 @@ dependencies { implementation(projects.sentryAndroid) implementation(projects.sentryAndroidFragment) + implementation(projects.sentryAndroidSqlite) implementation(projects.sentryAndroidTimber) implementation(projects.sentryCompose) implementation(projects.sentryKotlinExtensions) implementation(projects.sentryOkhttp) + implementation(projects.sentrySpotlight) // how to exclude androidx if release health feature is disabled // implementation(projects.sentryAndroid) { @@ -142,18 +206,29 @@ dependencies { implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.recyclerview) + implementation(libs.androidx.browser) + implementation(libs.androidx.room3.runtime) + implementation(libs.bundles.androidx.room2) + implementation(libs.bundles.androidx.sqlite.drivers) + implementation(libs.camerax.camera2) + implementation(libs.camerax.core) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) implementation(libs.coil.compose) implementation(libs.kotlinx.coroutines.android) + implementation(libs.lottie.compose) implementation(libs.retrofit) implementation(libs.retrofit.gson) implementation(libs.sentry.native.ndk) + implementation(libs.sqldelight.android.driver) implementation(libs.timber) - implementation(libs.camerax.core) - implementation(libs.camerax.camera2) - implementation(libs.camerax.lifecycle) - implementation(libs.camerax.view) + + ksp(libs.androidx.room.compiler) + ksp(libs.androidx.room3.compiler) debugImplementation(projects.sentryAndroidDistribution) debugImplementation(libs.leakcanary) diff --git a/sentry-samples/sentry-samples-android/proguard-rules.pro b/sentry-samples/sentry-samples-android/proguard-rules.pro index 1165340c893..5f4016f8f72 100644 --- a/sentry-samples/sentry-samples-android/proguard-rules.pro +++ b/sentry-samples/sentry-samples-android/proguard-rules.pro @@ -32,3 +32,15 @@ -dontwarn org.openjsse.javax.net.ssl.SSLParameters -dontwarn org.openjsse.javax.net.ssl.SSLSocket -dontwarn org.openjsse.net.ssl.OpenJSSE + +# Retrofit relies on generic signatures for its service methods. Under R8 full mode these are +# stripped for classes that aren't kept, which breaks call-adapter creation (SecondActivity's +# GithubAPI request). Keep the signature attributes and Retrofit's generic types. +-keepattributes Signature, InnerClasses, EnclosingMethod +-keep,allowobfuscation,allowshrinking interface retrofit2.Call +-keep,allowobfuscation,allowshrinking class retrofit2.Response +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface <1> +# Keep the response model so Gson can deserialize it. +-keep class io.sentry.samples.android.Repo { *; } diff --git a/sentry-samples/sentry-samples-android/src/debug/res/values/strings.xml b/sentry-samples/sentry-samples-android/src/debug/res/values/strings.xml new file mode 100644 index 00000000000..ef5dee529b6 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + Sentry Sample Debug + DEBUG + diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index c6360ca911d..ac53c538de5 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -1,188 +1,321 @@ + xmlns:tools="http://schemas.android.com/tools"> - - + + - + - - - - + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:name=".MyApplication" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:networkSecurityConfig="@xml/network" + android:roundIcon="@mipmap/ic_launcher_round" + android:theme="@style/AppTheme" + tools:ignore="GoogleAppIndexingWarning, UnusedAttribute"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp b/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp index de1f0f0d3e1..6b9e6e89d87 100644 --- a/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp +++ b/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp @@ -6,10 +6,18 @@ extern "C" { +// Faults inside this named function so the crashing frame resolves to a real +// symbol + source line. A bare raise(SIGSEGV) would instead fault in libc and, +// for a JNI-originated crash, not exercise app-native symbolication. +[[gnu::noinline]] +static void trigger_null_deref() { + volatile int *ptr = nullptr; + *ptr = 42; +} + JNIEXPORT void JNICALL Java_io_sentry_samples_android_NativeSample_crash(JNIEnv *env, jclass cls) { __android_log_print(ANDROID_LOG_WARN, TAG, "About to crash."); - char *ptr = 0; - *ptr += 1; + trigger_null_deref(); } JNIEXPORT void JNICALL Java_io_sentry_samples_android_NativeSample_message(JNIEnv *env, jclass cls) { @@ -17,7 +25,7 @@ JNIEXPORT void JNICALL Java_io_sentry_samples_android_NativeSample_message(JNIEn sentry_value_t event = sentry_value_new_message_event( /* level */ SENTRY_LEVEL_INFO, /* logger */ "custom", - /* message */ "It works!" + /* message */ "Native Capture button: native message" ); sentry_capture_event(event); } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/CustomTabsActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/CustomTabsActivity.java new file mode 100644 index 00000000000..4f615137ede --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/CustomTabsActivity.java @@ -0,0 +1,35 @@ +package io.sentry.samples.android; + +import android.net.Uri; +import android.os.Bundle; +import androidx.appcompat.app.AppCompatActivity; +import androidx.browser.customtabs.CustomTabColorSchemeParams; +import androidx.browser.customtabs.CustomTabsIntent; +import androidx.core.content.ContextCompat; + +public class CustomTabsActivity extends AppCompatActivity { + + private static final String DEMO_URL = "https://www.sentry.io/"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); + + CustomTabColorSchemeParams params = + new CustomTabColorSchemeParams.Builder() + .setToolbarColor(ContextCompat.getColor(this, R.color.colorPrimary)) + .build(); + builder.setDefaultColorSchemeParams(params); + + builder.setShowTitle(true); + builder.setShareState(CustomTabsIntent.SHARE_STATE_ON); + builder.setInstantAppsEnabled(true); + + CustomTabsIntent customTabsIntent = builder.build(); + customTabsIntent.launchUrl(this, Uri.parse(DEMO_URL)); + + finish(); + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt new file mode 100644 index 00000000000..3a38814c5d8 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt @@ -0,0 +1,50 @@ +package io.sentry.samples.android + +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit + +class DetachAttachTabsActivity : AppCompatActivity(R.layout.activity_detach_attach_tabs) { + + private val tags = arrayOf("tab_a", "tab_b") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + findViewById(R.id.btn_tab_a).setOnClickListener { showTab(0) } + findViewById(R.id.btn_tab_b).setOnClickListener { showTab(1) } + + if (savedInstanceState == null) { + val tabB = TabFragmentB() + supportFragmentManager.commit { + add(R.id.tab_container, TabFragmentA(), tags[0]) + add(R.id.tab_container, tabB, tags[1]) + detach(tabB) + } + } + } + + private fun showTab(index: Int) { + supportFragmentManager.commit { + for (i in tags.indices) { + val frag = supportFragmentManager.findFragmentByTag(tags[i]) ?: continue + if (i == index) attach(frag) else detach(frag) + } + } + } +} + +class TabFragmentA : Fragment(R.layout.fragment_tab) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById(R.id.tab_label).text = "Tab A" + } +} + +class TabFragmentB : Fragment(R.layout.fragment_tab) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById(R.id.tab_label).text = "Tab B" + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java deleted file mode 100644 index 62280ecd763..00000000000 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ /dev/null @@ -1,372 +0,0 @@ -package io.sentry.samples.android; - -import android.content.Intent; -import android.content.pm.ActivityInfo; -import android.content.res.Configuration; -import android.os.Bundle; -import android.os.Handler; -import android.widget.Toast; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import io.sentry.Attachment; -import io.sentry.ISpan; -import io.sentry.MeasurementUnit; -import io.sentry.Sentry; -import io.sentry.SentryLogLevel; -import io.sentry.UpdateStatus; -import io.sentry.instrumentation.file.SentryFileOutputStream; -import io.sentry.protocol.Feedback; -import io.sentry.protocol.User; -import io.sentry.samples.android.compose.ComposeActivity; -import io.sentry.samples.android.databinding.ActivityMainBinding; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.channels.FileChannel; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import timber.log.Timber; - -public class MainActivity extends AppCompatActivity { - - private int crashCount = 0; - private int screenLoadCount = 0; - - final Object mutex = new Object(); - - @Override - @SuppressWarnings("deprecation") - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - SharedState.INSTANCE.setOrientationChange( - getIntent().getBooleanExtra("isOrientationChange", false)); - final ActivityMainBinding binding = ActivityMainBinding.inflate(getLayoutInflater()); - - final File imageFile = getApplicationContext().getFileStreamPath("sentry.png"); - try (final InputStream inputStream = - getApplicationContext().getResources().openRawResource(R.raw.sentry); - FileOutputStream outputStream = new FileOutputStream(imageFile)) { - final byte[] bytes = new byte[1024]; - while (inputStream.read(bytes) != -1) { - // To keep the sample code simple this happens on the main thread. Don't do this in a - // real app. - outputStream.write(bytes); - } - outputStream.flush(); - } catch (IOException e) { - Sentry.captureException(e); - } - - final Attachment image = new Attachment(imageFile.getAbsolutePath(), "sentry.png", "image/png"); - Sentry.configureScope( - scope -> { - scope.addAttachment(image); - }); - - binding.crashFromJava.setOnClickListener( - view -> { - throw new RuntimeException("Uncaught Exception from Java."); - }); - - binding.sendMessage.setOnClickListener(view -> Sentry.captureMessage("Some message.")); - - binding.sendUserFeedback.setOnClickListener( - view -> { - Feedback feedback = - new Feedback("It broke on Android. I don't know why, but this happens."); - feedback.setContactEmail("john@me.com"); - feedback.setName("John Me"); - Sentry.captureFeedback(feedback); - }); - - binding.addAttachment.setOnClickListener( - view -> { - String fileName = Calendar.getInstance().getTimeInMillis() + "_file.txt"; - File file = getApplication().getFileStreamPath(fileName); - try (final FileOutputStream fos = - SentryFileOutputStream.Factory.create(new FileOutputStream(file), file)) { - FileChannel channel = fos.getChannel(); - channel.write(java.nio.ByteBuffer.wrap("Hello, World!".getBytes())); - } catch (IOException e) { - Sentry.captureException(e); - } - - Sentry.configureScope( - scope -> { - String json = "{ \"number\": 10 }"; - Attachment attachment = new Attachment(json.getBytes(), "log.json"); - scope.addAttachment(attachment); - scope.addAttachment(new Attachment(file.getPath())); - }); - }); - - binding.captureException.setOnClickListener( - view -> - Sentry.captureException( - new Exception(new Exception(new Exception("Some exception."))))); - - binding.breadcrumb.setOnClickListener( - view -> { - Sentry.addBreadcrumb("Breadcrumb"); - Sentry.setExtra("extra", "extra"); - Sentry.setFingerprint(Collections.singletonList("fingerprint")); - Sentry.setTransaction("transaction"); - Sentry.captureException(new Exception("Some exception with scope.")); - }); - - binding.unsetUser.setOnClickListener( - view -> { - Sentry.setTag("user_set", "null"); - Sentry.setUser(null); - }); - - binding.setUser.setOnClickListener( - view -> { - Sentry.setTag("user_set", "instance"); - User user = new User(); - user.setUsername("username_from_java"); - // works with some null properties? - // user.setId("id_from_java"); - user.setEmail("email_from_java"); - // Use the client's IP address - user.setIpAddress("{{auto}}"); - Sentry.setUser(user); - }); - - binding.outOfMemory.setOnClickListener( - view -> { - final CountDownLatch latch = new CountDownLatch(1); - for (int i = 0; i < 20; i++) { - new Thread( - () -> { - final List data = new ArrayList<>(); - try { - latch.await(); - for (int j = 0; j < 1_000_000; j++) { - data.add(new String(new byte[1024 * 8])); - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - }) - .start(); - } - - latch.countDown(); - }); - - binding.stackOverflow.setOnClickListener(view -> stackOverflow()); - - binding.nativeCrash.setOnClickListener(view -> NativeSample.crash()); - - binding.nativeCapture.setOnClickListener(view -> NativeSample.message()); - - binding.anr.setOnClickListener( - view -> { - // Try cause ANR by blocking for 10 seconds. - // By default the SDK sends an event if blocked by at least 5 seconds. - // Keep clicking on the ANR button till you've gotten the "App. isn''t responding" dialog, - // then either click on Wait or Close, at this point you should have seen an event on - // Sentry. - // NOTE: By default it doesn't raise if the debugger is attached. That can also be - // configured. - new Thread( - new Runnable() { - @Override - public void run() { - synchronized (mutex) { - while (true) { - try { - Thread.sleep(10000); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - } - } - }) - .start(); - - new Handler() - .postDelayed( - new Runnable() { - @Override - public void run() { - synchronized (mutex) { - // Shouldn't happen - throw new IllegalStateException(); - } - } - }, - 1000); - }); - - binding.nativeAnr.setOnClickListener( - view -> { - new Thread( - new Runnable() { - @Override - public void run() { - NativeSample.freezeMysteriously(mutex); - } - }) - .start(); - - new Handler() - .postDelayed( - new Runnable() { - @Override - public void run() { - synchronized (mutex) { - // Shouldn't happen - throw new IllegalStateException(); - } - } - }, - 1000); - }); - - binding.openSecondActivity.setOnClickListener( - view -> { - // finishing so its completely destroyed - finish(); - startActivity(new Intent(this, SecondActivity.class)); - }); - - binding.openSampleFragment.setOnClickListener( - view -> SampleFragment.newInstance().show(getSupportFragmentManager(), null)); - - binding.openThirdFragment.setOnClickListener( - view -> startActivity(new Intent(this, ThirdActivityFragment.class))); - - binding.openGesturesActivity.setOnClickListener( - view -> startActivity(new Intent(this, GesturesActivity.class))); - - binding.testTimberIntegration.setOnClickListener( - view -> { - crashCount++; - Timber.i("Some info here"); - Timber.e( - new RuntimeException("Uncaught Exception from Java."), - "Something wrong happened %d times", - crashCount); - }); - - binding.openPermissionsActivity.setOnClickListener( - view -> { - startActivity(new Intent(this, PermissionsActivity.class)); - }); - - binding.openComposeActivity.setOnClickListener( - view -> { - startActivity(new Intent(this, ComposeActivity.class)); - }); - - binding.openProfilingActivity.setOnClickListener( - view -> { - startActivity(new Intent(this, ProfilingActivity.class)); - }); - - binding.openFrameDataForSpans.setOnClickListener( - view -> startActivity(new Intent(this, FrameDataForSpansActivity.class))); - - binding.throwInCoroutine.setOnClickListener( - view -> { - CoroutinesUtil.INSTANCE.throwInCoroutine(); - }); - - binding.showDialog.setOnClickListener( - view -> { - new AlertDialog.Builder(MainActivity.this) - .setTitle("Example Title") - .setMessage("Example Message") - .setPositiveButton( - "Close", - (dialog, which) -> { - if (SharedState.INSTANCE.isOrientationChange()) { - int currentOrientation = getResources().getConfiguration().orientation; - if (currentOrientation == Configuration.ORIENTATION_PORTRAIT) { - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); - } else if (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) { - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); - } - } else { - dialog.dismiss(); - } - }) - .show(); - }); - - binding.enableReplayDebugMode.setOnClickListener( - view -> { - Sentry.replay().enableDebugMaskingOverlay(); - }); - - binding.checkForUpdate.setOnClickListener( - view -> { - Toast.makeText(this, "Checking for updates...", Toast.LENGTH_SHORT).show(); - Sentry.distribution() - .checkForUpdate( - result -> { - runOnUiThread( - () -> { - String message; - if (result instanceof UpdateStatus.NewRelease) { - UpdateStatus.NewRelease newRelease = (UpdateStatus.NewRelease) result; - message = - "Update available: " - + newRelease.getInfo().getBuildVersion() - + " (Build " - + newRelease.getInfo().getBuildNumber() - + ")\nDownload URL: " - + newRelease.getInfo().getDownloadUrl(); - } else if (result instanceof UpdateStatus.UpToDate) { - message = "App is up to date!"; - } else if (result instanceof UpdateStatus.NoNetwork) { - UpdateStatus.NoNetwork noNetwork = (UpdateStatus.NoNetwork) result; - message = "No network connection: " + noNetwork.getMessage(); - } else if (result instanceof UpdateStatus.UpdateError) { - UpdateStatus.UpdateError error = (UpdateStatus.UpdateError) result; - message = "Error checking for updates: " + error.getMessage(); - } else { - message = "Unknown status"; - } - Toast.makeText(this, message, Toast.LENGTH_LONG).show(); - }); - }); - }); - - binding.openCameraActivity.setOnClickListener( - view -> { - startActivity(new Intent(this, CameraXActivity.class)); - }); - - Sentry.logger().log(SentryLogLevel.INFO, "Creating content view"); - setContentView(binding.getRoot()); - - Sentry.logger().log(SentryLogLevel.INFO, "MainActivity created"); - } - - private void stackOverflow() { - stackOverflow(); - } - - @Override - protected void onResume() { - super.onResume(); - screenLoadCount++; - final ISpan span = Sentry.getSpan(); - if (span != null) { - ISpan measurementSpan = span.startChild("screen_load_measurement", "test measurement"); - measurementSpan.setMeasurement( - "screen_load_count", screenLoadCount, new MeasurementUnit.Custom("test")); - measurementSpan.finish(); - } - Sentry.reportFullyDisplayed(); - } -} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt new file mode 100644 index 00000000000..b38f17ed64c --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -0,0 +1,1022 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package io.sentry.samples.android + +import android.annotation.SuppressLint +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.sentry.Attachment +import io.sentry.MeasurementUnit +import io.sentry.Sentry +import io.sentry.SentryLogLevel +import io.sentry.UpdateStatus +import io.sentry.android.core.SentryUserFeedbackForm +import io.sentry.compose.SentryTraced +import io.sentry.protocol.Feedback +import io.sentry.protocol.User +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.Calendar +import java.util.concurrent.CountDownLatch +import kotlinx.coroutines.launch +import timber.log.Timber + +@OptIn(ExperimentalComposeUiApi::class) +class MainActivity : AppCompatActivity() { + + private var screenLoadCount = 0 + internal lateinit var imageFile: File + + @SuppressLint("NewApi") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + SharedState.isOrientationChange = intent.getBooleanExtra("isOrientationChange", false) + + imageFile = createSentryImageFile() + + val image = Attachment(imageFile.absolutePath, "sentry.png", "image/png") + Sentry.configureScope { scope -> scope.addAttachment(image) } + + Sentry.logger().log(SentryLogLevel.INFO, "Creating content view") + + setContent { + val colorScheme = + if (isSystemInDarkTheme()) + darkColorScheme( + primary = Color(resources.getColor(R.color.colorPrimary, theme)), + secondary = Color(resources.getColor(R.color.colorAccent, theme)), + tertiary = Color(resources.getColor(R.color.colorPrimary, theme)), + ) + else + lightColorScheme( + primary = Color(resources.getColor(R.color.colorPrimary, theme)), + secondary = Color(resources.getColor(R.color.colorAccent, theme)), + tertiary = Color(resources.getColor(R.color.colorPrimary, theme)), + ) + MaterialTheme(colorScheme = colorScheme) { MainScreen() } + } + + Sentry.logger().log(SentryLogLevel.INFO, "MainActivity created") + } + + override fun onResume() { + super.onResume() + screenLoadCount++ + val span = Sentry.getSpan() + if (span != null) { + val measurementSpan = span.startChild("screen_load_measurement", "test measurement") + measurementSpan.setMeasurement( + "screen_load_count", + screenLoadCount, + MeasurementUnit.Custom("test"), + ) + measurementSpan.finish() + } + Sentry.reportFullyDisplayed() + } + + private fun createSentryImageFile(): File { + val file = applicationContext.getFileStreamPath("sentry.png") + try { + applicationContext.resources.openRawResource(R.raw.sentry).use { inputStream -> + FileOutputStream(file).use { outputStream -> + val bytes = ByteArray(1024) + var length = inputStream.read(bytes) + while (length != -1) { + outputStream.write(bytes, 0, length) + length = inputStream.read(bytes) + } + outputStream.flush() + } + } + } catch (e: IOException) { + Sentry.captureException(e) + } + return file + } +} + +enum class Category(val displayName: String, val icon: ImageVector) { + ERRORS("Errors", Icons.Filled.Error), + TRACING("Tracing", Icons.Filled.Speed), + SESSION_REPLAY("Session Replay", Icons.Filled.Videocam), + USER_FEEDBACK("User & Feedback", Icons.Filled.Person), + INTEGRATIONS("Integrations", Icons.Filled.Extension), + UPDATES("Updates", Icons.Filled.Settings), +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen() { + var selectedCategory by remember { mutableStateOf(Category.ERRORS) } + + Surface(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxSize()) { + // NavigationRail already draws its background edge-to-edge (behind the status bar) while + // insetting its own items, so we only need to inset the content area on the remaining sides. + CategoryNavigationRail( + selectedCategory = selectedCategory, + onCategorySelected = { selectedCategory = it }, + ) + Surface( + modifier = + Modifier.fillMaxSize() + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Top + WindowInsetsSides.Bottom + WindowInsetsSides.End + ) + ) + ) { + when (selectedCategory) { + Category.ERRORS -> ErrorsScreen() + Category.TRACING -> TracingScreen() + Category.SESSION_REPLAY -> SessionReplayScreen() + Category.USER_FEEDBACK -> UserFeedbackScreen() + Category.INTEGRATIONS -> IntegrationsScreen() + Category.UPDATES -> UpdatesScreen() + } + } + } + } +} + +@Composable +fun CategoryNavigationRail( + selectedCategory: Category, + onCategorySelected: (Category) -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + + NavigationRail( + modifier = + modifier.fillMaxHeight().defaultMinSize(minWidth = 100.dp).verticalScroll(scrollState), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + Spacer(Modifier.height(16.dp)) + val scope = rememberCoroutineScope() + val rotation = remember { Animatable(1f) } + + Icon( + painterResource(R.drawable.sentry_glyph), + contentDescription = "Sentry Logo", + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = + Modifier.size(48.dp) + .shadow(4.dp, shape = CircleShape) + .background(color = MaterialTheme.colorScheme.surfaceBright, shape = CircleShape) + .clickable { scope.launch { rotation.animateTo(rotation.targetValue + 360.0f) } } + .padding(12.dp) + .rotate(rotation.value), + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.build_type), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = + if (BuildConfig.DEBUG) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(16.dp)) + Category.entries.forEach { category -> + NavigationRailItem( + modifier = Modifier.defaultMinSize(minWidth = 100.dp), + selected = selectedCategory == category, + onClick = { onCategorySelected(category) }, + colors = + NavigationRailItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + ), + icon = { Icon(imageVector = category.icon, contentDescription = category.displayName) }, + alwaysShowLabel = true, + label = { + Text( + text = category.displayName, + style = MaterialTheme.typography.labelSmall, + maxLines = 2, + textAlign = TextAlign.Center, + fontWeight = if (selectedCategory == category) FontWeight.Bold else FontWeight.Normal, + ) + }, + ) + } + } +} + +@Composable +fun ErrorsScreen() { + val crashCount = remember { mutableIntStateOf(0) } + val mutex = remember { Object() } + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 180.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + SentryTraced("crash_from_java") { + OutlinedButton( + onClick = { + tagSampleAction("crash_from_java") + throw RuntimeException("Crash from Java button: uncaught RuntimeException") + } + ) { + Text("Crash from Java", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("capture_exception") { + OutlinedButton( + onClick = { + tagSampleAction("capture_exception") + Sentry.captureException( + Exception(Exception(Exception("Capture Exception button: nested exception"))) + ) + }, + modifier = Modifier, + ) { + Text("Capture Exception", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("breadcrumb") { + OutlinedButton( + onClick = { + tagSampleAction("breadcrumb") + Sentry.addBreadcrumb("Breadcrumb button clicked") + Sentry.setExtra("extra", "extra") + Sentry.setFingerprint(listOf("fingerprint")) + Sentry.setTransaction("transaction") + Sentry.captureException(Exception("Breadcrumb button: exception with scope data")) + }, + modifier = Modifier, + ) { + Text("Breadcrumb", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("stack_overflow") { + OutlinedButton( + onClick = { + tagSampleAction("stack_overflow") + stackOverflow() + }, + modifier = Modifier, + ) { + Text("Stack Overflow", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("native_crash") { + OutlinedButton( + onClick = { + tagSampleAction("native_crash") + NativeSample.crash() + }, + modifier = Modifier, + ) { + Text("Native Crash", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("native_capture") { + OutlinedButton( + onClick = { + tagSampleAction("native_capture") + NativeSample.message() + }, + modifier = Modifier, + ) { + Text("Native Capture", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("anr") { + OutlinedButton( + onClick = { + tagSampleAction("anr") + Thread { + synchronized(mutex) { + while (true) { + try { + Thread.sleep(10000) + } catch (e: InterruptedException) { + e.printStackTrace() + } + } + } + } + .start() + + Handler(Looper.getMainLooper()) + .postDelayed( + { + synchronized(mutex) { + throw IllegalStateException("ANR button: main thread blocked") + } + }, + 1000, + ) + }, + modifier = Modifier, + ) { + Text("ANR", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("native_anr") { + OutlinedButton( + onClick = { + tagSampleAction("native_anr") + Thread { NativeSample.freezeMysteriously(mutex) }.start() + + Handler(Looper.getMainLooper()) + .postDelayed( + { + synchronized(mutex) { + throw IllegalStateException("ANR (native) button: main thread blocked") + } + }, + 1000, + ) + }, + modifier = Modifier, + ) { + Text("ANR (native)", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("out_of_memory") { + OutlinedButton( + onClick = { + tagSampleAction("out_of_memory") + val latch = CountDownLatch(1) + for (i in 0 until 20) { + Thread { + val data = ArrayList() + try { + latch.await() + for (j in 0 until 1_000_000) { + data.add(String(ByteArray(1024 * 8))) + } + } catch (e: InterruptedException) { + e.printStackTrace() + } + } + .start() + } + latch.countDown() + }, + modifier = Modifier, + ) { + Text("Out of Memory", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("send_message") { + OutlinedButton( + onClick = { + tagSampleAction("send_message") + Sentry.captureMessage("Send Message button: test message") + }, + modifier = Modifier, + ) { + Text("Send Message", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("test_timber") { + OutlinedButton( + onClick = { + tagSampleAction("test_timber") + crashCount.intValue++ + Timber.i("Test Timber button: info log") + Timber.e( + RuntimeException("Test Timber button: error RuntimeException"), + "Test Timber button: error logged ${crashCount.intValue} times", + ) + }, + modifier = Modifier, + ) { + Text("Test Timber", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +fun TracingScreen() { + val activity = LocalContext.current.getActivity() + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 180.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + SentryTraced("open_second_activity") { + OutlinedButton( + onClick = { + activity.finish() + activity.startActivity(Intent(activity, SecondActivity::class.java)) + }, + modifier = Modifier, + ) { + Text("Open Second Activity", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("open_gestures_activity") { + OutlinedButton( + onClick = { activity.startActivity(Intent(activity, GesturesActivity::class.java)) }, + modifier = Modifier, + ) { + Text("Open Gestures Activity", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("open_frame_data") { + OutlinedButton( + onClick = { + activity.startActivity(Intent(activity, FrameDataForSpansActivity::class.java)) + }, + modifier = Modifier, + ) { + Text("Open Frame Data for Spans", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("open_profiling") { + OutlinedButton( + onClick = { activity.startActivity(Intent(activity, ProfilingActivity::class.java)) }, + modifier = Modifier, + ) { + Text("Open Profiling Activity", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("open_sqlite") { + OutlinedButton( + onClick = { + activity.startActivity( + Intent(activity, io.sentry.samples.android.sqlite.SQLiteActivity::class.java) + ) + }, + modifier = Modifier, + ) { + Text("Open SQLite Activity", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@SuppressLint("SourceLockedOrientationActivity") +@Composable +fun SessionReplayScreen() { + val activity = LocalContext.current.getActivity() + var showDialog by remember { mutableStateOf(false) } + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 180.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + SentryTraced("enable_replay_debug") { + OutlinedButton( + onClick = { Sentry.replay().enableDebugMaskingOverlay() }, + modifier = Modifier, + ) { + Text("Enable Replay Debug Mode", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("open_replay_animations") { + OutlinedButton( + onClick = { + activity.startActivity(Intent(activity, ReplayAnimationsActivity::class.java)) + }, + modifier = Modifier, + ) { + Text("Open Animations", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("show_dialog") { + OutlinedButton(onClick = { showDialog = true }, modifier = Modifier) { + Text("Show Dialog", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + } + + // AlertDialog managed by local state + if (showDialog) { + AlertDialog( + onDismissRequest = { + if (SharedState.isOrientationChange) { + val currentOrientation = activity.resources.configuration.orientation + if (currentOrientation == Configuration.ORIENTATION_PORTRAIT) { + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } else if (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) { + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + } else { + showDialog = false + } + }, + title = { Text("Example Title") }, + text = { Text("Example Message") }, + confirmButton = { + TextButton( + onClick = { + if (SharedState.isOrientationChange) { + val currentOrientation = activity.resources.configuration.orientation + if (currentOrientation == Configuration.ORIENTATION_PORTRAIT) { + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } else if (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) { + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + } else { + showDialog = false + } + } + ) { + Text("Close") + } + }, + ) + } +} + +@Composable +fun UserFeedbackScreen() { + val activity = LocalContext.current.getActivity() + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 180.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + SentryTraced("set_user") { + OutlinedButton( + onClick = { + Sentry.setTag("user_set", "instance") + val user = + User().apply { + username = "username_from_java" + email = "email_from_java" + ipAddress = "{{auto}}" + } + Sentry.setUser(user) + }, + modifier = Modifier, + ) { + Text("Set User", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("unset_user") { + OutlinedButton( + onClick = { + Sentry.setTag("user_set", "null") + Sentry.setUser(null) + }, + modifier = Modifier, + ) { + Text("Unset User", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("add_attachment") { + OutlinedButton( + onClick = { + val fileName = Calendar.getInstance().timeInMillis.toString() + "_file.txt" + val file = activity.application.getFileStreamPath(fileName) + try { + io.sentry.instrumentation.file.SentryFileOutputStream.Factory.create( + FileOutputStream(file), + file, + ) + .use { fos -> fos.write("Hello, World!".toByteArray()) } + } catch (e: IOException) { + Sentry.captureException(e) + } + + Sentry.configureScope { scope -> + val json = "{ \"number\": 10 }" + val attachment = Attachment(json.toByteArray(), "log.json") + scope.addAttachment(attachment) + scope.addAttachment(Attachment(file.path)) + } + }, + modifier = Modifier, + ) { + Text("Add Attachment", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + + // Bring up User Feedback Form from a custom button using the global Sentry.feedback() API + item(span = { GridItemSpan(maxLineSpan) }) { + Button(modifier = Modifier, onClick = { Sentry.feedback().show() }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + painter = + painterResource( + id = io.sentry.compose.R.drawable.sentry_user_feedback_compose_button_logo_24 + ), + contentDescription = null, + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text(text = "Report a Bug") + } + } + } + + // Create a SentryUserFeedbackForm programmatically and show it + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + SentryUserFeedbackForm.Builder(activity) + .configurator { options -> + options.formTitle = "Custom Form" + options.submitButtonLabel = "Send" + options.cancelButtonLabel = "Never mind" + options.messageLabel = "What happened?" + options.messagePlaceholder = "Describe the issue..." + options.isShowBranding = false + options.isNameRequired = true + options.isEmailRequired = true + options.setOnSubmitSuccess { feedback -> + Toast.makeText(activity, "Thanks for the feedback!", Toast.LENGTH_SHORT).show() + } + } + .create() + .show() + }, + ) { + Text(text = "Custom Form (Builder)") + } + } + + // Showcases how to manually show and dismiss a form programmatically + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + val form = + SentryUserFeedbackForm.Builder(activity) + .configurator { options -> options.formTitle = "Quick! You have 2 seconds" } + .create() + form.show() + Handler(Looper.getMainLooper()).postDelayed({ form.dismiss() }, 2000) + }, + ) { + Text(text = "Auto-dismiss Form (2s)") + } + } + + // Send feedback programmatically without showing a form + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + val feedback = + Feedback("The app crashed when I tapped the button").apply { + name = "Jane Doe" + contactEmail = "jane@example.com" + url = "https://example.com/page" + } + val eventId = Sentry.feedback().capture(feedback) + Toast.makeText(activity, "Feedback sent: $eventId", Toast.LENGTH_SHORT).show() + }, + ) { + Text(text = "Send Feedback (no form)") + } + } + + // Toggle shake-to-show at runtime using the global Sentry.feedback() API + item(span = { GridItemSpan(maxLineSpan) }) { + var shakeEnabled by remember { mutableStateOf(Sentry.feedback().isOnShakeEnabled) } + Button( + modifier = Modifier, + onClick = { + if (shakeEnabled) { + Sentry.feedback().disableOnShake() + } else { + Sentry.feedback().enableOnShake() + Toast.makeText(activity, "Shake your device to open the form!", Toast.LENGTH_SHORT) + .show() + } + shakeEnabled = Sentry.feedback().isOnShakeEnabled + }, + ) { + Text(text = if (shakeEnabled) "Disable Shake-to-Show" else "Enable Shake-to-Show") + } + } + } +} + +@Composable +fun IntegrationsScreen() { + val activity = LocalContext.current.getActivity() + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 180.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + SentryTraced("open_compose_activity") { + OutlinedButton( + onClick = { + activity.startActivity( + Intent(activity, io.sentry.samples.android.compose.ComposeActivity::class.java) + ) + }, + modifier = Modifier, + ) { + Text("Open Compose Activity", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("open_sample_fragment") { + OutlinedButton( + onClick = { + SampleFragment.newInstance() + .show((activity as AppCompatActivity).supportFragmentManager, null) + }, + modifier = Modifier, + ) { + Text("Open Sample Fragment", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("open_third_fragment") { + OutlinedButton( + onClick = { activity.startActivity(Intent(activity, ThirdActivityFragment::class.java)) }, + modifier = Modifier, + ) { + Text("Open Third Fragment", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("open_detach_attach_tabs") { + OutlinedButton( + onClick = { + activity.startActivity(Intent(activity, DetachAttachTabsActivity::class.java)) + }, + modifier = Modifier, + ) { + Text("Open Detach/Attach Tabs", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("open_permissions_activity") { + OutlinedButton( + onClick = { activity.startActivity(Intent(activity, PermissionsActivity::class.java)) }, + modifier = Modifier, + ) { + Text("Open Permissions Activity", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("open_custom_tabs_activity") { + OutlinedButton( + onClick = { activity.startActivity(Intent(activity, CustomTabsActivity::class.java)) }, + modifier = Modifier, + ) { + Text("Open Custom Tabs Activity", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("open_camera_activity") { + OutlinedButton( + onClick = { activity.startActivity(Intent(activity, CameraXActivity::class.java)) }, + modifier = Modifier, + ) { + Text("Open Camera Activity", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("open_http_request_activity") { + OutlinedButton( + onClick = { + activity.startActivity(Intent(activity, TriggerHttpRequestActivity::class.java)) + }, + modifier = Modifier, + ) { + Text("Open HTTP Request Activity", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("throw_in_coroutine") { + OutlinedButton(onClick = { CoroutinesUtil.throwInCoroutine() }, modifier = Modifier) { + Text("Throw in Coroutine", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +fun UpdatesScreen() { + val activity = LocalContext.current.getActivity() + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 180.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + SentryTraced("check_for_update") { + OutlinedButton( + onClick = { + Toast.makeText(activity, "Checking for updates...", Toast.LENGTH_SHORT).show() + val future = Sentry.distribution().checkForUpdate() + + Thread { + try { + val result = future.get() + activity.runOnUiThread { + val message = + when (result) { + is UpdateStatus.NewRelease -> { + "Update available: ${result.info.buildVersion} " + + "(Build ${result.info.buildNumber})\n" + + "Download URL: ${result.info.downloadUrl}" + } + + is UpdateStatus.UpToDate -> "App is up to date!" + is UpdateStatus.NoNetwork -> "No network connection: ${result.message}" + is UpdateStatus.UpdateError -> + "Error checking for updates: ${result.message}" + + else -> "Unknown status" + } + Toast.makeText(activity, message, Toast.LENGTH_LONG).show() + } + } catch (e: Exception) { + activity.runOnUiThread { + Toast.makeText( + activity, + "Error checking for updates: ${e.message}", + Toast.LENGTH_LONG, + ) + .show() + } + } + } + .start() + }, + modifier = Modifier, + ) { + Text("Check for Update", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +fun Context.getActivity(): ComponentActivity { + var currentContext = this + while (currentContext is ContextWrapper) { + if (currentContext is ComponentActivity) { + return currentContext + } + currentContext = currentContext.baseContext + } + if (currentContext is ComponentActivity) { + return currentContext + } + throw IllegalArgumentException("Context is not an Activity.") +} + +fun stackOverflow() { + stackOverflow() +} + +private fun tagSampleAction(action: String) { + // Tag every event with the button that triggered it so it can be filtered in Sentry. + Sentry.setTag("sample_action", action) +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java index 572c4cdba72..d9d142cfc65 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java @@ -2,17 +2,28 @@ import android.app.Application; import android.os.StrictMode; +import io.sentry.ISpan; import io.sentry.Sentry; +import io.sentry.samples.android.sqlite.SampleDatabases; /** Apps. main Application. */ public class MyApplication extends Application { @Override public void onCreate() { + // Make Session Replay fail fast instead of silently degrading masking when an exception is + // swallowed (e.g. unsupported/obfuscated Compose internals). This way regressions surface as + // crashes in our release/obfuscated builds that run on real devices in CI. Only meant for our + // own sample/UI-test apps, customers should never set this. + System.setProperty("io.sentry.replay.compose.fail-fast", "true"); Sentry.startProfiler(); strictMode(); super.onCreate(); + extendAppStartExample(); + + SampleDatabases.INSTANCE.warmUp(this); + // Example how to initialize the SDK manually which allows access to SentryOptions callbacks. // Make sure you disable the auto init via manifest meta-data: io.sentry.auto-init=false // SentryAndroid.init( @@ -28,6 +39,35 @@ public void onCreate() { // }); } + // Example of extending the app start: launch-time work done here (after the SDK auto-inits) is + // included in the app start measurement. Requires standalone app start tracing + // (io.sentry.standalone-app-start-tracing.enable in the manifest). The artificial delays stand in + // for real launch work, e.g. loading remote config or feature flags before the first screen. + private void extendAppStartExample() { + Sentry.extendAppStart(); + + final ISpan extendedSpan = Sentry.getExtendedAppStartSpan(); + if (extendedSpan != null) { + final ISpan configSpan = extendedSpan.startChild("remote_config", "Load remote config"); + artificialDelay(200); + configSpan.finish(); + + final ISpan flagsSpan = extendedSpan.startChild("feature_flags", "Fetch feature flags"); + artificialDelay(100); + flagsSpan.finish(); + } + + Sentry.finishExtendedAppStart(); + } + + private static void artificialDelay(final long millis) { + try { + Thread.sleep(millis); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + private void strictMode() { // https://developer.android.com/reference/android/os/StrictMode // StrictMode is a developer tool which detects things you might be doing by accident and diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt index ab63ea04b58..e24822b3e42 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt @@ -1,145 +1,108 @@ package io.sentry.samples.android +import android.os.Build import android.os.Bundle -import android.view.View -import android.widget.SeekBar import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.recyclerview.widget.LinearLayoutManager -import io.sentry.ITransaction -import io.sentry.ProfilingTraceData +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import io.sentry.Sentry -import io.sentry.SentryEnvelopeItem -import io.sentry.samples.android.databinding.ActivityProfilingBinding -import java.io.ByteArrayOutputStream -import java.io.File -import java.util.UUID import java.util.concurrent.Executors -import java.util.zip.GZIPOutputStream -class ProfilingActivity : AppCompatActivity() { - private lateinit var binding: ActivityProfilingBinding +class ProfilingActivity : ComponentActivity() { + private val executors = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()) private var profileFinished = true override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityProfilingBinding.inflate(layoutInflater) - - binding.profilingDurationSeekbar.setOnSeekBarChangeListener( - object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(p0: SeekBar, p1: Int, p2: Boolean) { - binding.profilingDurationText.text = - getString(R.string.profiling_duration, getProfileDuration()) - } - - override fun onStartTrackingTouch(p0: SeekBar) {} - - override fun onStopTrackingTouch(p0: SeekBar) {} - } - ) - binding.profilingDurationText.text = - getString(R.string.profiling_duration, getProfileDuration()) - - binding.profilingThreadsSeekbar.setOnSeekBarChangeListener( - object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(p0: SeekBar, p1: Int, p2: Boolean) { - binding.profilingThreadsText.text = - getString(R.string.profiling_threads, getBackgroundThreads()) - } - - override fun onStartTrackingTouch(p0: SeekBar) {} - - override fun onStopTrackingTouch(p0: SeekBar) {} - } - ) - binding.profilingThreadsSeekbar.max = Runtime.getRuntime().availableProcessors() - 1 - binding.profilingThreadsText.text = - getString(R.string.profiling_threads, getBackgroundThreads()) - - binding.profilingList.adapter = ProfilingListAdapter() - binding.profilingList.layoutManager = LinearLayoutManager(this) - - binding.profilingStart.setOnClickListener { - binding.profilingProgressBar.visibility = View.VISIBLE - profileFinished = false - val seconds = getProfileDuration() - val threads = getBackgroundThreads() - val t = Sentry.startTransaction("Profiling Test", "$seconds s - $threads threads") - repeat(threads) { executors.submit { runMathOperations() } } - executors.submit { swipeList() } - - Thread { - Thread.sleep((seconds * 1000).toLong()) - finishTransactionAndPrintResults(t) - binding.root.post { binding.profilingProgressBar.visibility = View.GONE } - } - .start() - } - setContentView(binding.root) - Sentry.reportFullyDisplayed() + setContent { MaterialTheme { ProfilingScreen() } } } - private fun finishTransactionAndPrintResults(t: ITransaction) { - t.finish() - profileFinished = true - val profilesDirPath = Sentry.getCurrentScopes().options.profilingTracesDirPath - if (profilesDirPath == null) { - Toast.makeText(this, R.string.profiling_no_dir_set, Toast.LENGTH_SHORT).show() - return - } - - // We have concurrent profiling now. We have to wait for all transactions to finish (e.g. button - // click) - // before reading the profile, otherwise it's empty and a crash occurs - if (Sentry.getSpan() != null) { - val timeout = Sentry.getCurrentScopes().options.idleTimeout ?: 0 - val duration = (getProfileDuration() * 1000).toLong() - Thread.sleep((timeout - duration).coerceAtLeast(0)) - } - - try { - // Get the last trace file, which is the current profile - val origProfileFile = File(profilesDirPath).listFiles()?.maxByOrNull { f -> f.lastModified() } - // Create a new profile file and copy the content of the original file into it - val profile = File(cacheDir, UUID.randomUUID().toString()) - origProfileFile?.copyTo(profile) - - val profileLength = profile.length() - val traceData = ProfilingTraceData(profile, t) - // Create envelope item from copied profile - val item = - SentryEnvelopeItem.fromProfilingTrace( - traceData, - Long.MAX_VALUE, - Sentry.getCurrentScopes().options.serializer, - ) - val itemData = item.data - - // Compress the envelope item using Gzip - val bos = ByteArrayOutputStream() - GZIPOutputStream(bos).bufferedWriter().use { it.write(String(itemData)) } - - binding.root.post { - binding.profilingResult.text = - getString(R.string.profiling_result, profileLength, itemData.size, bos.toByteArray().size) + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun ProfilingScreen() { + val context = LocalContext.current + val options = remember { Sentry.getCurrentScopes().options } + val isPerfetto = remember { Build.VERSION.SDK_INT >= 35 } + val isContinuousEnabled = remember { options.isContinuousProfilingEnabled } + + var showProgress by remember { mutableStateOf(false) } + var manualActive by remember { mutableStateOf(false) } + + val statusText = + when { + !isContinuousEnabled -> stringResource(R.string.profiling_status_none) + isPerfetto -> stringResource(R.string.profiling_status_perfetto) + else -> stringResource(R.string.profiling_status_legacy) } - } catch (e: Exception) { - e.printStackTrace() - } - } - private fun swipeList() { - while (!profileFinished) { - if ( - (binding.profilingList.layoutManager as? LinearLayoutManager) - ?.findFirstVisibleItemPosition() == 0 + Scaffold(topBar = { TopAppBar(title = { Text("Profiling") }) }) { innerPadding -> + Column( + modifier = Modifier.fillMaxSize().padding(innerPadding).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - binding.profilingList.smoothScrollToPosition(100) - } else { - binding.profilingList.smoothScrollToPosition(0) + Text(text = statusText, fontWeight = FontWeight.Bold) + + Text("profiling.enable-legacy-profiling: ${options.isEnableLegacyProfiling}") + Text("Build.VERSION.SDK_INT: ${Build.VERSION.SDK_INT}") + Text("traces.profiling.session-sample-rate: ${options.profileSessionSampleRate}") + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + Button( + onClick = { + if (!manualActive) { + Sentry.startProfiler() + manualActive = true + profileFinished = false + showProgress = true + + val threads = 2 + repeat(threads) { executors.submit { runMathOperations() } } + + Toast.makeText(context, R.string.profiling_manual_started, Toast.LENGTH_SHORT).show() + } else { + Sentry.stopProfiler() + manualActive = false + profileFinished = true + showProgress = false + + Toast.makeText(context, R.string.profiling_manual_stopped, Toast.LENGTH_SHORT).show() + } + } + ) { + Text( + if (manualActive) stringResource(R.string.profiling_stop_manual) + else stringResource(R.string.profiling_start_manual) + ) + } + + if (showProgress) { + CircularProgressIndicator() + } } - Thread.sleep(3000) } } @@ -151,29 +114,8 @@ class ProfilingActivity : AppCompatActivity() { private fun fibonacci(n: Int): Int = when { - profileFinished -> n // If we destroy the activity we stop this function + profileFinished -> n n <= 1 -> 1 else -> fibonacci(n - 1) + fibonacci(n - 2) } - - override fun onBackPressed() { - if (profileFinished) { - super.onBackPressed() - } else { - Toast.makeText(this, R.string.profiling_running, Toast.LENGTH_SHORT).show() - } - } - - private fun getProfileDuration(): Float { - // Minimum duration of the profile is 100 milliseconds - return binding.profilingDurationSeekbar.progress / 10.0F + 0.1F - } - - private fun getBackgroundThreads(): Int { - // Minimum duration of the profile is 100 milliseconds - return binding.profilingThreadsSeekbar.progress.coerceIn( - 0, - Runtime.getRuntime().availableProcessors() - 1, - ) - } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt deleted file mode 100644 index bf025118c80..00000000000 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.sentry.samples.android - -import android.graphics.Bitmap -import android.graphics.Color -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.ImageView -import androidx.recyclerview.widget.RecyclerView -import io.sentry.samples.android.databinding.ProfilingItemListBinding -import kotlin.random.Random - -class ProfilingListAdapter : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val binding = - ProfilingItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ViewHolder(binding) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.imageView.setImageBitmap(generateBitmap()) - } - - @Suppress("MagicNumber") - private fun generateBitmap(): Bitmap { - val bitmapSize = 128 - val colors = - (0 until (bitmapSize * bitmapSize)) - .map { Color.rgb(Random.nextInt(256), Random.nextInt(256), Random.nextInt(256)) } - .toIntArray() - return Bitmap.createBitmap(colors, bitmapSize, bitmapSize, Bitmap.Config.ARGB_8888) - } - - // Disables view recycling. - override fun getItemViewType(position: Int): Int = position - - override fun getItemCount(): Int = 200 -} - -class ViewHolder(binding: ProfilingItemListBinding) : RecyclerView.ViewHolder(binding.root) { - val imageView: ImageView = binding.benchmarkItemListImage -} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt new file mode 100644 index 00000000000..0fe6cda581f --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt @@ -0,0 +1,302 @@ +package io.sentry.samples.android + +import android.animation.Animator +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Color as AndroidColor +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.animation.LinearInterpolator +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin + +class ReplayAnimationsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val primaryColor = Color(ContextCompat.getColor(this, R.color.colorPrimary)) + val accentColor = Color(ContextCompat.getColor(this, R.color.colorAccent)) + val colorScheme = + if (isSystemInDarkTheme()) + darkColorScheme(primary = primaryColor, secondary = accentColor, tertiary = primaryColor) + else + lightColorScheme(primary = primaryColor, secondary = accentColor, tertiary = primaryColor) + + MaterialTheme(colorScheme = colorScheme) { ReplayAnimationsScreen(onClose = { finish() }) } + } + } +} + +@Composable +private fun ReplayAnimationsScreen(onClose: () -> Unit) { + var selectedSample by remember { mutableStateOf(null) } + + BackHandler(enabled = selectedSample != null) { selectedSample = null } + + selectedSample?.let { sample -> + ReplayAnimationDetailScreen(sample = sample, onBack = { selectedSample = null }) + return + } + + Column( + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Button(onClick = onClose, modifier = Modifier.align(Alignment.End)) { Text("Close") } + Text( + text = "Replay animations", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + ReplayAnimationSample.entries.forEach { sample -> + Button(onClick = { selectedSample = sample }, modifier = Modifier.fillMaxWidth()) { + Text(sample.title) + } + } + } +} + +@Composable +private fun ReplayAnimationDetailScreen(sample: ReplayAnimationSample, onBack: () -> Unit) { + Column( + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Button(onClick = onBack, modifier = Modifier.align(Alignment.End)) { Text("Back") } + Text( + text = sample.title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + Surface( + modifier = Modifier.fillMaxWidth().height(420.dp), + shape = RoundedCornerShape(8.dp), + tonalElevation = 2.dp, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + when (sample) { + ReplayAnimationSample.LOTTIE -> LottieReplayAnimation() + ReplayAnimationSample.COMPOSE_CANVAS -> ComposeCanvasAnimation() + ReplayAnimationSample.ANDROID_VIEWS -> + AndroidView( + factory = { context -> ClassicAnimationLayout(context) }, + modifier = Modifier.fillMaxWidth().height(360.dp), + ) + } + } + } + } +} + +private enum class ReplayAnimationSample(val title: String) { + LOTTIE("Lottie"), + COMPOSE_CANVAS("Compose canvas"), + ANDROID_VIEWS("Android views"), +} + +@Composable +private fun LottieReplayAnimation() { + val composition by + rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.replay_lottie_pulse)) + val progress by + animateLottieCompositionAsState( + composition = composition, + iterations = LottieConstants.IterateForever, + ) + + LottieAnimation( + composition = composition, + progress = { progress }, + modifier = Modifier.fillMaxSize(), + ) +} + +@Composable +private fun ComposeCanvasAnimation() { + val transition = rememberInfiniteTransition() + val angle by + transition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = + infiniteRepeatable( + animation = tween(1600, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) + val pulse by + transition.animateFloat( + initialValue = 0.25f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(900, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + ) + + Canvas( + modifier = + Modifier.fillMaxWidth().height(160.dp).background(Color(0xFF101820), RoundedCornerShape(8.dp)) + ) { + val center = Offset(size.width / 2f, size.height / 2f) + val orbitRadius = min(size.width, size.height) * 0.32f + val ballRadius = min(size.width, size.height) * 0.1f + val radians = angle / 180f * PI.toFloat() + + drawCircle( + color = Color(0xFF8BE9FD), + radius = orbitRadius * pulse, + center = center, + style = Stroke(width = 5.dp.toPx()), + alpha = 0.55f, + ) + drawCircle( + color = Color(0xFFFF6B6B), + radius = ballRadius, + center = Offset(center.x + cos(radians) * orbitRadius, center.y + sin(radians) * orbitRadius), + ) + drawCircle( + color = Color(0xFFFFD166), + radius = ballRadius * 0.75f, + center = + Offset( + center.x + cos(radians + PI.toFloat()) * orbitRadius, + center.y + sin(radians + PI.toFloat()) * orbitRadius, + ), + ) + } +} + +private class ClassicAnimationLayout(context: Context) : FrameLayout(context) { + private val movingDot = + View(context).apply { background = ovalDrawable(AndroidColor.rgb(255, 107, 107)) } + private val rotatingSquare = + View(context).apply { background = roundedRectDrawable(AndroidColor.rgb(139, 233, 253), dp(8)) } + private val scalingBar = + View(context).apply { background = roundedRectDrawable(AndroidColor.rgb(255, 209, 102), dp(6)) } + private val animators: List + + init { + setBackgroundColor(AndroidColor.rgb(16, 24, 32)) + clipChildren = false + clipToPadding = false + + addView(scalingBar, LayoutParams(dp(180), dp(18), Gravity.CENTER).apply { topMargin = dp(116) }) + addView(rotatingSquare, LayoutParams(dp(64), dp(64), Gravity.CENTER)) + addView(movingDot, LayoutParams(dp(48), dp(48), Gravity.CENTER)) + + animators = + listOf( + ObjectAnimator.ofFloat(movingDot, View.TRANSLATION_X, -dp(92).toFloat(), dp(92).toFloat()) + .repeatable(durationMillis = 900, mode = ValueAnimator.REVERSE), + ObjectAnimator.ofFloat(movingDot, View.TRANSLATION_Y, -dp(28).toFloat(), dp(28).toFloat()) + .repeatable(durationMillis = 650, mode = ValueAnimator.REVERSE), + ObjectAnimator.ofFloat(rotatingSquare, View.ROTATION, 0f, 360f) + .repeatable(durationMillis = 1200), + ObjectAnimator.ofFloat(scalingBar, View.SCALE_X, 0.25f, 1f) + .repeatable(durationMillis = 800, mode = ValueAnimator.REVERSE), + ) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + animators.forEach { animator -> + if (!animator.isStarted) { + animator.start() + } + } + } + + override fun onDetachedFromWindow() { + animators.forEach { it.cancel() } + super.onDetachedFromWindow() + } + + private fun ObjectAnimator.repeatable( + durationMillis: Long, + mode: Int = ValueAnimator.RESTART, + ): ObjectAnimator = apply { + duration = durationMillis + interpolator = LinearInterpolator() + repeatCount = ValueAnimator.INFINITE + repeatMode = mode + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt() + + private fun ovalDrawable(color: Int): GradientDrawable = + GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(color) + } + + private fun roundedRectDrawable(color: Int, radius: Int): GradientDrawable = + GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = radius.toFloat() + setColor(color) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java new file mode 100644 index 00000000000..10b4fd4d94e --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java @@ -0,0 +1,26 @@ +package io.sentry.samples.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * A manifest-declared broadcast receiver for testing standalone app starts. + * + *

    Test with: + * + *

    {@code
    + * adb shell am force-stop io.sentry.samples.android && \
    + * adb shell am broadcast -a io.sentry.samples.android.TEST_BROADCAST \
    + *   -n io.sentry.samples.android/.TestBroadcastReceiver
    + * }
    + */ +public class TestBroadcastReceiver extends BroadcastReceiver { + private static final String TAG = "SentryAppStart"; + + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "TestBroadcastReceiver.onReceive() called - no activity will launch"); + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java new file mode 100644 index 00000000000..5671a044437 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java @@ -0,0 +1,696 @@ +package io.sentry.samples.android; + +import android.os.Bundle; +import android.text.method.ScrollingMovementMethod; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; +import androidx.appcompat.app.AppCompatActivity; +import io.sentry.Sentry; +import io.sentry.okhttp.SentryOkHttpEventListener; +import io.sentry.okhttp.SentryOkHttpInterceptor; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.Locale; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.json.JSONObject; + +public class TriggerHttpRequestActivity extends AppCompatActivity { + + private EditText urlInput; + private TextView requestDisplay; + private TextView responseDisplay; + private ProgressBar loadingIndicator; + private Button getButton; + private Button postButton; + private Button formButton; + private Button binaryButton; + private Button stringButton; + private Button oneShotButton; + private Button largeTextButton; + private Button largeBinaryButton; + private Button clearButton; + + private OkHttpClient okHttpClient; + private SimpleDateFormat dateFormat; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_trigger_http_request); + + dateFormat = new SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()); + + initializeViews(); + setupOkHttpClient(); + setupClickListeners(); + } + + private void initializeViews() { + urlInput = findViewById(R.id.url_input); + requestDisplay = findViewById(R.id.request_display); + responseDisplay = findViewById(R.id.response_display); + loadingIndicator = findViewById(R.id.loading_indicator); + getButton = findViewById(R.id.trigger_get_request); + postButton = findViewById(R.id.trigger_post_request); + formButton = findViewById(R.id.trigger_form_request); + binaryButton = findViewById(R.id.trigger_binary_request); + stringButton = findViewById(R.id.trigger_string_request); + oneShotButton = findViewById(R.id.trigger_oneshot_request); + largeTextButton = findViewById(R.id.trigger_large_text_request); + largeBinaryButton = findViewById(R.id.trigger_large_binary_request); + clearButton = findViewById(R.id.clear_display); + + requestDisplay.setMovementMethod(new ScrollingMovementMethod()); + responseDisplay.setMovementMethod(new ScrollingMovementMethod()); + } + + private void setupOkHttpClient() { + // OkHttpClient with Sentry integration for monitoring HTTP requests + // Both SentryOkHttpEventListener and SentryOkHttpInterceptor are enabled to test + // network detail capture when both components are used together + okHttpClient = + new OkHttpClient.Builder() + .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .eventListener(new SentryOkHttpEventListener()) + .addInterceptor(new SentryOkHttpInterceptor()) + .build(); + } + + private void setupClickListeners() { + getButton.setOnClickListener(v -> performGetRequest()); + postButton.setOnClickListener(v -> performJsonRequest()); + formButton.setOnClickListener(v -> performFormUrlencodedRequest()); + binaryButton.setOnClickListener(v -> performOctetStreamRequest()); + stringButton.setOnClickListener(v -> performTextPlainRequest()); + oneShotButton.setOnClickListener(v -> performOneShotJsonRequest()); + largeTextButton.setOnClickListener(v -> performLargeTextPlainRequest()); + largeBinaryButton.setOnClickListener(v -> performLargeOctetStreamRequest()); + clearButton.setOnClickListener(v -> clearDisplays()); + } + + private void performGetRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + Request request = + new Request.Builder() + .url(url) + .get() + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Accept", "application/json") + // Test headers for network detail filtering + .addHeader("Authorization", "Bearer test-token-12345") + .addHeader("X-Custom-Header", "custom-value-for-testing") + .addHeader("X-Test-Request", "network-detail-test") + .build(); + + displayRequest("GET", request); + executeRequest(request); + } + + private void performJsonRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + JSONObject json = new JSONObject(); + json.put("request_type", "POST_JSON"); + json.put("button_clicked", "POST JSON"); + json.put("message", "Hello from Sentry Android Sample"); + json.put("timestamp", System.currentTimeMillis()); + json.put("device", android.os.Build.MODEL); + + RequestBody body = + RequestBody.create(json.toString(), MediaType.get("application/json; charset=utf-8")); + + Request request = + new Request.Builder() + .url(url) + .post(body) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .addHeader("X-Request-Type", "POST_JSON") + .build(); + + displayRequest("POST", request, json.toString(2)); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText(this, "Error creating request: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private void executeRequest(Request request) { + showLoading(true); + + okHttpClient + .newCall(request) + .enqueue( + new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Sentry.captureException(e); + runOnUiThread( + () -> { + showLoading(false); + displayResponse("ERROR", null, "Request failed: " + e.getMessage(), 0); + }); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + final long startTime = System.currentTimeMillis(); + final int statusCode = response.code(); + final String statusMessage = response.message(); + ResponseBody responseBody = response.body(); + String body = ""; + + try { + if (responseBody != null) { + body = responseBody.string(); + } + } catch (IOException e) { + body = "Error reading response body: " + e.getMessage(); + Sentry.captureException(e); + } + + final long responseTime = System.currentTimeMillis() - startTime; + final String finalBody = body; + + // Capture response headers for network detail testing + final StringBuilder responseHeaders = new StringBuilder(); + for (int i = 0; i < response.headers().size(); i++) { + responseHeaders + .append(" ") + .append(response.headers().name(i)) + .append(": ") + .append(response.headers().value(i)) + .append("\n"); + } + final String finalResponseHeaders = responseHeaders.toString(); + + runOnUiThread( + () -> { + showLoading(false); + displayResponse( + statusMessage, statusCode, finalBody, responseTime, finalResponseHeaders); + }); + + response.close(); + } + }); + } + + private void displayRequest(String method, Request request) { + displayRequest(method, request, null); + } + + private void displayRequest(String method, Request request, String body) { + StringBuilder sb = new StringBuilder(); + sb.append("[").append(getCurrentTime()).append("]\n"); + sb.append("━━━━━━━━━━━━━━━━━━━━━━━━\n"); + sb.append("METHOD: ").append(method).append("\n"); + sb.append("URL: ").append(request.url()).append("\n\n"); + sb.append("HEADERS:\n"); + + for (int i = 0; i < request.headers().size(); i++) { + sb.append(" ") + .append(request.headers().name(i)) + .append(": ") + .append(request.headers().value(i)) + .append("\n"); + } + + if (body != null && !body.isEmpty()) { + sb.append("\nBODY:\n").append(body).append("\n"); + } + + sb.append("━━━━━━━━━━━━━━━━━━━━━━━━"); + + requestDisplay.setText(sb.toString()); + } + + private void displayResponse(String status, Integer code, String body, long responseTime) { + displayResponse(status, code, body, responseTime, null); + } + + private void displayResponse( + String status, Integer code, String body, long responseTime, String headers) { + StringBuilder sb = new StringBuilder(); + sb.append("[").append(getCurrentTime()).append("]\n"); + sb.append("━━━━━━━━━━━━━━━━━━━━━━━━\n"); + + if (code != null) { + sb.append("STATUS: ").append(code).append(" ").append(status).append("\n"); + sb.append("RESPONSE TIME: ").append(responseTime).append("ms\n"); + } else { + sb.append("STATUS: ").append(status).append("\n"); + } + + if (headers != null && !headers.isEmpty()) { + sb.append("\nRESPONSE HEADERS:\n").append(headers); + } + + if (body != null && !body.isEmpty()) { + try { + if (body.trim().startsWith("{") || body.trim().startsWith("[")) { + JSONObject json = new JSONObject(body); + sb.append("\nBODY (JSON):\n").append(json.toString(2)); + } else { + sb.append("\nBODY:\n").append(body); + } + } catch (Exception e) { + sb.append("\nBODY:\n").append(body); + } + } + + sb.append("\n━━━━━━━━━━━━━━━━━━━━━━━━"); + + responseDisplay.setText(sb.toString()); + } + + private void clearDisplays() { + requestDisplay.setText("No request yet..."); + responseDisplay.setText("No response yet..."); + } + + private String getUrl() { + String url = urlInput.getText().toString().trim(); + if (url.isEmpty()) { + return "https://api.github.com/users/getsentry"; + } + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "https://" + url; + } + return url; + } + + private void showLoading(boolean show) { + loadingIndicator.setVisibility(show ? View.VISIBLE : View.GONE); + getButton.setEnabled(!show); + postButton.setEnabled(!show); + formButton.setEnabled(!show); + binaryButton.setEnabled(!show); + stringButton.setEnabled(!show); + oneShotButton.setEnabled(!show); + largeTextButton.setEnabled(!show); + largeBinaryButton.setEnabled(!show); + } + + private void performFormUrlencodedRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + // Create URL-encoded form data + String formData = + "request_type=POST_FORM_URLENCODED&" + + "button_clicked=POST%20Form&" + + "username=sentry_android_user&" + + "email=test@example.com&" + + "message=Hello%20from%20Android%20Sample%20Form%20Request&" + + "timestamp=" + + System.currentTimeMillis() + + "&" + + "device=" + + android.os.Build.MODEL.replace(" ", "%20"); + + RequestBody body = + RequestBody.create(formData, MediaType.get("application/x-www-form-urlencoded")); + + Request request = + new Request.Builder() + .url(url) + .post(body) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "application/x-www-form-urlencoded") + .addHeader("X-Request-Type", "POST_FORM_URLENCODED") + .build(); + + displayRequest("POST", request, formData); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText(this, "Error creating form request: " + e.getMessage(), Toast.LENGTH_SHORT) + .show(); + } + } + + private void performOctetStreamRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + // Add request type to URL as query parameter for binary requests + String separator = url.contains("?") ? "&" : "?"; + String urlWithType = url + separator + "request_type=POST_BINARY&button=POST_Binary"; + + // Create binary data (simulate a small file upload) + byte[] binaryData = new byte[1024]; // 1KB of binary data + for (int i = 0; i < binaryData.length; i++) { + binaryData[i] = (byte) (i % 256); + } + + RequestBody body = RequestBody.create(binaryData, MediaType.get("application/octet-stream")); + + Request request = + new Request.Builder() + .url(urlWithType) + .post(body) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "application/octet-stream") + .addHeader("Content-Length", String.valueOf(binaryData.length)) + .addHeader("X-Request-Type", "POST_BINARY") + .build(); + + String displayBody = + "[Binary data: " + + binaryData.length + + " bytes]\n" + + "Request type in URL: POST_BINARY\n" + + "Sample bytes: " + + Arrays.toString(Arrays.copyOf(binaryData, Math.min(10, binaryData.length))); + + displayRequest("POST", request, displayBody); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText(this, "Error creating binary request: " + e.getMessage(), Toast.LENGTH_SHORT) + .show(); + } + } + + private void performTextPlainRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + // Create plain text string data with request type identifier + String textData = + "REQUEST_TYPE: POST_STRING\n" + + "BUTTON_CLICKED: POST String\n" + + "Hello from Sentry Android Sample!\n" + + "This is a plain text request body.\n" + + "Timestamp: " + + new Date().toString() + + "\n" + + "Device: " + + android.os.Build.MODEL + + "\n" + + "SDK Version: " + + android.os.Build.VERSION.SDK_INT + + "\n" + + "Lorem ipsum dolor sit amet, consectetur adipiscing elit."; + + RequestBody body = RequestBody.create(textData, MediaType.get("text/plain; charset=utf-8")); + + Request request = + new Request.Builder() + .url(url) + .post(body) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "text/plain; charset=utf-8") + .addHeader("X-Request-Type", "POST_STRING") + .build(); + + displayRequest("POST", request, textData); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText(this, "Error creating string request: " + e.getMessage(), Toast.LENGTH_SHORT) + .show(); + } + } + + private void performOneShotJsonRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + // Add request type to URL as query parameter for one-shot requests + String separator = url.contains("?") ? "&" : "?"; + String urlWithType = url + separator + "request_type=POST_ONE_SHOT&button=POST_OneShotBody"; + + // Create JSON data for one-shot request body + JSONObject json = new JSONObject(); + json.put("request_type", "POST_ONE_SHOT"); + json.put("button_clicked", "POST One-Shot"); + json.put("message", "This is a ONE-SHOT REQUEST BODY - can only be read once!"); + json.put("timestamp", System.currentTimeMillis()); + json.put("device", android.os.Build.MODEL); + json.put("warning", "Reading this body multiple times will cause IOException"); + + String jsonString = json.toString(); + byte[] bodyBytes = jsonString.getBytes("UTF-8"); + + // Create a TRUE one-shot request body that will fail if read multiple times + RequestBody oneShotBody = + new RequestBody() { + private InputStream inputStream = new ByteArrayInputStream(bodyBytes); + private boolean hasBeenRead = false; + + @Override + public MediaType contentType() { + return MediaType.get("application/json; charset=utf-8"); + } + + @Override + public long contentLength() { + return bodyBytes.length; + } + + @Override + public void writeTo(okio.BufferedSink sink) throws IOException { + if (hasBeenRead) { + throw new IOException( + "One-shot body has already been read! This would happen in real scenarios with FileInputStream or other non-repeatable streams."); + } + + hasBeenRead = true; + + try { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + sink.write(buffer, 0, bytesRead); + } + } finally { + inputStream.close(); + } + } + }; + + Request request = + new Request.Builder() + .url(urlWithType) + .post(oneShotBody) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "application/json; charset=utf-8") + .addHeader("X-Request-Type", "POST_ONE_SHOT") + .addHeader("X-Body-Type", "ONE_SHOT_STREAM") + .build(); + + String displayBody = + "[ONE-SHOT REQUEST BODY]\n" + + "Type: InputStream-based RequestBody\n" + + "Size: " + + bodyBytes.length + + " bytes\n" + + "Content: " + + json.toString(2) + + "\n" + + "\nWARNING: This body can only be read once!\n" + + "If interceptors try to read it multiple times, it will fail."; + + displayRequest("POST", request, displayBody); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText(this, "Error creating one-shot request: " + e.getMessage(), Toast.LENGTH_SHORT) + .show(); + } + } + + private void performLargeTextPlainRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + // Add request type to URL for identification + String separator = url.contains("?") ? "&" : "?"; + String urlWithType = url + separator + "request_type=POST_LARGE_TEXT&button=POST_LargeText"; + + // Create large text data that exceeds MAX_NETWORK_BODY_SIZE (150KB) + // Target size: 200KB (204,800 bytes) + int targetSize = 200 * 1024; // 200KB + StringBuilder largeText = new StringBuilder(); + + largeText.append("REQUEST_TYPE: POST_LARGE_TEXT\n"); + largeText.append("BUTTON_CLICKED: POST Large Text\n"); + largeText.append("SIZE_TARGET: ").append(targetSize).append(" bytes (exceeds 150KB limit)\n"); + largeText.append("TIMESTAMP: ").append(new Date()).append("\n"); + largeText.append("DEVICE: ").append(android.os.Build.MODEL).append("\n"); + largeText.append("WARNING: This body size exceeds MAX_NETWORK_BODY_SIZE!\n\n"); + + // Fill with repeated content to reach target size + String filler = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. "; + + int currentSize = largeText.length(); + while (currentSize < targetSize) { + largeText + .append("FILLER_LINE_") + .append(currentSize / filler.length()) + .append(": ") + .append(filler); + currentSize = largeText.length(); + } + + String textData = largeText.toString(); + + RequestBody body = RequestBody.create(textData, MediaType.get("text/plain; charset=utf-8")); + + Request request = + new Request.Builder() + .url(urlWithType) + .post(body) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "text/plain; charset=utf-8") + .addHeader("X-Request-Type", "POST_LARGE_TEXT") + .addHeader("X-Body-Size", String.valueOf(textData.length())) + .build(); + + String displayBody = + "[LARGE TEXT REQUEST BODY]\n" + + "Type: text/plain\n" + + "Size: " + + textData.length() + + " bytes (" + + (textData.length() / 1024) + + "KB)\n" + + "Limit: 153,600 bytes (150KB)\n" + + "Status: " + + (textData.length() > 153600 ? "EXCEEDS LIMIT" : "Within limit") + + "\n" + + "Preview: " + + textData.substring(0, Math.min(200, textData.length())) + + "..."; + + displayRequest("POST", request, displayBody); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText( + this, "Error creating large text request: " + e.getMessage(), Toast.LENGTH_SHORT) + .show(); + } + } + + private void performLargeOctetStreamRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + // Add request type to URL for identification (binary bodies are ignored) + String separator = url.contains("?") ? "&" : "?"; + String urlWithType = + url + separator + "request_type=POST_LARGE_BINARY&button=POST_LargeBinary"; + + // Create large binary data that exceeds MAX_NETWORK_BODY_SIZE (150KB) + // Target size: 256KB (262,144 bytes) + int targetSize = 256 * 1024; // 256KB + byte[] binaryData = new byte[targetSize]; + + // Fill with a pattern for easier identification + for (int i = 0; i < binaryData.length; i++) { + // Create a pattern: alternating bytes with position info + binaryData[i] = (byte) ((i % 256) ^ ((i / 256) % 256)); + } + + RequestBody body = RequestBody.create(binaryData, MediaType.get("application/octet-stream")); + + Request request = + new Request.Builder() + .url(urlWithType) + .post(body) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "application/octet-stream") + .addHeader("Content-Length", String.valueOf(binaryData.length)) + .addHeader("X-Request-Type", "POST_LARGE_BINARY") + .addHeader("X-Body-Size", String.valueOf(binaryData.length)) + .build(); + + String displayBody = + "[LARGE BINARY REQUEST BODY]\n" + + "Type: application/octet-stream\n" + + "Size: " + + binaryData.length + + " bytes (" + + (binaryData.length / 1024) + + "KB)\n" + + "Limit: 153,600 bytes (150KB)\n" + + "Status: " + + (binaryData.length > 153600 ? "EXCEEDS LIMIT" : "Within limit") + + "\n" + + "Pattern: Alternating bytes with position info\n" + + "Sample bytes: " + + Arrays.toString(Arrays.copyOf(binaryData, Math.min(16, binaryData.length))) + + "\n" + + "Request type in URL: POST_LARGE_BINARY"; + + displayRequest("POST", request, displayBody); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText( + this, "Error creating large binary request: " + e.getMessage(), Toast.LENGTH_SHORT) + .show(); + } + } + + private String getCurrentTime() { + return dateFormat.format(new Date()); + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt new file mode 100644 index 00000000000..fd80a5aae1e --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt @@ -0,0 +1,111 @@ +package io.sentry.samples.android.sqlite + +/** + * Display text for each "SQL run" summary shown in the [SQLiteActivity] screen UI. Documentation + * only / never executed. The real statements live in [SqlStatements]. + */ +internal data class DisplayInfo(val sql: String, val sqlHeavy: String = sql) + +internal val DRIVER_DIRECT = + DisplayInfo( + sql = + """ + CREATE TABLE IF NOT EXISTS song(…) + INSERT INTO song(title, artist) VALUES (?, ?) + SELECT count(*) FROM song + """ + .trimIndent(), + sqlHeavy = + """ + CREATE TABLE IF NOT EXISTS song(…) + INSERT INTO song(title, artist) VALUES (?, ?) + INSERT INTO song(title, artist) VALUES (?, ?), (?, ?), … (?, ?) + SELECT id, title, artist FROM song + SELECT count(*) FROM song + -- then, per row: appWork() = 500x SHA-256, in the app (not in any span) + """ + .trimIndent(), + ) + +internal val DRIVER_ROOM2 = + DisplayInfo( + sql = + """ + INSERT OR ABORT INTO `song` (…) VALUES (nullif(?, 0), ?, ?) + SELECT count(*) FROM song + """ + .trimIndent(), + sqlHeavy = + """ + INSERT OR ABORT INTO `song` (…) VALUES (nullif(?, 0), ?, ?) + SELECT * FROM song + SELECT count(*) FROM song + -- then, per row: appWork() = 500x SHA-256, outside the step()-timed spans + """ + .trimIndent(), + ) + +// Room 3 issues the same statements as Room 2 (see SqlStatements.driverWithRoom3). +internal val DRIVER_ROOM3 = DRIVER_ROOM2 + +internal val OPENHELPER_DIRECT = + DisplayInfo( + sql = + """ + CREATE TABLE IF NOT EXISTS song(…) + INSERT INTO song(title, artist) VALUES (?, ?) + SELECT count(*) FROM song + """ + .trimIndent(), + sqlHeavy = + """ + CREATE TABLE IF NOT EXISTS song(…) + INSERT INTO song(title, artist) VALUES (?, ?) + INSERT INTO song(title, artist) VALUES (?, ?), (?, ?), … (?, ?) + SELECT id, title, artist FROM song + SELECT count(*) FROM song + -- then, per row: appWork() = 500x SHA-256, in the app + """ + .trimIndent(), + ) + +internal val OPENHELPER_ROOM = + DisplayInfo( + sql = + """ + INSERT OR ABORT INTO `song` (…) VALUES (nullif(?, 0), ?, ?) + SELECT count(*) FROM song + """ + .trimIndent(), + sqlHeavy = + """ + INSERT OR ABORT INTO `song` (…) VALUES (nullif(?, 0), ?, ?) + SELECT * FROM song + SELECT count(*) FROM song + -- then, per row: appWork() = 500x SHA-256, in the app + """ + .trimIndent(), + ) + +// Bridge demos run the same SQL as the driver paths; spans come from the open-helper layer. +internal val BRIDGE_DIRECT = DRIVER_DIRECT + +internal val BRIDGE_ROOM2 = DRIVER_ROOM2 + +internal val OPENHELPER_SQLDELIGHT = + DisplayInfo( + sql = + """ + INSERT INTO song(title, artist) VALUES (?, ?) + SELECT count(*) FROM song + """ + .trimIndent(), + sqlHeavy = + """ + INSERT INTO song(title, artist) VALUES (?, ?) + SELECT * FROM song + SELECT count(*) FROM song + -- then, per row: appWork() = 500x SHA-256, in the app + """ + .trimIndent(), + ) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room2Dao.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room2Dao.kt new file mode 100644 index 00000000000..31814c750bc --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room2Dao.kt @@ -0,0 +1,42 @@ +package io.sentry.samples.android.sqlite + +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.RoomDatabase + +@Entity(tableName = "song") +data class SongEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val title: String, + val artist: String, +) + +@Dao +interface SongDao { + + @Insert suspend fun insert(song: SongEntity) + + /** Batch insert: Room runs all rows in a single transaction, reusing one compiled statement. */ + @Insert suspend fun insertAll(songs: List) + + @Query("SELECT * FROM song") suspend fun getAll(): List + + @Query("SELECT count(*) FROM song") suspend fun count(): Int + + /** + * No-op write (matches no rows) used at warm-up to open Room's writer connection up front. A read + * like [count] only opens a reader, so without this the first INSERT would (noisily) open and + * bootstrap the writer connection inside a demo transaction. + */ + @Query("DELETE FROM song WHERE id < 0") suspend fun primeWriter() +} + +@Database(entities = [SongEntity::class], version = 1, exportSchema = false) +abstract class SampleRoom2Database : RoomDatabase() { + + abstract fun songDao(): SongDao +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt new file mode 100644 index 00000000000..145e12d3897 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt @@ -0,0 +1,42 @@ +package io.sentry.samples.android.sqlite + +import androidx.room3.Dao +import androidx.room3.Database +import androidx.room3.Entity +import androidx.room3.Insert +import androidx.room3.PrimaryKey +import androidx.room3.Query +import androidx.room3.RoomDatabase + +@Entity(tableName = "song") +data class SongEntity3( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val title: String, + val artist: String, +) + +@Dao +interface SongDao3 { + + @Insert suspend fun insert(song: SongEntity3) + + /** Batch insert: Room runs all rows in a single transaction, reusing one compiled statement. */ + @Insert suspend fun insertAll(songs: List) + + @Query("SELECT * FROM song") suspend fun getAll(): List + + @Query("SELECT count(*) FROM song") suspend fun count(): Int + + /** + * No-op write (matches no rows) used at warm-up to open Room's writer connection up front. A read + * like [count] only opens a reader, so without this the first INSERT would (noisily) open and + * bootstrap the writer connection inside a demo transaction. + */ + @Query("DELETE FROM song WHERE id < 0") suspend fun primeWriter() +} + +@Database(entities = [SongEntity3::class], version = 1, exportSchema = false) +abstract class SampleRoom3Database : RoomDatabase() { + + abstract fun songDao(): SongDao3 +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt new file mode 100644 index 00000000000..54334b6e407 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -0,0 +1,777 @@ +package io.sentry.samples.android.sqlite + +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.keyframes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.lifecycleScope +import io.sentry.Sentry +import io.sentry.SpanId +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.SentryId +import io.sentry.samples.android.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val SentryPink = Color(0xFFC85B9C) +private val SentryPurple = Color(0xFF7B52FB) +private val SentryOrange = Color(0xFFE8743F) +private val SentryRed = Color(0xFFF55459) + +/** Intro text, surfaced via the "?" tooltip next to the "Run it" header. */ +private const val INSTRUCTIONS = + "Tap a button to execute a SQL statement in its own transaction; long press to run it in a ui.load transaction." + +/** Start state of the "SQL run" box. */ +private const val SQL_DETAIL_HINT = "Tap a button above to see the SQL it runs…" + +private val TOGGLE_SECTION_GAP = 24.dp + +private val CONTROL_SECTION_GAP = TOGGLE_SECTION_GAP * 2 + +private val SECTION_HEADER_HEIGHT = 28.dp + +private const val SAGP_DIRECT_DRIVER_MESSAGE = + "SAGP doesn't auto-instrument SQLiteDriver for direct use" + +/** Which sentry-android-sqlite integration the demo currently targets. */ +private enum class IntegrationMode( + val color: Color, + val segmentLabel: String, + val apiName: String, +) { + + DRIVER(SentryPurple, "SQLiteDriver", "SQLiteDriver"), + OPEN_HELPER(SentryPink, "OpenHelper", "SupportSQLiteOpenHelper"), + // Not directly-supported, but lets us verify behavior when both the DRIVER and OPEN_HELPER + // integrations are used together via the SupportSQLiteDriver bridge. + BRIDGE(SentryOrange, "Bridge", "SupportSQLiteDriver bridge"); + + fun subtitle(): String = + when (this) { + DRIVER -> + if (BuildConfig.USE_SAGP) { + "BundledSQLiteDriver (SAGP auto-wrap)" + } else { + "SentrySQLiteDriver.create(BundledSQLiteDriver)" + } + + OPEN_HELPER -> + if (BuildConfig.USE_SAGP) { + "FrameworkSQLiteOpenHelperFactory (SAGP auto-wrap)" + } else { + "SentrySupportSQLiteOpenHelper.create(...)" + } + + BRIDGE -> + if (BuildConfig.USE_SAGP) { + "SupportSQLiteDriver(open helper) (SAGP auto-wrap)" + } else { + "SentrySQLiteDriver.create(SupportSQLiteDriver(Sentry helper))" + } + } +} + +/** + * How one demo button behaves for a given integration: which [SqlStatements] work it runs ([demo]), + * the name/op of the manual transaction a tap wraps it in, and the SQL summary shown in the detail + * panel ([displayInfo]). + */ +private class DemoVariant( + val demo: SqlDemo, + val transactionName: String, + val op: String, + val displayInfo: DisplayInfo, +) + +/** + * A single demo button in the list. [driver] / [openHelper] / [bridge] hold the variant for each + * integration; a null variant means the row doesn't apply and renders dimmed (e.g., Room 3 is + * driver-only; SQLDelight is open-helper-only; etc.). + */ +private class DemoRow( + val label: String, + val driver: DemoVariant?, + val openHelper: DemoVariant?, + val bridge: DemoVariant?, +) { + + fun variantFor(mode: IntegrationMode): DemoVariant? = + when (mode) { + IntegrationMode.DRIVER -> driver + IntegrationMode.OPEN_HELPER -> openHelper + IntegrationMode.BRIDGE -> bridge + } +} + +// The demo buttons, top to bottom, paired with each integration's variant. Pure data — the actual +// SQL lives in SqlStatements, dispatched by id. +private val DEMO_ROWS = + listOf( + DemoRow( + label = "Direct (no library)", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_DIRECT, + transactionName = "SentrySQLiteDriver — Direct", + op = "db.sql.driver-direct", + displayInfo = DRIVER_DIRECT, + ), + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_DIRECT, + transactionName = "SentrySupportSQLiteOpenHelper — Direct", + op = "db.sql.openhelper-direct", + displayInfo = OPENHELPER_DIRECT, + ), + bridge = + DemoVariant( + demo = SqlDemo.BRIDGE_DIRECT, + transactionName = "Bridge stack — Direct", + op = "db.sql.bridge-direct", + displayInfo = BRIDGE_DIRECT, + ), + ), + DemoRow( + label = "Room 2", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_ROOM2, + transactionName = "SentrySQLiteDriver — Room 2", + op = "db.sql.driver-room2", + displayInfo = DRIVER_ROOM2, + ), + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_ROOM, + transactionName = "SentrySupportSQLiteOpenHelper — Room", + op = "db.sql.openhelper-room", + displayInfo = OPENHELPER_ROOM, + ), + bridge = + DemoVariant( + demo = SqlDemo.BRIDGE_ROOM2, + transactionName = "Bridge stack — Room 2", + op = "db.sql.bridge-room2", + displayInfo = BRIDGE_ROOM2, + ), + ), + DemoRow( + label = "Room 3", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_ROOM3, + transactionName = "SentrySQLiteDriver — Room 3", + op = "db.sql.driver-room3", + displayInfo = DRIVER_ROOM3, + ), + openHelper = null, // Room 3 only runs on the SQLiteDriver path. + bridge = null, + ), + DemoRow( + label = "SQLDelight", + driver = null, // SQLDelight's AndroidSqliteDriver is built on SupportSQLiteOpenHelper. + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_SQLDELIGHT, + transactionName = "SentrySupportSQLiteOpenHelper — SQLDelight", + op = "db.sql.openhelper-sqldelight", + displayInfo = OPENHELPER_SQLDELIGHT, + ), + bridge = null, + ), + ) + +/** + * Activity that lets us exercise our two `sentry-android-sqlite` integrations + * ([SentrySQLiteDriver][io.sentry.sqlite.SentrySQLiteDriver] and + * [SentrySupportSQLiteOpenHelper][io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper]), both + * directly and via Room or SQLDelight. + * + * Example SQL statements are deliberately identical across integrations so we can identify + * similarities and differences in their transaction / span support. + */ +class SQLiteActivity : ComponentActivity() { + + private var latestResult by mutableStateOf("") + private var warmUpErrors by mutableStateOf("") + private var sqlDetail by mutableStateOf(SQL_DETAIL_HINT) + private var heavyWork by mutableStateOf(false) + + /** + * When enabled, every per-button transaction in one screen visit continues [screenTraceHeader], + * so they all share a trace ("session"-like). When disabled (the default), each tap is the root + * of its own trace, which renders as a standalone waterfall scaled to that one transaction — + * easier to read how time is allocated among its spans. + */ + private var shareScreenTrace by mutableStateOf(false) + + /** Which integration is currently being demoed. Switching it disables rows that don't apply. */ + private var integration by mutableStateOf(IntegrationMode.DRIVER) + + /** Incremented on each tap that runs SQL. Used to retrigger the detail box's outline shimmer. */ + private var runTick by mutableStateOf(0) + + /** True while a demo or reset is running SQL on a background thread. */ + private var dbOperationInFlight by mutableStateOf(false) + + /** True for the duration of a reset; disables the reset button immediately (no debounce). */ + private var resetInProgress by mutableStateOf(false) + + /** + * The shared trace used when [shareScreenTrace] is enabled: one trace per visit to this screen. + * onResume() generates a fresh one each time the screen is (re)entered. + */ + private var screenTraceHeader = newScreenTrace() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme { + Surface { + Column( + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val screenHeightDp = LocalConfiguration.current.screenHeightDp + // A small gap below the screen title that grows with screen height and collapses to 0 + // on short screens, so the title isn't crowded against "Configure it" on tall devices. + val titleGap = + (((((screenHeightDp / 4) - 48) / 3).coerceAtLeast(0).dp + TOGGLE_SECTION_GAP) / 2 - + SECTION_HEADER_HEIGHT) + .coerceAtLeast(0.dp) + + // Pulse the "Under the hood" outline in the integration color whenever a tap runs SQL. + val shimmer = remember { Animatable(0f) } + LaunchedEffect(runTick) { + if (runTick == 0) return@LaunchedEffect + shimmer.animateTo( + targetValue = 0f, + animationSpec = + keyframes { + durationMillis = 900 + 0f at 0 + 1f at 200 + 0.4f at 450 + 1f at 650 + 0f at 900 + }, + ) + } + + val detailOutline = + lerp(MaterialTheme.colorScheme.outline, integration.color, shimmer.value) + + Text(text = "SQLite Instrumentation", style = MaterialTheme.typography.headlineSmall) + SagpBuildPill() + + Spacer(Modifier.height(titleGap)) + + SectionHeader("Configure it") + + val controlSwitchColors = + SwitchDefaults.colors( + checkedTrackColor = Color.Black, + checkedBorderColor = Color.Black, + ) + IntegrationModeSelector( + selected = integration, + onSelected = { + integration = it + sqlDetail = SQL_DETAIL_HINT + latestResult = "" + }, + ) + ToggleRow( + label = if (heavyWork) "Heavy app-level work" else "No app-level work", + checked = heavyWork, + switchColors = controlSwitchColors, + ) { + heavyWork = it + } + ToggleRow( + label = + if (shareScreenTrace) "Single trace for all button clicks" + else "Separate trace per button click", + checked = shareScreenTrace, + switchColors = controlSwitchColors, + ) { + shareScreenTrace = it + } + + SectionHeader("Run it", topPadding = CONTROL_SECTION_GAP) { HelpTooltip() } + + // One consolidated list of demo buttons. Each row dispatches to the selected + // integration's variant; a row that doesn't apply explains why via a toast (see + // [DemoRowButton]). + DEMO_ROWS.forEach { row -> + val variant = row.variantFor(integration) + DemoRowButton( + label = row.label, + color = integration.color, + variant = variant, + sagpDisabledReason = sagpDisabledReason(integration, row), + disabledReason = "${row.label} doesn't support the ${integration.apiName} stack", + ) + } + + ResetButton( + dbOperationInFlight = dbOperationInFlight, + resetInProgress = resetInProgress, + ) + + // Same [CONTROL_SECTION_GAP] above as the other sections, separating the controls from + // the detail output. + SectionHeader("Under the hood", topPadding = CONTROL_SECTION_GAP) + LaunchedEffect(Unit) { + while (!SampleDatabases.isWarmUpComplete()) { + warmUpErrors = SampleDatabases.warmUpErrors + delay(250) + } + warmUpErrors = SampleDatabases.warmUpErrors + } + if (warmUpErrors.isNotEmpty()) { + Text( + text = warmUpErrors, + style = MaterialTheme.typography.bodyMedium, + color = SentryRed, + ) + } + // The latest run result (row counts, errors). Hidden until the first run. + if (latestResult.isNotEmpty()) { + Text( + text = latestResult, + style = MaterialTheme.typography.bodyMedium, + color = if (latestResult.looksLikeError()) SentryRed else Color.Unspecified, + ) + } + DetailField("SQL run", sqlDetail, borderColor = detailOutline) + } + } + } + } + } + + override fun onResume() { + super.onResume() + // Start a new trace each time the user (re)enters the screen, so each visit is its own session. + screenTraceHeader = newScreenTrace() + } + + /** Run the variant's SQL statement inside a manual, scope-bound transaction. */ + private fun onTap(variant: DemoVariant) { + if (dbOperationInFlight) return + + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql + runTick++ // shimmer the detail box outline in the integration color + + lifecycleScope.launch { + dbOperationInFlight = true + try { + val result = + withContext(Dispatchers.IO) { + runInTransaction(variant.transactionName, variant.op) { + SqlStatements.execute(applicationContext, variant.demo, heavyWork) + } + } + latestResult = result + } finally { + dbOperationInFlight = false + } + } + } + + /** + * Run the variant's SQL statement in [UiLoadActivity] with no manual transaction, so its auto + * `ui.load` transaction owns the spans. + */ + private fun onLongPress(variant: DemoVariant) { + if (dbOperationInFlight) return + + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql + latestResult = "Opened the auto-load screen — its ui.load transaction owns the db spans." + startActivity(UiLoadActivity.intent(this, variant.demo, heavyWork)) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun IntegrationModeSelector( + selected: IntegrationMode, + onSelected: (IntegrationMode) -> Unit, + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + IntegrationMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + shape = + SegmentedButtonDefaults.itemShape(index = index, count = IntegrationMode.entries.size), + onClick = { onSelected(mode) }, + selected = selected == mode, + icon = {}, + colors = + SegmentedButtonDefaults.colors( + activeContainerColor = mode.color, + activeContentColor = Color.White, + ), + label = { Text(mode.segmentLabel, style = MaterialTheme.typography.labelSmall) }, + ) + } + } + + Text( + text = selected.subtitle(), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + modifier = Modifier.padding(top = 6.dp), + ) + } + + @Composable + private fun SagpBuildPill() { + val useSagp = BuildConfig.USE_SAGP + + Surface( + shape = RoundedCornerShape(percent = 50), + color = if (useSagp) SentryPurple.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.2f), + modifier = Modifier.padding(top = 6.dp), + ) { + Text( + text = if (useSagp) "Built with SAGP" else "Built without SAGP", + style = MaterialTheme.typography.labelSmall, + color = if (useSagp) SentryPurple else Color.DarkGray, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } + } + + /** + * A compact, left-justified labeled switch. [labelColor] defaults to [Color.Unspecified] so the + * label inherits the default text color. + */ + @Composable + private fun ToggleRow( + label: String, + checked: Boolean, + modifier: Modifier = Modifier, + labelColor: Color = Color.Unspecified, + switchColors: SwitchColors = SwitchDefaults.colors(), + onCheckedChange: (Boolean) -> Unit, + ) { + // Constrain the row height: a Switch otherwise reserves ~48dp, leaving a large gap between the + // toggles. 32dp keeps them about one line of text apart. + Row(modifier = modifier.height(32.dp), verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = switchColors, + modifier = Modifier.scale(0.75f), + ) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = labelColor, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + + @Composable + private fun SectionHeader( + title: String, + topPadding: Dp = 8.dp, + trailing: (@Composable () -> Unit)? = null, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(top = topPadding)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + trailing?.invoke() + } + HorizontalDivider(thickness = 1.dp, modifier = Modifier.padding(top = 4.dp)) + } + } + + /** + * A circled "?" next to the "Run it" header. Tapping it briefly shows the [INSTRUCTIONS] in a + * tooltip that auto-dismisses after a few seconds. + */ + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun HelpTooltip() { + val tooltipState = rememberTooltipState(isPersistent = true) + val scope = rememberCoroutineScope() + LaunchedEffect(tooltipState.isVisible) { + if (tooltipState.isVisible) { + delay(4000) + tooltipState.dismiss() + } + } + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(INSTRUCTIONS) } }, + state = tooltipState, + ) { + Icon( + imageVector = Icons.Outlined.HelpOutline, + contentDescription = "What do the buttons do?", + tint = Color.Gray, + modifier = + Modifier.padding(start = 8.dp).size(20.dp).clickable { + scope.launch { tooltipState.show() } + }, + ) + } + } + + /** + * A filled button that runs [variant] on tap (manual transaction) or long-press (ui.load). It's a + * [Surface] rather than a [Button] because Material3's Button has no long-press hook; the + * [combinedClickable] modifier gives us both. + * + * A null [variant] means the row doesn't apply to the selected integration: the button renders + * dimmed and, when clicked, explains why via a toast ([disabledReason]) instead of running. + */ + @OptIn(ExperimentalFoundationApi::class) + @Composable + private fun DemoRowButton( + label: String, + color: Color, + variant: DemoVariant?, + sagpDisabledReason: String?, + disabledReason: String, + ) { + val context = LocalContext.current + val enabled = variant != null && sagpDisabledReason == null + val explain = { + Toast.makeText(context, sagpDisabledReason ?: disabledReason, Toast.LENGTH_SHORT).show() + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = ButtonDefaults.shape, + color = if (enabled) color else color.copy(alpha = 0.26f), + contentColor = Color.White, + ) { + Box( + modifier = + Modifier.combinedClickable( + onClick = { if (enabled) onTap(variant) else explain() }, + onLongClick = { if (enabled) onLongPress(variant) else explain() }, + ) + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, style = MaterialTheme.typography.labelLarge) + } + } + } + + @Composable + private fun ResetButton(dbOperationInFlight: Boolean, resetInProgress: Boolean) { + // Debounce demo-driven disablement so fast taps don't flicker the button; reset disables + // immediately via [resetInProgress]. [dbOperationInFlight] still guards [onClick] either way. + var enabled by remember { mutableStateOf(true) } + LaunchedEffect(dbOperationInFlight, resetInProgress) { + when { + resetInProgress -> enabled = false + dbOperationInFlight -> { + delay(RESET_DISABLE_DEBOUNCE_MS) + enabled = false + } + else -> enabled = true + } + } + + Button( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + enabled = enabled, + colors = ButtonDefaults.buttonColors(containerColor = Color.Gray, contentColor = Color.White), + onClick = { + if (dbOperationInFlight) return@Button + lifecycleScope.launch { + this@SQLiteActivity.resetInProgress = true + this@SQLiteActivity.dbOperationInFlight = true + try { + val message = withContext(Dispatchers.IO) { resetDatabases() } + latestResult = message + warmUpErrors = SampleDatabases.warmUpErrors + sqlDetail = "DROP: deletes every demo database file, resetting all row counts to 0." + } catch (t: Throwable) { + Log.e(TAG, "Reset failed", t) + latestResult = "Reset failed: ${t.message ?: t.javaClass.simpleName}" + } finally { + this@SQLiteActivity.dbOperationInFlight = false + this@SQLiteActivity.resetInProgress = false + } + } + }, + ) { + Text("Drop all tables (reset)") + } + } + + @Composable + private fun DetailField(label: String, value: String, borderColor: Color) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + textStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp), + // The border color is driven by the shimmer animation so the box pulses on each SQL run. + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = borderColor, + unfocusedBorderColor = borderColor, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + + /** + * Runs [block] inside a scope-bound transaction and returns the result. When [shareScreenTrace] + * is enabled, the transaction continues this screen's trace so all demos in one visit share a + * trace; otherwise it starts its own trace (1 transaction = 1 trace). + */ + private suspend fun runInTransaction( + transactionName: String, + op: String, + block: suspend () -> String, + ): String { + // Continuing the screen trace keeps the shared trace id but mints a fresh span id for this + // transaction; the standalone path (and the continueTrace fallback when tracing is disabled) + // gives the transaction its own trace. + val context = + if (shareScreenTrace) { + Sentry.continueTrace(screenTraceHeader, null)?.apply { + name = transactionName + operation = op + } ?: TransactionContext(transactionName, op) + } else { + TransactionContext(transactionName, op) + } + + val options = TransactionOptions().apply { isBindToScope = true } + val transaction = Sentry.startTransaction(context, options) + + return try { + val result = block() + transaction.status = SpanStatus.OK + result + } catch (t: Throwable) { + transaction.status = SpanStatus.INTERNAL_ERROR + Log.e(TAG, "$transactionName failed", t) + "$transactionName failed: ${t.message ?: t.javaClass.simpleName}" + } finally { + transaction.finish() + } + } + + private fun sagpDisabledReason(mode: IntegrationMode, row: DemoRow): String? { + if (!BuildConfig.USE_SAGP) return null + val demo = row.variantFor(mode)?.demo ?: return null + return when (demo) { + SqlDemo.DRIVER_DIRECT -> SAGP_DIRECT_DRIVER_MESSAGE + else -> null + } + } + + /** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */ + private suspend fun resetDatabases(): String { + val cleared = SampleDatabases.reset(applicationContext) + SampleDatabases.awaitWarmUp() + return buildString { + append("Dropped tables: cleared $cleared database file(s).") + if (SampleDatabases.warmUpErrors.isNotEmpty()) { + append("\n\n") + append(SampleDatabases.warmUpErrors) + } + } + } + + private companion object { + + private const val TAG = "SQLiteActivity" + + /** Demo SQL shorter than this won't visibly disable the reset button. */ + private const val RESET_DISABLE_DEBOUNCE_MS = 300L + + /** + * Builds a fresh sentry-trace header ("--") representing this screen + * visit's trace. The trailing "-1" marks it sampled so the whole session is kept. + */ + private fun newScreenTrace(): String = "${SentryId()}-${SpanId()}-1" + } +} + +private fun String.looksLikeError(): Boolean = contains("failed", ignoreCase = true) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt new file mode 100644 index 00000000000..f01a529499d --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -0,0 +1,368 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import android.util.Log +import androidx.room.Room +import androidx.room3.Room as Room3 +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.sqlite.driver.SupportSQLiteDriver +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper +import io.sentry.samples.android.BuildConfig +import io.sentry.samples.android.sqlite.SampleDatabases.driverDirectLock +import io.sentry.samples.android.sqlite.SampleDatabases.openHelperDirectLock +import io.sentry.samples.android.sqlite.SampleDatabases.reset +import io.sentry.samples.android.sqlite.SampleDatabases.warmUp +import io.sentry.sqlite.SentrySQLiteDriver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Process-lifetime holder for the demo databases used by [SQLiteActivity]. + * + * Real apps open a database once (commonly a DI singleton) and keep it open for the process, so a + * screen that touches the DB almost always finds it already "warm". We model that here: [warmUp] is + * called from `MyApplication` at launch, off the main thread, so the one-time open + Room + * connection-pool bootstrap happens with no active transaction — those `db.sql.query` spans have + * nothing to attach to and are dropped. Every screen afterward reuses the warm handle and records + * only its statements of interest. + * + * Handles are held for the whole process: Android has no reliable "app closed" callback, and the OS + * reclaims the connections on process death, so we never close them except via [reset] (the "Drop + * all tables" button), which closes, deletes the files, and re-warms. + * + * The two "direct" handles wrap a single raw connection that isn't safe for concurrent use, so + * callers serialize their whole unit of work via [driverDirectLock] / [openHelperDirectLock]. Room + * and SQLDelight manage their own connection pools and don't need one. + */ +object SampleDatabases { + + private const val TAG = "SampleDatabases" + + /** Non-empty when one or more warm-up steps failed; shown on [SQLiteActivity]. */ + @Volatile + var warmUpErrors: String = "" + private set + + @Volatile private var warmUpComplete = false + @Volatile private var warmUpGeneration = 0 + @Volatile private var warmUpJob: Job? = null + + fun isWarmUpComplete(): Boolean = warmUpComplete + + /** Blocks until the in-flight [warmUp] job (if any) finishes. */ + suspend fun awaitWarmUp() { + warmUpJob?.join() + } + + private val sqlAccess = Mutex() + + val driverDirectLock = Any() + val bridgeDirectLock = Any() + val openHelperDirectLock = Any() + + /** Serializes demo SQL and [reset] so handles are never closed mid-statement. */ + suspend fun withSqlAccess(block: suspend () -> T): T = sqlAccess.withLock { block() } + + @Volatile private var driverConnection: SQLiteConnection? = null + @Volatile private var bridgeConnection: SQLiteConnection? = null + @Volatile private var driverRoom2Db: SampleRoom2Database? = null + @Volatile private var bridgeRoom2Db: SampleRoom2Database? = null + @Volatile private var driverRoom3Db: SampleRoom3Database? = null + @Volatile private var directHelper: SupportSQLiteOpenHelper? = null + @Volatile private var bridgeDirectHelper: SupportSQLiteOpenHelper? = null + @Volatile private var openHelperRoomDb: SampleRoom2Database? = null + @Volatile private var sqlDelightDriver: AndroidSqliteDriver? = null + + fun driverConnection(context: Context): SQLiteConnection = + synchronized(driverDirectLock) { + driverConnection + ?: wrapDriver(BundledSQLiteDriver()).open(databaseFile(context, "driver_direct.db")).also { + it.execSQL(SqlStatements.CREATE_SONG) // one-time table setup, at open + driverConnection = it + } + } + + /** + * The Room 2.7+ duplicate-span scenario: a Sentry-wrapped open helper bridged to + * [SupportSQLiteDriver], then passed to [SentrySQLiteDriver.create] (which no-ops on the bridge). + */ + fun bridgeConnection(context: Context): SQLiteConnection = + synchronized(bridgeDirectLock) { + bridgeConnection + ?: run { + // SupportSQLiteDriver.open() requires fileName to match the helper's databaseName(); + // use the absolute path Room and the direct driver path both pass to open(). + val dbPath = databaseFile(context, "bridge_direct.db") + wrapDriver(SupportSQLiteDriver(buildBridgeDirectHelper(context, dbPath))) + .open(dbPath) + .also { + it.execSQL(SqlStatements.CREATE_SONG) + bridgeConnection = it + } + } + } + + fun bridgeRoom2Db(context: Context): SampleRoom2Database = + synchronized(this) { + bridgeRoom2Db + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "bridge_room2.db", + ) + .setDriver( + wrapDriver(SupportSQLiteDriver(buildBridgeRoom2Helper(context.applicationContext))) + ) + .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(true) + .build() + .also { bridgeRoom2Db = it } + } + + fun driverRoom2Db(context: Context): SampleRoom2Database = + synchronized(this) { + driverRoom2Db + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "driver_room2.db", + ) + .setDriver(wrapDriver(BundledSQLiteDriver())) + .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(true) + .build() + .also { driverRoom2Db = it } + } + + fun driverRoom3Db(context: Context): SampleRoom3Database = + synchronized(this) { + driverRoom3Db + ?: Room3.databaseBuilder(context.applicationContext, "driver_room3.db") + .setDriver(wrapDriver(BundledSQLiteDriver())) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + .also { driverRoom3Db = it } + } + + fun directHelper(context: Context): SupportSQLiteOpenHelper = + synchronized(openHelperDirectLock) { + directHelper ?: buildDirectHelper(context).also { directHelper = it } + } + + fun openHelperRoomDb(context: Context): SampleRoom2Database = + synchronized(this) { + openHelperRoomDb + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "openhelper_room.db", + ) + .openHelperFactory { configuration -> + wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) + } + .fallbackToDestructiveMigration(true) + .build() + .also { openHelperRoomDb = it } + } + + fun sqlDelightDriver(context: Context): AndroidSqliteDriver = + synchronized(this) { + sqlDelightDriver + ?: AndroidSqliteDriver( + schema = SampleSQLDelightDatabase.Schema, + context = context.applicationContext, + name = "openhelper_sqldelight.db", + factory = + SupportSQLiteOpenHelper.Factory { configuration -> + wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) + }, + ) + .also { sqlDelightDriver = it } + } + + private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper = + buildSentryHelper(context, "openhelper_direct.db").also { directHelper = it } + + private fun buildBridgeDirectHelper(context: Context, dbPath: String): SupportSQLiteOpenHelper = + buildSentryHelper(context, dbPath).also { bridgeDirectHelper = it } + + /** + * Open helper for the Bridge + Room 2 stack. Must not create tables in [onCreate] — Room owns the + * schema when [setDriver] is used. Room also passes [SupportSQLiteOpenHelper.databaseName] (the + * short name below), not an absolute path, to [SupportSQLiteDriver.open]. + * + * The callback version must be 1 (FrameworkSQLiteOpenHelper rejects < 1). That sets `PRAGMA + * user_version = 1` before Room opens, so Room would skip [onCreate] and validate the empty file + * as pre-packaged → "invalid schema". [onOpen] clears user_version back to 0 until + * [ROOM_MASTER_TABLE] exists. + */ + private fun buildBridgeRoom2Helper(context: Context): SupportSQLiteOpenHelper { + val configuration = + SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) + .name("bridge_room2.db") + .callback( + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = Unit + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = + Unit + + override fun onOpen(db: SupportSQLiteDatabase) { + if (!db.hasRoomMasterTable()) { + db.execSQL("PRAGMA user_version = 0") + } + } + } + ) + .build() + return wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) + } + + private fun buildSentryHelper(context: Context, dbName: String): SupportSQLiteOpenHelper { + val configuration = + SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) + .name(dbName) + .callback( + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL(SqlStatements.CREATE_SONG) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = + Unit + } + ) + .build() + return wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) + } + + private fun wrapDriver(driver: SQLiteDriver): SQLiteDriver = + if (BuildConfig.USE_SAGP) driver else SentrySQLiteDriver.create(driver) + + private fun wrapOpenHelper(delegate: SupportSQLiteOpenHelper): SupportSQLiteOpenHelper = + if (BuildConfig.USE_SAGP) delegate else SentrySupportSQLiteOpenHelper.create(delegate) + + /** Opens every database on a background thread, forcing the one-time open + bootstrap to run. */ + fun warmUp(context: Context) { + val appContext = context.applicationContext + val generation = ++warmUpGeneration + warmUpComplete = false + warmUpErrors = "" + Log.i(TAG, "Warm-up starting (USE_SAGP=${BuildConfig.USE_SAGP})") + // Fire-and-forget: the warm-up outlives no particular screen, so a bare scope is fine here. + warmUpJob = + CoroutineScope(Dispatchers.IO).launch { + val failures = mutableListOf() + runWarmUpStep("driver direct", failures) { driverConnection(appContext) } + runWarmUpStep("bridge direct", failures) { bridgeConnection(appContext) } + // primeWriter() + count() opens both Room pool connections (writer + reader), so the first + // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its + // transaction. + runWarmUpStep("driver Room 2", failures) { + driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("bridge Room 2", failures) { + bridgeRoom2Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("driver Room 3", failures) { + driverRoom3Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("open helper direct", failures) { directHelper(appContext).writableDatabase } + runWarmUpStep("open helper Room", failures) { + openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("SQLDelight", failures) { + SampleSQLDelightDatabase(sqlDelightDriver(appContext)) + .songQueries + .countSongs() + .executeAsOne() + } + if (generation == warmUpGeneration) { + warmUpErrors = failures.joinToString("\n") { "Warm-up failed: $it" } + warmUpComplete = true + } + } + } + + private inline fun runWarmUpStep(step: String, failures: MutableList, block: () -> Unit) { + try { + block() + } catch (t: Throwable) { + Log.e(TAG, "Warm-up failed: $step", t) + failures.add("$step: ${t.message ?: t.javaClass.simpleName}") + } + } + + /** + * Closes the open handles, deletes every demo database file, then re-warms. Returns the number of + * files cleared. Waits for any in-flight demo SQL (including [UiLoadActivity]) to finish first. + */ + suspend fun reset(context: Context): Int = withSqlAccess { + closeAll() + val appContext = context.applicationContext + val names = + listOf( + "driver_direct.db", + "bridge_direct.db", + "driver_room2.db", + "bridge_room2.db", + "driver_room3.db", + "openhelper_direct.db", + "openhelper_room.db", + "openhelper_sqldelight.db", + ) + val cleared = names.count { appContext.deleteDatabase(it) } + warmUp(appContext) + cleared + } + + private fun closeAll() { + synchronized(driverDirectLock) { + driverConnection?.close() + driverConnection = null + } + synchronized(bridgeDirectLock) { + bridgeConnection?.close() + bridgeConnection = null + bridgeDirectHelper?.close() + bridgeDirectHelper = null + } + synchronized(openHelperDirectLock) { + directHelper?.close() + directHelper = null + } + synchronized(this) { + driverRoom2Db?.close() + driverRoom2Db = null + bridgeRoom2Db?.close() + bridgeRoom2Db = null + driverRoom3Db?.close() + driverRoom3Db = null + openHelperRoomDb?.close() + openHelperRoomDb = null + sqlDelightDriver?.close() + sqlDelightDriver = null + } + } + + private fun databaseFile(context: Context, name: String): String = + context.applicationContext.getDatabasePath(name).also { it.parentFile?.mkdirs() }.absolutePath + + private fun SupportSQLiteDatabase.hasRoomMasterTable(): Boolean = + query("SELECT 1 FROM sqlite_master WHERE name = '$ROOM_MASTER_TABLE' LIMIT 1").use { + it.moveToFirst() + } +} + +private const val ROOM_MASTER_TABLE = "room_master_table" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq new file mode 100644 index 00000000000..345e55a3582 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq @@ -0,0 +1,17 @@ +CREATE TABLE song ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + artist TEXT NOT NULL +); + +insertSong: +INSERT INTO song(title, artist) +VALUES (?, ?); + +selectAll: +SELECT * +FROM song; + +countSongs: +SELECT count(*) +FROM song; diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt new file mode 100644 index 00000000000..9bd2d624694 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt @@ -0,0 +1,261 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Rows inserted (and then consumed + processed) per demo when "heavy application-level work" is + * enabled. + */ +private const val HEAVY_ROW_COUNT = 50 + +/** + * Identifies a single SQLite demo: one of the two integrations crossed with the way it's used + * (raw/direct, Room, or SQLDelight). Used to dispatch the same SQL from both trace styles. + */ +enum class SqlDemo { + DRIVER_DIRECT, + DRIVER_ROOM2, + DRIVER_ROOM3, + BRIDGE_DIRECT, + BRIDGE_ROOM2, + OPENHELPER_DIRECT, + OPENHELPER_ROOM, + OPENHELPER_SQLDELIGHT, +} + +/** + * Executable SQL and demo runners for the SQLite sample screens. The human-readable "SQL run" + * summaries shown in the UI live in the per-demo [DisplayInfo] constants; keep those in lockstep + * with the statements here. + * + * The actual SQL each demo runs is kept separate from how its trace is created so the two screens + * can share it: + * - [SQLiteActivity]: Wraps [execute] in a manual `Sentry.startTransaction(…)`. + * - [UiLoadActivity]: Calls the same [execute] with no manual transaction, so the screen's auto + * `ui.load` transaction owns the resulting `db.sql.query` spans. + * + * All demos read the shared, already-warm handles from [SampleDatabases] and return a short status + * line. [heavy] mirrors the screen's "heavy app-level work" toggle. When enabled, each demo also + * batch inserts [HEAVY_ROW_COUNT] rows and consumes them with per-row [appWork]. + */ +object SqlStatements { + + const val CREATE_SONG = + "CREATE TABLE IF NOT EXISTS song(id INTEGER PRIMARY KEY, title TEXT, artist TEXT)" + const val INSERT_SONG = "INSERT INTO song(title, artist) VALUES (?, ?)" + const val SELECT_SONGS = "SELECT id, title, artist FROM song" + const val COUNT_SONGS = "SELECT count(*) FROM song" + + /** + * A single multi-row INSERT for [rowCount] songs, bound with [batchSongArgs]. One statement <> + * one round-trip, which is the realistic way to add a known batch of rows, rather than a loop of + * [rowCount] single-row inserts. + */ + fun insertSongsBatch(rowCount: Int): String = + "INSERT INTO song(title, artist) VALUES " + List(rowCount) { "(?, ?)" }.joinToString(", ") + + /** Flattened title/artist bind args for [insertSongsBatch]: "song 0", "artist 0", "song 1", … */ + fun batchSongArgs(rowCount: Int): Array = + Array(rowCount * 2) { i -> if (i % 2 == 0) "song ${i / 2}" else "artist ${i / 2}" } + + suspend fun execute(context: Context, demo: SqlDemo, heavy: Boolean): String = + SampleDatabases.withSqlAccess { + when (demo) { + SqlDemo.DRIVER_DIRECT -> driverDirect(context, heavy) + SqlDemo.DRIVER_ROOM2 -> driverWithRoom2(context, heavy) + SqlDemo.DRIVER_ROOM3 -> driverWithRoom3(context, heavy) + SqlDemo.BRIDGE_DIRECT -> bridgeDirect(context, heavy) + SqlDemo.BRIDGE_ROOM2 -> bridgeWithRoom2(context, heavy) + SqlDemo.OPENHELPER_DIRECT -> openHelperDirect(context, heavy) + SqlDemo.OPENHELPER_ROOM -> openHelperWithRoom(context, heavy) + SqlDemo.OPENHELPER_SQLDELIGHT -> openHelperWithSqlDelight(context, heavy) + } + } + + // --- 1. SentrySQLiteDriver, used directly ------------------------------------------------- + + private fun driverDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.driverDirectLock) { + val connection = SampleDatabases.driverConnection(context) + insert(connection, "Mishima / Closing", "Philip Glass") + insert(connection, "School of Velocity, op 299 no 1, ", "Carl Czerny") + + if (heavy) { + // One multi-row INSERT for all HEAVY_ROWS rows, rather than a naive loop of single-row + // inserts. + connection.prepare(insertSongsBatch(HEAVY_ROW_COUNT)).use { statement -> + var param = 1 + repeat(HEAVY_ROW_COUNT) { row -> + statement.bindText(param++, "song $row") + statement.bindText(param++, "artist $row") + } + statement.step() + } + + connection.prepare(SELECT_SONGS).use { statement -> + while (statement.step()) { + // Consumption: pull each column across the JNI boundary into the ART heap. + val row = "${statement.getLong(0)}:${statement.getText(1)}:${statement.getText(2)}" + // Application work: e.g. per-row decryption. + appWork(row) + } + } + } + "Driver (Direct): ${count(connection)} rows." + } + + private fun insert(connection: SQLiteConnection, title: String, artist: String) { + connection.prepare(INSERT_SONG).use { statement -> + statement.bindText(1, title) + statement.bindText(2, artist) + statement.step() + } + } + + private fun count(connection: SQLiteConnection): Long = + connection.prepare(COUNT_SONGS).use { statement -> + if (statement.step()) statement.getLong(0) else 0 + } + + // --- 1b. SupportSQLiteDriver bridge (helper + driver both wrapped; SDK skips driver wrap) -- + + private fun bridgeDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.bridgeDirectLock) { + val connection = SampleDatabases.bridgeConnection(context) + insert(connection, "Mishima / Closing", "Philip Glass") + insert(connection, "School of Velocity, op 299 no 1, ", "Carl Czerny") + + if (heavy) { + connection.prepare(insertSongsBatch(HEAVY_ROW_COUNT)).use { statement -> + var param = 1 + repeat(HEAVY_ROW_COUNT) { row -> + statement.bindText(param++, "song $row") + statement.bindText(param++, "artist $row") + } + statement.step() + } + + connection.prepare(SELECT_SONGS).use { statement -> + while (statement.step()) { + val row = "${statement.getLong(0)}:${statement.getText(1)}:${statement.getText(2)}" + appWork(row) + } + } + } + "Bridge (Direct): ${count(connection)} rows." + } + + private suspend fun bridgeWithRoom2(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.bridgeRoom2Db(context).songDao(), "Bridge (Room 2)", heavy) + + // --- 2. SentrySQLiteDriver, used through Room 2.7+ ---------------------------------------- + + private suspend fun driverWithRoom2(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.driverRoom2Db(context).songDao(), "Driver (Room 2)", heavy) + + /** + * Shared Room 2 demo so the driver and open-helper paths run *identical* SQL. The only difference + * is how each integration instruments it: the driver spans every read, while the open helper's + * Room reads go via `moveToNext()` and emit no span, so only the INSERTs are spanned. + */ + private suspend fun roomDemo(dao: SongDao, label: String, heavy: Boolean): String { + dao.insert(SongEntity(title = "Spiders (Kidsmoke)", artist = "Wilco")) + if (heavy) { + // Batch insert: one insertAll() runs all rows in a single transaction, vs. a per-row loop. + dao.insertAll(List(HEAVY_ROW_COUNT) { SongEntity(title = "song $it", artist = "artist $it") }) + dao.getAll().forEach { appWork("${it.id}:${it.title}:${it.artist}") } + } + return "$label: ${dao.count()} rows." + } + + // --- 2b. SentrySQLiteDriver, used through Room 3.0+ (androidx.room3) ----------------------- + + private suspend fun driverWithRoom3(context: Context, heavy: Boolean): String { + val dao = SampleDatabases.driverRoom3Db(context).songDao() + dao.insert(SongEntity3(title = "What's Up", artist = "4 Non Blondes")) + if (heavy) { + // Batch insert: one insertAll() runs all rows in a single transaction, vs. a naive per-row + // loop. + dao.insertAll( + List(HEAVY_ROW_COUNT) { SongEntity3(title = "song $it", artist = "artist $it") } + ) + dao.getAll().forEach { appWork("${it.id}:${it.title}:${it.artist}") } + } + return "Driver (Room 3): ${dao.count()} rows." + } + + // --- 3. SentrySupportSQLiteOpenHelper, used directly -------------------------------------- + + private fun openHelperDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.openHelperDirectLock) { + // Runs the *same* SQL as driverDirect(), so the only difference you see in the Sentry UI is + // how each integration instruments identical statements. + val db = SampleDatabases.directHelper(context).writableDatabase + db.execSQL(INSERT_SONG, arrayOf("Mishima / Closing", "Philip Glass")) + db.execSQL(INSERT_SONG, arrayOf("School of Velocity, op 299 no 1, ", "Carl Czerny")) + if (heavy) { + // One multi-row INSERT for all HEAVY_ROWS rows, rather than a naive loop of single-row + // inserts. + db.execSQL(insertSongsBatch(HEAVY_ROW_COUNT), batchSongArgs(HEAVY_ROW_COUNT)) + db.query(SELECT_SONGS).use { cursor -> + while (cursor.moveToNext()) { + // Consumption: read each column out of the cursor window. + val row = "${cursor.getLong(0)}:${cursor.getString(1)}:${cursor.getString(2)}" + // Application work: e.g. per-row decryption. + appWork(row) + } + } + } + "OpenHelper (Direct): ${querySongCount(db)} rows." + } + + /** + * Runs the shared `SELECT count(*)` through the open helper and returns the value, read the + * normal way: moveToFirst() + getInt(). These are delegated straight to the underlying cursor + * (the open helper only instruments getCount()/onMove()/fillWindow()), so this read produces no + * `db.sql.query` span — the same as a real app reading a scalar count. + */ + private fun querySongCount(db: SupportSQLiteDatabase): Int = + db.query(COUNT_SONGS).use { cursor -> + cursor.moveToFirst() + cursor.getInt(0) + } + + // --- 4. SentrySupportSQLiteOpenHelper, used through Room ---------------------------------- + + // Runs the same [roomDemo] SQL as the driver path; only the instrumentation differs. + private suspend fun openHelperWithRoom(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.openHelperRoomDb(context).songDao(), "OpenHelper (Room)", heavy) + + // --- 5. SentrySupportSQLiteOpenHelper, used through SQLDelight ---------------------------- + + private fun openHelperWithSqlDelight(context: Context, heavy: Boolean): String { + val database = SampleSQLDelightDatabase(SampleDatabases.sqlDelightDriver(context)) + database.songQueries.insertSong("Nightcall", "Kavinsky") + if (heavy) { + // Wrap the batch in one transaction, vs. each insertSong() naively committing on its own. + database.transaction { + repeat(HEAVY_ROW_COUNT) { database.songQueries.insertSong("song $it", "artist $it") } + } + database.songQueries.selectAll().executeAsList().forEach { + appWork("${it.id}:${it.title}:${it.artist}") + } + } + // SQLDelight reads its cursor only via moveToNext(), which is delegated past the wrapper, so + // this count read produces no span. + val count = database.songQueries.countSongs().executeAsOne() + return "OpenHelper (SQLDelight): $count rows." + } + + /** + * Simulates per-row application-level work (e.g. decrypting a column) on consumed results. This + * is deliberately CPU-heavy and unrelated to the SQLite engine. + */ + private fun appWork(value: String) { + val digest = java.security.MessageDigest.getInstance("SHA-256") + var bytes = value.toByteArray() + repeat(500) { bytes = digest.digest(bytes) } + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt new file mode 100644 index 00000000000..3cc6d394daa --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt @@ -0,0 +1,72 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.lifecycleScope +import io.sentry.Sentry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Activity that lets us simulate SDK auto-generation of a `ui.load` transaction + attach SQLite + * statement spans to it. + * + * Timing note: the work runs off the main thread, so it finishes after the screen is first drawn. + * Time-to-full-display tracing (enabled in the manifest) keeps the `ui.load` transaction open until + * [Sentry.reportFullyDisplayed], which we call once the work completes — otherwise the transaction + * would auto-finish at first display and the late db spans would have nowhere to attach. + */ +class UiLoadActivity : ComponentActivity() { + + private var status by mutableStateOf("Running under the screen's auto ui.load transaction…") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val id = + SqlDemo.entries.find { it.name == intent.getStringExtra(EXTRA_DEMO_ID) } + ?: run { + finish() + return + } + val heavy = intent.getBooleanExtra(EXTRA_HEAVY, false) + + setContent { UiLoadScreen(status = status, onClose = ::finish) } + + // No Sentry.startTransaction(): the work runs under the auto ui.load:UiLoadActivity span. + lifecycleScope.launch { + status = + try { + val result = + withContext(Dispatchers.IO) { SqlStatements.execute(applicationContext, id, heavy) } + "$result\n\nRan under the auto ui.load transaction." + } catch (t: Throwable) { + Log.e(TAG, "Load failed", t) + "Load failed: ${t.message ?: t.javaClass.simpleName}" + } finally { + // Close the TTFD window so the ui.load transaction finishes with the db spans attached. + Sentry.reportFullyDisplayed() + } + } + } + + companion object { + private const val TAG = "UiLoadActivity" + private const val EXTRA_DEMO_ID = "demo_id" + private const val EXTRA_HEAVY = "heavy" + + /** Builds the intent that runs [id] (honoring the [heavy] toggle) on this UiLoadScreen. */ + fun intent(context: Context, id: SqlDemo, heavy: Boolean): Intent = + Intent(context, UiLoadActivity::class.java) + .putExtra(EXTRA_DEMO_ID, id.name) + .putExtra(EXTRA_HEAVY, heavy) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt new file mode 100644 index 00000000000..6495726448d --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt @@ -0,0 +1,110 @@ +package io.sentry.samples.android.sqlite + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.sentry.samples.android.R + +private val ShimmerHighlight = Color(0xFFBDBDBD) + +@Composable +fun UiLoadScreen(status: String, onClose: () -> Unit) { + MaterialTheme { + Surface { + Box( + modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding().padding(24.dp) + ) { + Column( + modifier = Modifier.align(Alignment.Center).fillMaxWidth().offset(y = (-48).dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShimmerSentryGlyph(modifier = Modifier.size(96.dp)) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = status, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } + + Button( + onClick = onClose, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), + colors = + ButtonDefaults.buttonColors(containerColor = Color.Black, contentColor = Color.White), + ) { + Text("Close") + } + } + } + } +} + +@Composable +private fun ShimmerSentryGlyph(modifier: Modifier = Modifier) { + val progress = remember { Animatable(0f) } + LaunchedEffect(Unit) { + progress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 700, delayMillis = 250, easing = LinearEasing), + ) + } + + Image( + painter = painterResource(R.drawable.sentry_glyph), + contentDescription = "Sentry", + modifier = + modifier + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val p = progress.value + val band = size.width * 0.5f + // Sweep the highlight band diagonally from off the bottom-left corner (p=0) to off the + // top-right corner (p=1): x travels left→right, y travels bottom→top. + val x = -band + (size.width + 2f * band) * p + val y = (size.height + band) - (size.height + 2f * band) * p + drawRect( + brush = + Brush.linearGradient( + colors = listOf(Color.Black, ShimmerHighlight, Color.Black), + start = Offset(x, y), + end = Offset(x + band, y - band), + ), + blendMode = BlendMode.SrcAtop, + ) + }, + ) +} diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml new file mode 100644 index 00000000000..b2dc323d185 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml @@ -0,0 +1,32 @@ + + + + + +