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 a8cd64f19ec..bee668917c1 100644 --- a/.craft.yml +++ b/.craft.yml @@ -1,17 +1,13 @@ minVersion: 0.29.3 changelogPolicy: auto targets: - - name: symbol-collector - includeNames: /libsentry(-android)?\.so/ - batchType: android - bundleIdPrefix: sentry-android-ndk- - name: maven includeNames: /^sentry.*$/ gradleCliPath: ./gradlew mavenCliPath: scripts/mvnw mavenSettingsPath: scripts/settings.xml - mavenRepoId: ossrh - mavenRepoUrl: https://oss.sonatype.org/service/local/staging/deploy/maven2/ + mavenRepoId: ossrh-staging-api + mavenRepoUrl: https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/ android: distDirRegex: /^(sentry-android-|.*-android).*$/ fileReplaceeRegex: /\d+\.\d+\.\d+(-\w+(\.\d+)?)?(-SNAPSHOT)?/ @@ -23,8 +19,13 @@ targets: maven:io.sentry:sentry: maven:io.sentry:sentry-spring: maven:io.sentry:sentry-spring-jakarta: + maven:io.sentry:sentry-spring-7: + maven:io.sentry:sentry-spring-boot: + maven:io.sentry:sentry-spring-boot-jakarta: maven:io.sentry:sentry-spring-boot-starter: maven:io.sentry:sentry-spring-boot-starter-jakarta: + maven:io.sentry:sentry-spring-boot-4: + maven:io.sentry:sentry-spring-boot-4-starter: maven:io.sentry:sentry-servlet: maven:io.sentry:sentry-servlet-jakarta: maven:io.sentry:sentry-logback: @@ -33,22 +34,44 @@ 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-android-okhttp: 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: + maven:io.sentry:sentry-quartz: + maven:io.sentry:sentry-okhttp: maven:io.sentry:sentry-android-navigation: maven:io.sentry:sentry-compose: maven:io.sentry:sentry-compose-android: maven:io.sentry:sentry-compose-desktop: maven:io.sentry:sentry-apollo-3: maven:io.sentry:sentry-android-sqlite: + maven:io.sentry:sentry-android-replay: + 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/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/deduplication.mdc b/.cursor/rules/deduplication.mdc new file mode 100644 index 00000000000..b48516dae44 --- /dev/null +++ b/.cursor/rules/deduplication.mdc @@ -0,0 +1,13 @@ +--- +alwaysApply: false +description: Java SDK Event deduplication +--- + +# Java SDK Event deduplication + +To avoid sending the same error multiple times, there is deduplication logic in place in the SDK. +Duplicate captures can happen due to multiple integrations capturing the exception as well as additional manual calls to `Sentry.captureException`. + +Deduplication is performed in `DuplicateEventDetectionEventProcessor` which returns `null` when it detects a duplicate event causing it to be dropped. + +The `enableDeduplication` option can be used to opt out of deduplication. It is enabled by default. diff --git a/.cursor/rules/e2e_tests.mdc b/.cursor/rules/e2e_tests.mdc new file mode 100644 index 00000000000..17e088774b5 --- /dev/null +++ b/.cursor/rules/e2e_tests.mdc @@ -0,0 +1,28 @@ +--- +alwaysApply: false +description: Java SDK End to End Tests +--- + +# Java SDK End to End Tests (System Tests) + +The samples in the `sentry-samples` directory are used to run end to end tests against them. + +There is a python script (`system-test-runner.py`) that can be used to run one (using `--module SAMPLE_NAME`) or all (using `--all`) system tests. + +The script has an interactive mode (`-i`) which allows selection of test setups to execute, whether to run the tests or just prepare infrastructure for testing from IDE. + +The tests run a mock Sentry server via `system-test-sentry-server.py`. Any system under test will then have a DSN set that reflects this local mock server like `http://502f25099c204a2fbf4cb16edc5975d1@localhost:8000/0`. +By using this local DSN, the system under test sends events to the local mock server. +The tests can then use `TestHelper` to assert envelopes that were received by the mock server. +`TestHelper` uses HTTP requests to retrieve the JSON payload of the received events and deserialize them back to objects for easier assertion. +Tests can then assert events, transactions, logs etc. similar to how they would appear in `beforeSend` and similar callbacks. + +`TestHelper` has a lot of helper methods for asserting, e.g. by span name, log body etc. + +The end to end tests either expect the system under test to either be running on a server or call `java -jar` to execute a CLI system under test. + +For Spring Boot, we spin up the Spring Boot server. The tests then send requests to that server and assert what is sent to Sentry. + +End to end tests are also executed on CI using a matrix build, as defined in `.github/workflows/system-tests-backend.yml`. + +Some of the samples are tested in multiple ways, e.g. with OpenTelemetry Agent auto init turned on and off. 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/new_module.mdc b/.cursor/rules/new_module.mdc new file mode 100644 index 00000000000..5bf2c70c2f7 --- /dev/null +++ b/.cursor/rules/new_module.mdc @@ -0,0 +1,88 @@ +--- +description: Module Addition Rules for sentry-java +alwaysApply: false +--- +# Module Addition Rules for sentry-java + +## Overview + +This document outlines the complete process for adding a new module to the sentry-java repository. Follow these steps in order to ensure proper integration and release management. + +## Step-by-Step Process + +### 1. Create the Module Structure + +1. Create the new module, conforming to the existing naming conventions and build scripts + +2. Add the module to the include list in `settings.gradle.kts` + +If adding a `sentry-samples` module, also add it to the `ignoredProjects` list in the root `build.gradle.kts`: + +```kotlin +ignoredProjects.addAll( + listOf( + // ... existing projects ... + "sentry-samples-{module-name}" + ) +) +``` + +3. If adding a JVM sample, add E2E (system) tests, following the structure we have in the existing JVM examples. + The test should then be added to `test/system-test-runner.py` and `.github/workflows/system-tests-backend.yml`. + +### 2. Create Module Documentation + +Create a `README.md` in the module directory with the following structure: + +```markdown +# sentry-{module-name} + +This module provides an integration for [Technology/Framework Name]. + +Please consult the documentation on how to install and use this integration in the Sentry Docs for [Android](https://docs.sentry.io/platforms/android/integrations/{module-name}/) or [Java](https://docs.sentry.io/platforms/java/tracing/instrumentation/{module-name}/). +``` + +The following tasks are required only when adding a module that isn't a sample. + +### 3. Update Main README.md + +Add the new module to the packages table in the main `README.md` with a placeholder link to the badge: + +```markdown +| sentry-{module-name} | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-{module-name}/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-{module-name}) | | +``` + +Note that the badge will only work after the module is released to Maven Central. + +### 4. Add Documentation to docs.sentry.io + +Add the necessary documentation to [docs.sentry.io](https://docs.sentry.io): +- For Java modules: Add to Java platform docs, usually in integrations section +- For Android modules: Add to Android platform docs, usually in integrations section +- Include installation instructions, configuration options, and usage examples + +### 5. Post release tasks + +Remind the user to perform the following tasks after the module is merged and released: + +1. Add the SDK to the Sentry release registry, following the instructions in the [sentry-release-registry README](https://github.com/getsentry/sentry-release-registry#adding-new-sdks) + +2. Add the module to `.craft.yml` in the `sdks` section: + ```yaml + sdks: + # ... existing modules ... + maven:io.sentry:sentry-{module-name}: + ``` + +## Module Naming Conventions + +- Use kebab-case for module names: `sentry-{module-name}` +- Follow existing patterns: `sentry-okhttp`, `sentry-apollo-4`, `sentry-spring-boot` +- For version-specific modules, include the version: `sentry-apollo-3`, `sentry-apollo-4` + +## Important Notes + +1. **API Files**: Do not modify `.api` files manually. Run `./gradlew apiDump` to regenerate them +2. **Backwards Compatibility**: Ensure new features are opt-in by default +3. **Testing**: Write comprehensive tests for all new functionality +4. **Documentation**: Always include proper documentation and examples diff --git a/.cursor/rules/offline.mdc b/.cursor/rules/offline.mdc new file mode 100644 index 00000000000..14e9419b4de --- /dev/null +++ b/.cursor/rules/offline.mdc @@ -0,0 +1,87 @@ +--- +alwaysApply: false +description: Java SDK Offline behaviour +--- +# Java SDK Offline behaviour + +By default offline caching is enabled for Android but disabled for JVM. +It can be enabled by setting SentryOptions.cacheDirPath. + +For Android, AndroidEnvelopeCache is used. For JVM, if cache path has been configured, EnvelopeCache will be used. + +Any error, event, transaction, profile, replay etc. is turned into an envelope and then sent into ITransport.send. +The default implementation is AsyncHttpTransport. + +If an envelope is dropped due to rate limit and has previously been cached (Cached hint) it will be discarded from the IEnvelopeCache. + +AsyncHttpTransport.send will enqueue an AsyncHttpTransport.EnvelopeSender task onto an executor. + +Any envelope that doesn't have the Cached hint will be stored in IEnvelopeCache by the EventSender task. Previously cached envelopes (Cached hint) will have a noop cache passed to AsyncHttpTransport.EnvelopeSender and thus not cache again. It is also possible cache is disabled in general. + +An envelope being sent directly from SDK API like Sentry.captureException will not have the Retryable hint. + +In case the SDK is offline, it'll mark the envelope to be retried if it has the Retryable hint. +If the envelope is not retryable and hasn't been sent to offline cache, it's recorded as lost in a client report. + +In case the envelope can't be sent due to an error or network connection problems it'll be marked for retry if it has the Retryable hint. +If it's not retryable and hasn't been cached, it's recorded as lost in a client report. + +In case the envelope is sent successfully, it'll be discarded from cache. + +The SDK has multiple mechanisms to deal with envelopes on disk. +- OutboxSender: Sends events coming from other SDKs like NDK that wrote them to disk. +- io.sentry.EnvelopeSender: This is the offline cache. + +Both of these are set up through an integration (SendCachedEnvelopeIntegration) which is configured to use SendFireAndForgetOutboxSender or SendFireAndForgetEnvelopeSender. + +io.sentry.EnvelopeSender is able to pick up files in the cache directory and send them. +It will trigger sending envelopes in cache dir on init and when the connection status changes (e.g. the SDK comes back online, meaning it has Internet connection again). + +## When Envelope Files Are Removed From Cache + +Envelope files are removed from the cache directory in the following scenarios: + +### 1. Successful Send to Sentry Server +When `AsyncHttpTransport` successfully sends an envelope to the Sentry server, it calls `envelopeCache.discard(envelope)` to remove the cached file. This happens in `AsyncHttpTransport.EnvelopeSender.flush()` when `result.isSuccess()` is true. + +### 2. Rate Limited Previously Cached Envelopes +If an envelope is dropped due to rate limiting **and** has previously been cached (indicated by the `Cached` hint), it gets discarded immediately via `envelopeCache.discard(envelope)` in `AsyncHttpTransport.send()`. +In this case the discarded envelope is recorded as lost in client reports. + +### 3. Offline Cache Processing (EnvelopeSender) +When the SDK processes cached envelope files from disk (via `EnvelopeSender`), files are deleted after processing **unless** they are marked for retry. In `EnvelopeSender.processFile()`, the file is deleted with `safeDelete(file)` if `!retryable.isRetry()`. + +### 4. Session File Management +Session-related files (session.json, previous_session.json) are removed during session lifecycle events like session start/end and abnormal exits. + +### 5. Cache rotation +If the number of files in the cache directory has reached the configured limit (SentryOptions.maxCacheItems), the oldest file will be deleted to make room. +This happens in `CacheStrategy.rotateCacheIfNeeded`. The deleted envelope will be recorded as lost in client reports. + +## Retry Mechanism + +**Important**: The SDK does NOT implement a traditional "max retry count" mechanism. Instead: + +### Infinite Retry Approach +- **Retryable envelopes**: Stay in cache indefinitely and are retried when conditions improve (network connectivity restored, rate limits expire, etc.) +- **Non-retryable envelopes**: If they fail to send, they're immediately recorded as lost (not cached for retry) + +### When Envelopes Are Permanently Lost (Not Due to Retry Limits) + +1. **Queue Overflow**: When the transport executor queue is full - recorded as `DiscardReason.QUEUE_OVERFLOW` + +2. **Network Errors (Non-Retryable)**: When an envelope isn't marked as retryable and fails due to network issues - recorded as `DiscardReason.NETWORK_ERROR` + +3. **Rate Limiting**: When envelope items are dropped due to active rate limits - recorded as `DiscardReason.RATELIMIT_BACKOFF` + +4. **Cache Overflow**: When the cache directory has reached maxCacheItems, old files are deleted - recorded as `DiscardReason.CACHE_OVERFLOW` + +### Cache Processing Triggers +Cached envelopes are processed when: +- Network connectivity is restored (via connection status observer) +- SDK initialization occurs +- Rate limits expire +- Manual flush operations + +### File Deletion Implementation +The actual file deletion is handled by `EnvelopeCache.discard()` which calls `envelopeFile.delete()` and logs errors if deletion fails. diff --git a/.cursor/rules/opentelemetry.mdc b/.cursor/rules/opentelemetry.mdc new file mode 100644 index 00000000000..4e773233f04 --- /dev/null +++ b/.cursor/rules/opentelemetry.mdc @@ -0,0 +1,97 @@ +--- +alwaysApply: false +description: Java SDK OpenTelemetry Integration +--- +# Java SDK OpenTelemetry Integration + +## Overview + +The Sentry Java SDK provides comprehensive OpenTelemetry integration through multiple modules: + +- `sentry-opentelemetry-core`: Core OpenTelemetry integration functionality +- `sentry-opentelemetry-agent`: Java Agent-based integration for automatic instrumentation +- `sentry-opentelemetry-agentless`: Manual instrumentation without Java agent +- `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 + +- Support for more libraries and frameworks + - See https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/instrumentation for a list of supported libraries and frameworks +- More automated Performance instrumentation (spans) created + - Using `sentry-opentelemetry-agent` offers most support + - Using `sentry-opentelemetry-agentless-spring` for Spring Boot also has a lot of supported libraries, altough fewer than the agent does + - Note that `sentry-opentelemetry-agentless` will not have any OpenTelemetry auto instrumentation +- Sentry also relies on OpenTelemetry `Context` propagation to propagate Sentry `Scopes`, ensuring e.g. that execution flow for a request shares data and does not leak data into other requests. +- OpenTelemetry also offers better support for distributed tracing since more libraries are supported for attaching tracing information to outgoing requests and picking up incoming tracing information. + +## Key Components + +### Agent vs Agentless + +**Java Agent-based integration**: +- Automatic instrumentation via Java agent +- Can be added to any JAR when starting, no extra dependencies or code changes required. Just add the agent when running the application, e.g. `SENTRY_PROPERTIES_FILE=sentry.properties JAVA_TOOL_OPTIONS="-javaagent:sentry-opentelemetry-agent.jar" java -jar your-application.jar`. +- Uses OpenTelemetry Java agent with Sentry extensions +- Uses bytecode manipulation + +**Agentless-Spring integration**: +- Automatic instrumentation setup via Spring Boot +- Dependency needs to be added to the project. + +**Agentless integration**: +- Manual instrumentation setup +- Dependency needs to be added to the project. + +**Manual Integration**: +While it's possible to manually wire up all the required classes to make Sentry and OpenTelemetry work together, we do not recommend this. +It is instead preferrable to use `SentryAutoConfigurationCustomizerProvider` so the Sentry SDK has a place to manage required classes and update it when changes are needed. +This way customers receive the updated config automatically as oppposed to having to update manually, wire in new classes, remove old ones etc. + +### Integration Architecture + +Sentry will try to locate certain classes that come with the Sentry OpenTelemetry integration to: +- Determine whether any Sentry OpenTelemetry integration is present +- Determine which mode to use and in turn which Sentry auto instrumentation to suppress + +Reflection is used to search for `io.sentry.opentelemetry.OtelContextScopesStorage` and use it instead of `DefaultScopesStorage` when a Sentry OpenTelemetry integration is present at runtime. `IScopesStorage` is used to store Sentry `Scopes` instances. `DefaultScopesStorage` will use a thread local variable to store the current threads' `Scopes` whereas `OtelContextScopesStorage` makes use of OpenTelemetry SDKs `Context`. Sentry OpenTelemetry integrations configure OpenTelemetry to use `SentryOtelThreadLocalStorage` to customize restoring of the previous `Context`. + +OpenTelemetry SDK makes use of `io.opentelemetry.context.Scope` in `try-with-resources` statements that call `close` when a code block is finished. Without customization, it would refuse to restore the previous `Context` onto the `ThreadLocal` if the current state of the `ThreadLocal` isn't the same as the one this scope was created for. Sentry changes this behaviour in `SentryScopeImpl` to restore the previous `Context` onto the `ThreadLocal` even if an inner `io.opentelemetry.context.Scope` wasn't properly cleaned up. Our thinking here is to prefer returning to a clean state as opposed to propagating the problem. The unclean state could happen, if `io.opentelemetry.context.Scope` isn't closed, e.g. when forgetting to put it in a `try-with-resources` statement and not calling `close` (e.g. not putting it in a `finally` block in that case). + +`SentryContextStorageProvider` looks for any other `ContextStorageProvider` and forwards to that to not override any customized `ContextStorage`. If no other provider is found, `SentryOtelThreadLocalStorage` is used. + +`SpanFactoryFactory` is used to configure Sentry to use `io.sentry.opentelemetry.OtelSpanFactory` if the class is present at runtime. Reflection is used to search for it. If the class is not available, we fall back to `DefaultSpanFactory`. + +`DefaultSpanFactory` creates a `SentryTracer` instance when creating a transaction and spans are then created directly on the transaction via `startChild`. +`OtelSpanFactory` instead creates an OpenTelemetry span and wraps it using `OtelTransactionSpanForwarder` to simulate a transaction. The `startChild` invocations on `OtelTransactionSpanForwarder` go through `OtelSpanFactory` again to create the child span. + +## Configuration + +We use `SentryAutoConfigurationCustomizerProvider` to configure OpenTelemetry for use with Sentry and register required classes, hooks etc. + +## Span Processing + +Both Sentry and OpenTelemetry API can be used to create spans. When using Sentry API, `OtelSpanFactory` is used to indirectly create a OpenTelemetry span. +Regardless of API used, when an OpenTelemetry span is created, it goes through `SentrySampler` for sampling and `OtelSentrySpanProcessor` for `Scopes` forking and ensuring the trace is continued. +When Sentry API is used, sampling is performed in `Scopes.createTransaction` before forwarding the call to `OtelSpanFactory`. The sampling decision and other sampling details are forwarded to `SentrySampler` and `OtelSentrySpanProcessor`. + +When a span is finished, regardless of whether Sentry or OpenTelemetry API is used, it goes through `OtelSentrySpanProcessor` to set the end date and then through `BatchSpanProcessor` which will batch spans and then forward them to `SentrySpanExporter`. + +`SentrySpanExporter` collects spans, then structures them to create a transaction for the local root span and attaches child spans to form a span tree. +Some OpenTelemetry attributes are transformed into their corresponding Sentry data structure or format. + +After creating the transaction with child spans `SentrySpanExporter` uses Sentry API to send the transaction to Sentry. This API call however forces the use of `DefaultSpanFactory` in order to create the required Sentry classes for sending and also to not create an infinite loop where any span created will cause a new span to be created recursively. + +## 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/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 new file mode 100644 index 00000000000..e054755d4f5 --- /dev/null +++ b/.cursor/rules/scopes.mdc @@ -0,0 +1,121 @@ +--- +alwaysApply: false +description: Java SDK Hubs and Scopes +--- +# Java SDK Hubs and Scopes + +## `Scopes` + +`Scopes` implements `IScopes` and manages three `Scope` instances, `global`, `isolation` and `current` scope. +For some data, all three `Scope` instances are combined, for others, a certain one is used exclusively and for some we look at each scope in a certain order and use the data of the first scope that has the data set. This logic is contained in `CombinedScopeView`. +Data itself is stored on `Scope` instances. +`Scopes` also has a `parent` field, linking the `Scopes` it was forked off of and a `creator` String, explaining why it was forked. + +## `Hub` + +Up until major version 7 of the Java SDK the `IHub` interface was a central part of the SDK. +In major version 8 we replaced the `IHub` interface with `IScopes`. `IHub` has been deprecated. +While there is some bridging code in place to allow for easier migration, we are planning to remove it in an upcoming major. + +## Scope Types + +We have introduced 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 + +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) -> { ... })`. + +Global scope can be retrieved from `Scopes` via `getGlobalScope`. It can also be retrieved directly via `Sentry.getGlobalScope`. + +### Isolation 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. + +Isolation scope can be retrieved from `Scopes` via `getIsolationScope`. + +### Current scope + +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. + +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). +This happens through `Sentry.scopesStorage` and `DefaultScopesStorage`. + +The lifetime of `Scopes` in the thread local is managed by `ISentryLifecycleToken`. +When the scopes are forked, they are stored into the `ThreadLocal` and a `ISentryLifecycleToken` is returned. +When the `Scopes` are no longer needed, e.g. because a request is finished, `ISentryLifecycleToken.close` can be called to restore the previous state of the `ThreadLocal`. + +## Old versions of the Java SDK + +There were several implementations of the `IHub` interface: +- `Hub` managed a stack of `Scope` instances, which were pushed and popped. +- A `Hub` could be cloned, meaning there could be multiple stacks of scopes active, e.g. for two separate requests being handled in a server application. + +### Migrating to major version 8 of the SDK + +`IHub` has been replaced by `IScopes` +`HubAdapter` has been replaced by `ScopesAdapter` +`Hub.clone` should be replaced by using `pushScope` or `pushIsolationScope` +`Sentry.getCurrentHub` has been replaced by `Sentry.getCurrentScopes` +`Sentry.popScope` has been deprecated. Instead `close` should be called on the `ISentryLifecycleToken` returned e.g. by `pushScope`. This can also be done in a `try-with-resource` block. + +## `globalHubMode` +The SDK has a `globalHubMode` option which affects forking behaviour of the SDK. + +Android has `globalHubMode` enabled by default. +For JVM Desktop applications, `globalHubMode` can be used. +For JVM Backend applications (servers) we discourage enabling `globalHubMode` since it will cause scopes to bleed into each other. This can e.g. mean that state from request A leaks into request B and events sent to Sentry contain a mix of both request A and B potentially rendering the data useless. + +### Enabled + +If `globalHubMode` is enabled, the SDK avoids forking scopes. + +This means, retrieving current scopes on a thread where specific scopes do not exist yet for the thread, the root scopes are not forked but returned directly. +The SDK also doesn't fork scopes when `Sentry.pushScope`, `Sentry.pushIsolation`, `Sentry.withScope` or `Sentry.withIsolationScope` are executed. + +The suppression of forking via `globalHubMode` only applies when using `Sentry` static API or `ScopesAdapter`. +In case the `Scopes` instance is accessed directly, forking will happen as if `globalHubMode` is disabled. +However, while it's possible to use `Sentry.setCurrentScopes` it does not have any effect due to `Sentry.getCurrentScopes` directly returning `rootScopes` if `globalHubMode` is enabled. +This means the forked scopes have to be managed manually, e.g. by keeping a reference and accessing Sentry API via the reference instead of using static API. + +`ScopesAdapter` makes use of the static `Sentry` API internally. It allows us to access the correct scopes for the current context without passing it along explicitly. It also makes testing easier. + +### Disabled + +If `globalHubMode` is disabled, the SDK forks scopes freely, e.g. when: +- `Sentry.getCurrentScopes()` is executed on a Thread where no specific scopes for that thread have been stored yet. In this case the SDK will fork `rootScopes` (stored in a `Sentry` static property). +- `withScope` or `withIsolationScope` are executed +- `pushScope` or `pushIsolationScope` are executed + +## `defaultScopeType` + +The `defaultScopeType` controls which `Scope` instance is being used for writing to and reading from as a default value. +When using API like `Sentry.setTag` the SDK adds that tag to the default `Scope`. + +This also ensures, customers who migrate to the latest SDK version and already have `Sentry.configureScope` invocations in place, will now write to the default `Scope` instance that was chosen. + +The default value for `defaultScopeType` is `ISOLATION` scope for JVM and `CURRENT` scope for Android. + +Which fields are written/read from/to `defaultScopeType` is controlled in `CombinedScopeView`. diff --git a/.editorconfig b/.editorconfig index 9f227909547..4aad35e29d3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,19 +2,18 @@ root = true [*] indent_style = space +indent_size = 2 trim_trailing_whitespace = true insert_final_newline = true +max_line_length = 140 +ij_java_names_count_to_use_import_on_demand = 9999 +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL [*.md] trim_trailing_whitespace = false [*.java] -indent_size = 2 charset = utf-8 [*.{kt,kts}] -indent_size = 4 charset = utf-8 - -[*.xml] -indent_size = 2 diff --git a/.envrc b/.envrc new file mode 100644 index 00000000000..f58a7cee600 --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +export VIRTUAL_ENV="${PWD}/.venv" +devenv sync +PATH_add "${PWD}/.venv/bin" diff --git a/.fossa.yml b/.fossa.yml new file mode 100644 index 00000000000..d4ee8ca10a1 --- /dev/null +++ b/.fossa.yml @@ -0,0 +1,4 @@ +version: 3 +targets: + exclude: + - type: setuptools diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000000..2614e090e80 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Reformat codebase with Ktfmt and more accurate spotless configuration: #4499 +8b8369f06cbc5a9738de7810b1df5863b3ac6bcb diff --git a/.gitattributes b/.gitattributes index a41c0a0e15b..f444fd5957d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +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 dfca015d130..6e1f71a7677 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @adinauer @romtsn @stefanosiano @markushi +* @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 9b6bfc9ff6a..5dff43579c6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_android.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_android.yml @@ -1,6 +1,6 @@ name: 🐞 Bug Report - Android description: Tell us about something that's not working the way we (probably) intend. -labels: ["Platform: Android", "Type: Bug"] +labels: ["Android", "Bug"] body: - type: dropdown id: integration @@ -10,13 +10,14 @@ body: options: - sentry-android - sentry-android-ndk - - sentry-android-okhttp - sentry-android-timber - sentry-android-fragment - sentry-android-sqlite - sentry-apollo - - sentry-compose - sentry-apollo-3 + - sentry-compose + - sentry-launchdarkly-android + - sentry-okhttp - other validations: required: true @@ -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 ef030cbf438..8355d75a43b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_java.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_java.yml @@ -1,6 +1,6 @@ name: 🐞 Bug Report - Java description: Tell us about something that's not working the way we (probably) intend. -labels: ["Platform: Java", "Type: Bug"] +labels: ["Java", "Bug"] body: - type: dropdown id: integration @@ -15,18 +15,31 @@ body: - sentry-apollo-3 - sentry-kotlin-extensions - sentry-opentelemetry-agent + - sentry-opentelemetry-agentless + - sentry-opentelemetry-agentless-spring - sentry-opentelemetry-core - sentry-servlet - sentry-servlet-jakarta + - sentry-spring-boot + - sentry-spring-boot-jakarta - sentry-spring-boot-starter - sentry-spring-boot-starter-jakarta + - sentry-spring-boot-4 + - sentry-spring-boot-4-starter - sentry-spring - sentry-spring-jakarta + - sentry-spring-7 - sentry-logback - sentry-log4j2 - sentry-graphql + - sentry-graphql-22 + - sentry-quartz - sentry-openfeign + - sentry-openfeature + - sentry-launchdarkly-server - sentry-apache-http-client-5 + - sentry-okhttp + - sentry-reactor - other validations: required: true @@ -40,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/ISSUE_TEMPLATE/feature_android.yml b/.github/ISSUE_TEMPLATE/feature_android.yml index 31619ab8c9c..d1f71024569 100644 --- a/.github/ISSUE_TEMPLATE/feature_android.yml +++ b/.github/ISSUE_TEMPLATE/feature_android.yml @@ -1,6 +1,6 @@ name: 💡 Feature Request - Android description: Tell us about a problem our SDK could solve but doesn't. -labels: ["Platform: Android", "Type: Feature Request"] +labels: ["Android", "Feature"] body: - type: textarea id: problem diff --git a/.github/ISSUE_TEMPLATE/feature_java.yml b/.github/ISSUE_TEMPLATE/feature_java.yml index ed509856762..686ad45e229 100644 --- a/.github/ISSUE_TEMPLATE/feature_java.yml +++ b/.github/ISSUE_TEMPLATE/feature_java.yml @@ -1,6 +1,6 @@ name: 💡 Feature Request - Java description: Tell us about a problem our SDK could solve but doesn't. -labels: ["Platform: Java", "Type: Feature Request"] +labels: ["Java", "Feature"] body: - type: textarea id: problem diff --git a/.github/ISSUE_TEMPLATE/maintainer-blank.yml b/.github/ISSUE_TEMPLATE/maintainer-blank.yml index 3c4607c465a..150f35a1316 100644 --- a/.github/ISSUE_TEMPLATE/maintainer-blank.yml +++ b/.github/ISSUE_TEMPLATE/maintainer-blank.yml @@ -1,6 +1,6 @@ name: Blank Issue description: Blank Issue. Reserved for maintainers. -labels: ["Platform: Java"] +labels: ["Java"] body: - type: textarea id: description 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/file-filters.yml b/.github/file-filters.yml new file mode 100644 index 00000000000..2b81e2f0b6d --- /dev/null +++ b/.github/file-filters.yml @@ -0,0 +1,12 @@ +# This is used by the action https://github.com/dorny/paths-filter + +high_risk_code: &high_risk_code + # Transport classes + - "sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java" + - "sentry/src/main/java/io/sentry/transport/HttpConnection.java" + - "sentry/src/main/java/io/sentry/transport/QueuedThreadPoolExecutor.java" + - "sentry/src/main/java/io/sentry/transport/RateLimiter.java" + - "sentry-apache-http-client-5/src/main/java/io/sentry/transport/apache/ApacheHttpClientTransport.java" + + # Class used by hybrid SDKs + - "sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 459ff3e14cd..baa2dad44a2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,25 +1,35 @@ ## :scroll: Description - + ## :bulb: Motivation and Context + ## :green_heart: How did you test it? + ## :pencil: Checklist -- [ ] I reviewed the submitted code. +- [ ] I added GH Issue ID _&_ Linear ID - [ ] I added tests to verify the changes. - [ ] No new PII added or SDK only sends newly added PII if `sendDefaultPII` is enabled. - [ ] I updated the docs if needed. +- [ ] I updated the wizard if needed. - [ ] 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 5aa25252568..4dc779ce812 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -4,63 +4,111 @@ on: push: branches: - main - - release/** pull_request: -jobs: - cancel-previous-workflow: - runs-on: ubuntu-latest - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@b173b6ec0100793626c2d9e6b90435061f4fc3e5 # pin@0.11.0 - with: - access_token: ${{ github.token }} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: agp-matrix-compatibility: timeout-minutes: 30 - runs-on: macos-latest + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - agp: [ '8.0.0','8.1.0-alpha11' ] + agp: [ '9.0.0', '9.1.1', '9.2.1' ] integrations: [ true, false ] name: AGP Matrix Release - AGP ${{ matrix.agp }} - Integrations ${{ matrix.integrations }} env: VERSION_AGP: ${{ matrix.agp }} APPLY_SENTRY_INTEGRATIONS: ${{ matrix.integrations }} + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: - name: Checkout Repo - uses: actions/checkout@v3 - - - name: Setup Gradle - uses: gradle/gradle-build-action@40b6781dcdec2762ad36556682ac74e31030cfe2 # pin@v2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@v3 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: AVD cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-api-30-x86_64-aosp_atd + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 + with: + api-level: 30 + target: aosp_atd + channel: canary # Necessary for ATDs + arch: x86_64 + force-avd-creation: false + disable-animations: true + disable-spellchecker: true + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disk-size: 4096M + script: echo "Generated AVD snapshot for caching." + # Clean, build and release a test apk - name: Make assembleUiTests run: make assembleUiTests - # We stop gradle at the end to make sure the cache folders - # don't contain any lock files and are free to be cached. - - name: Make stop - run: make stop - # 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@d94c3fbe4fe6a29e4a5ba47c12fb47677c73656b # pin@v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 with: api-level: 30 + target: aosp_atd + channel: canary # Necessary for ATDs + arch: x86_64 force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true disable-spellchecker: true - target: 'aosp_atd' - channel: canary # Necessary for ATDs - script: ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest -DtestBuildType=release --daemon + 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 -Denvironment=github --daemon + + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: test-results-AGP${{ matrix.agp }}-Integrations${{ matrix.integrations }} + path: | + **/build/reports/* + **/build/outputs/*/connected/* + **/build/outputs/mapping/release/* + + - name: Test Report + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 + if: always() + with: + name: JUnit AGP ${{ matrix.agp }} - Integrations ${{ matrix.integrations }} + path: | + **/build/outputs/androidTest-results/**/*.xml + reporter: java-junit + output-to: step-summary + fail-on-error: false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e1c3e75014b..ef9aa7cfc36 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,66 +3,79 @@ on: push: branches: - main - - release/** pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: - name: Build Job ${{ matrix.os }} - Java ${{ matrix.java }} - runs-on: ${{ matrix.os }} - strategy: - # we want that the matrix keeps running, default is to cancel them if it fails. - fail-fast: false - matrix: - # TODO: windows-latest - os: [ubuntu-latest, macos-latest] - # Zulu Community distribution of OpenJDK - java: ['17'] + name: Build Job ubuntu-latest - Java 17 + runs-on: ubuntu-latest + + env: + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: - - name: Git checkout - uses: actions/checkout@v3 + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + submodules: 'recursive' - - name: 'Set up Java: ${{ matrix.java }}' - uses: actions/setup-java@v3 + - name: Setup Java Version + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: - java-version: ${{ matrix.java }} distribution: 'temurin' + java-version: '17' - - name: Cache Gradle packages - uses: actions/cache@v3 + # 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: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- + path: buildSrc/build + key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - # Clean, check formatting, build and do a dry release - - name: Make all - run: make all + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: Run Tests and Lint + run: make preMerge - # We stop gradle at the end to make sure the cache folders - # don't contain any lock files and are free to be cached. - - name: Make stop - run: make stop + - name: Install Sentry CLI + uses: getsentry/action-setup-cli@70d7e587b84c2e78cf4d37cd33d7b74fb3729c1b # v1 - - name: Archive packages - # We need artifacts from only one the builds - if: runner.os == 'Linux' && matrix.java == '17' - uses: actions/upload-artifact@v3 + - 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ${{ github.sha }} - if-no-files-found: error + name: test-results-build path: | - ./*/build/distributions/*.zip - ./sentry-opentelemetry/*/build/distributions/*.zip - ./sentry-android-ndk/build/intermediates/merged_native_libs/release/out/lib/* + **/build/reports/* - - name: Upload coverage to Codecov - # We need coverage data from only one the builds - if: runner.os == 'Linux' && matrix.java == '17' - uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # pin@v3 + - name: Test Report + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 + if: always() with: - name: sentry-java + name: JUnit Build + list-suites: 'failed' + list-tests: 'failed' + path: | + **/build/test-results/**/*.xml + reporter: java-junit + output-to: step-summary + fail-on-error: false diff --git a/.github/workflows/changes-in-high-risk-code.yml b/.github/workflows/changes-in-high-risk-code.yml new file mode 100644 index 00000000000..44d65924209 --- /dev/null +++ b/.github/workflows/changes-in-high-risk-code.yml @@ -0,0 +1,49 @@ +name: Changes In High Risk Code +on: + pull_request: + +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + files-changed: + name: Detect changed files + runs-on: ubuntu-latest + # Map a step output to a job output + outputs: + high_risk_code: ${{ steps.changes.outputs.high_risk_code }} + high_risk_code_files: ${{ steps.changes.outputs.high_risk_code_files }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Get changed files + id: changes + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + with: + token: ${{ github.token }} + filters: .github/file-filters.yml + + # Enable listing of files matching each filter. + # Paths to files will be available in `${FILTER_NAME}_files` output variable. + list-files: csv + + validate-high-risk-code: + if: needs.files-changed.outputs.high_risk_code == 'true' + needs: files-changed + runs-on: ubuntu-latest + steps: + - name: Comment on PR to notify of changes in high risk files + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + high_risk_code: ${{ needs.files-changed.outputs.high_risk_code_files }} + with: + script: | + const highRiskFiles = process.env.high_risk_code; + const fileList = highRiskFiles.split(',').map(file => `- [ ] ${file}`).join('\n'); + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `### 🚨 Detected changes in high risk code 🚨 \n High-risk code has higher potential to break the SDK and may be hard to test. To prevent severe bugs, apply the rollout process for releasing such changes and be extra careful when changing and reviewing these files:\n ${fileList}` + }) 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 94fdea70bd3..57e2e4a1073 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -3,49 +3,46 @@ name: 'CodeQL' on: push: branches: [main] - pull_request: - # The branches below must be a subset of the branches above - branches: [main] schedule: - cron: '17 23 * * 3' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: analyze: name: Analyze - runs-on: ubuntu-latest + runs-on: macos-15 - strategy: - fail-fast: false - matrix: - language: ['cpp', 'java'] + env: + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: 'recursive' - - name: 'Set up Java: ${{ matrix.java }}' - uses: actions/setup-java@v3 + - name: Setup Java Version + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: - java-version: 17 distribution: 'temurin' + java-version: '17' - - name: Cache Gradle packages - uses: actions/cache@v3 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Initialize CodeQL - uses: github/codeql-action/init@cdcdbb579706841c47f7063dda365e292e5cad7a # pin@v2 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # pin@v2 with: - languages: ${{ matrix.language }} + languages: 'java' - - run: | - ./gradlew assemble + - name: Build Java + run: | + ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdcdbb579706841c47f7063dda365e292e5cad7a # pin@v2 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # pin@v2 diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 000b75ff3ee..e40b4563b00 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -2,8 +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: - uses: getsentry/github-workflows/.github/workflows/danger.yml@v2 + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/danger@607fed74f812e69201531a5185b6c3c57caa4e89 # v3 diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index bce8ee0165c..a7be2bdb001 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,15 +11,24 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/gradle-build-action@40b6781dcdec2762ad36556682ac74e31030cfe2 # pin@v2 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - name: Set up Java - uses: actions/setup-java@v3 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' + - name: Checkout + 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 fossa_api_key: ${{ secrets.FOSSA_API_KEY }} diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 1bcf6896bd8..7f638963fc0 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -8,26 +8,23 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - - name: Cache Gradle packages - uses: actions/cache@v3 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Make format - run: make format + - name: Format with spotlessApply + run: ./gradlew spotlessApply # actions/checkout fetches only a single commit in a detached HEAD state. Therefore # we need to pass the current branch, otherwise we can't commit the changes. diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 65c42776765..ad33bb93e7a 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -9,28 +9,24 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ - uses: actions/checkout@v3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: 'recursive' + - name: set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - - name: Cache Gradle packages - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - name: Generate Aggregate Javadocs run: | ./gradlew aggregateJavadocs - name: Deploy - uses: JamesIves/github-pages-deploy-action@22a6ee251d6f13c6ab1ecb200d974f1a6feb1b8d # pin@4.4.2 + 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/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml deleted file mode 100644 index 8d24a9d8a38..00000000000 --- a/.github/workflows/gradle-wrapper-validation.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: 'Validate Gradle Wrapper' -on: - push: - branches: - - main - - release/** - pull_request: - -jobs: - validation: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: gradle/wrapper-validation-action@8d49e559aae34d3e0eb16cde532684bc9702762b # pin@v1 diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 44e004cba4d..66e4498dcb5 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -11,6 +11,10 @@ on: - '**/sentry-android-integration-tests/**' - '**/.github/**' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: name: Benchmarks @@ -19,29 +23,32 @@ jobs: # we copy the secret to the env variable in order to access it in the workflow env: SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: - name: Git checkout - uses: actions/checkout@v3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@v3 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: - java-version: '17' distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} # Clean, build and release a test apk, but only if we will run the benchmark - name: Make assembleBenchmarks if: env.SAUCE_USERNAME != null run: make assembleBenchmarks - # We stop gradle at the end to make sure the cache folders - # don't contain any lock files and are free to be cached. - - name: Make stop - run: make stop - - name: Run All Tests in SauceLab - uses: saucelabs/saucectl-run-action@f401339df4c4b84945783f16b45fd545ac52a2eb # 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 }} @@ -51,7 +58,7 @@ jobs: config-file: .sauce/sentry-uitest-android-benchmark.yml - name: Run one test in SauceLab - uses: saucelabs/saucectl-run-action@f401339df4c4b84945783f16b45fd545ac52a2eb # 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 }} @@ -66,18 +73,26 @@ jobs: # we copy the secret to the env variable in order to access it in the workflow env: SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: - name: Git checkout - uses: actions/checkout@v3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@v3 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: - java-version: '17' distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - uses: actions/cache@v3 + - 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 @@ -91,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 new file mode 100644 index 00000000000..dd99f8c6b7c --- /dev/null +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -0,0 +1,171 @@ +name: UI Tests Critical + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + BASE_PATH: "sentry-android-integration-tests/sentry-uitest-android-critical" + BUILD_PATH: "build/outputs/apk/release" + APK_NAME: "sentry-uitest-android-critical-release.apk" + APK_ARTIFACT_NAME: "sentry-uitest-android-critical-release" + MAESTRO_VERSION: "2.7.0" + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + env: + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Java 17 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: Build debug APK + run: make assembleUiTestCriticalRelease + + - name: Upload APK artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ${{env.APK_ARTIFACT_NAME}} + path: "${{env.BASE_PATH}}/${{env.BUILD_PATH}}/${{env.APK_NAME}}" + retention-days: 1 + + run-maestro-tests: + name: Run Tests for API Level ${{ matrix.api-level }} + needs: build + runs-on: ubuntu-latest + strategy: + # we want that the matrix keeps running, default is to cancel them if it fails. + fail-fast: false + matrix: + include: + - api-level: 31 # Android 12 + target: google_apis + channel: canary # Necessary for ATDs + arch: x86_64 + memory: 4096 + - api-level: 33 # Android 13 + target: google_apis + channel: canary # Necessary for ATDs + arch: x86_64 + memory: 4096 + - api-level: 35 # Android 15 + target: google_apis + channel: canary # Necessary for ATDs + arch: x86_64 + 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + # 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@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + channel: ${{ matrix.channel }} + arch: ${{ matrix.arch }} + force-avd-creation: false + disable-animations: true + disable-spellchecker: true + 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ${{env.APK_ARTIFACT_NAME}} + + - name: Install Maestro + uses: dniHze/maestro-test-action@bda8a93211c86d0a05b7a4597c5ad134566fbde4 # pin@v1.0.0 + with: + version: ${{env.MAESTRO_VERSION}} + + - name: Run tests + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2.38.0 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + channel: ${{ matrix.channel }} + arch: ${{ matrix.arch }} + force-avd-creation: false + disable-animations: true + disable-spellchecker: true + 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}}" + 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: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + 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 367758bdbd7..043c4730f32 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -3,18 +3,13 @@ on: push: branches: - main - - release/** pull_request: -jobs: - cancel-previous-workflow: - runs-on: ubuntu-latest - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@b173b6ec0100793626c2d9e6b90435061f4fc3e5 # pin@0.11.0 - with: - access_token: ${{ github.token }} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: test: name: Ui tests runs-on: ubuntu-latest @@ -22,34 +17,81 @@ jobs: # we copy the secret to the env variable in order to access it in the workflow env: SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: - name: Git checkout - uses: actions/checkout@v3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@v3 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: - java-version: '17' distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} # Clean, build and release a test apk, but only if we will run the benchmark - name: Make assembleUiTests if: env.SAUCE_USERNAME != null run: make assembleUiTests - # We stop gradle at the end to make sure the cache folders - # don't contain any lock files and are free to be cached. - - name: Make stop - run: make stop - - - name: Run Tests in SauceLab - uses: saucelabs/saucectl-run-action@f401339df4c4b84945783f16b45fd545ac52a2eb # pin@v3 + - name: Install SauceLabs CLI + uses: saucelabs/saucectl-run-action@283660aa934c02723c497efa151d582a3acc5801 # pin@v4.5.0 env: GITHUB_TOKEN: ${{ github.token }} with: - sauce-username: ${{ secrets.SAUCE_USERNAME }} - sauce-access-key: ${{ secrets.SAUCE_ACCESS_KEY }} - config-file: .sauce/sentry-uitest-android-ui.yml + skip-run: true + if: env.SAUCE_USERNAME != null + + - name: Run Tests + id: saucelabs + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + exec &> >(tee -a "test_logs.txt") + saucectl run -c .sauce/sentry-uitest-android-ui.yml if: env.SAUCE_USERNAME != null + continue-on-error: true + - name: Verify Test Results + run: | + processCrashed=$(cat test_logs.txt | grep "Instrumentation run failed due to 'Process crashed.'" | wc -l) + if [[ ${{ steps.saucelabs.outcome }} == 'success' ]]; then + exit 0 + elif [[ "$processCrashed" -ne 0 ]]; then + exit 0 + else + exit 1 + fi + if: env.SAUCE_USERNAME != null + + + - 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 new file mode 100644 index 00000000000..a1f47577c5f --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,41 @@ +name: 'Build Release Artifacts' +on: + push: + branches: + - release/** + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + release: + name: Build release artifacts + runs-on: ubuntu-latest + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: 'recursive' + + - name: Setup Java Version + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + + - name: Build artifacts + run: make publish + + - name: Upload artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ${{ github.sha }} + if-no-files-found: error + path: | + ./*/build/distributions/*.zip + ./sentry-opentelemetry/*/build/distributions/*.zip 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 90cd720faf5..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,19 +12,31 @@ 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 name: "Release a new version" steps: - - uses: actions/checkout@v3 + - name: Get auth token + id: token + 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - token: ${{ secrets.GH_RELEASE_PAT }} + 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: ${{ secrets.GH_RELEASE_PAT }} + GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: version: ${{ github.event.inputs.version }} force: ${{ github.event.inputs.force }} diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml new file mode 100644 index 00000000000..66847c8c792 --- /dev/null +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -0,0 +1,152 @@ +name: Spring Boot 2.x Matrix + +on: + push: + branches: + - main + pull_request: + paths-ignore: + - '*android*/**' + - 'sentry-compose/**' + - 'sentry-samples/sentry-samples-android/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + spring-boot-2-matrix: + timeout-minutes: 45 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + springboot-version: [ '2.4.13', '2.5.15', '2.6.15', '2.7.0', '2.7.18' ] + + name: Spring Boot ${{ matrix.springboot-version }} + env: + SENTRY_URL: http://127.0.0.1:8000 + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: 'recursive' + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.10.5' + + - name: Install Python dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt + + - name: Set up Java + 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: Update Spring Boot 2.x version + run: | + 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: | + ./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" + + - 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" + + - 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" + + - 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" + + - 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" + + - name: Test sentry-samples-spring + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring" \ + --agent false \ + --auto-init "true" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: test-results-springboot-2-${{ matrix.springboot-version }} + path: | + **/build/reports/* + **/build/test-results/**/*.xml + sentry-mock-server.txt + spring-server.txt + + - name: Test Report + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 + if: always() + with: + name: JUnit Spring Boot 2.x ${{ matrix.springboot-version }} + path: | + **/build/test-results/**/*.xml + reporter: java-junit + output-to: step-summary + fail-on-error: false diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml new file mode 100644 index 00000000000..3ccfba65c4c --- /dev/null +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -0,0 +1,148 @@ +name: Spring Boot 3.x Matrix + +on: + push: + branches: + - main + pull_request: + paths-ignore: + - '*android*/**' + - 'sentry-compose/**' + - 'sentry-samples/sentry-samples-android/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + spring-boot-3-matrix: + timeout-minutes: 45 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + springboot-version: [ '3.2.12', '3.3.13', '3.4.13', '3.5.13' ] + + name: Spring Boot ${{ matrix.springboot-version }} + env: + SENTRY_URL: http://127.0.0.1:8000 + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: 'recursive' + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.10.5' + + - name: Install Python dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt + + - name: Set up Java + 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: Update Spring Boot 3.x version + run: | + 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: Build sample artifacts + run: | + ./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" + + - 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" + + - 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" + + - 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" + + - 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" + + - name: Test sentry-samples-spring-jakarta + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-jakarta" \ + --agent false \ + --auto-init "true" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: test-results-springboot-3-${{ matrix.springboot-version }} + path: | + **/build/reports/* + **/build/test-results/**/*.xml + sentry-mock-server.txt + spring-server.txt + + - name: Test Report + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 + if: always() + with: + name: JUnit Spring Boot 3.x ${{ matrix.springboot-version }} + path: | + **/build/test-results/**/*.xml + reporter: java-junit + output-to: step-summary + fail-on-error: false diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml new file mode 100644 index 00000000000..f75f31e38ef --- /dev/null +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -0,0 +1,148 @@ +name: Spring Boot 4.x Matrix + +on: + push: + branches: + - main + pull_request: + paths-ignore: + - '*android*/**' + - 'sentry-compose/**' + - 'sentry-samples/sentry-samples-android/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + spring-boot-4-matrix: + timeout-minutes: 45 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + springboot-version: [ '4.0.0', '4.0.5', '4.1.0' ] + + name: Spring Boot ${{ matrix.springboot-version }} + env: + SENTRY_URL: http://127.0.0.1:8000 + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: 'recursive' + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.10.5' + + - name: Install Python dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt + + - name: Set up Java + 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: Update Spring Boot 4.x version + run: | + 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: Build sample artifacts + run: | + ./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" + + - 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" + + - 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" + + - 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" + + - 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" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: test-results-springboot-4-${{ matrix.springboot-version }} + path: | + **/build/reports/* + **/build/test-results/**/*.xml + sentry-mock-server.txt + spring-server.txt + + - name: Test Report + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 + if: always() + with: + name: JUnit Spring Boot 4.x ${{ matrix.springboot-version }} + path: | + **/build/test-results/**/*.xml + reporter: java-junit + output-to: step-summary + fail-on-error: false diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml new file mode 100644 index 00000000000..12d84c0ef99 --- /dev/null +++ b/.github/workflows/system-tests-backend.yml @@ -0,0 +1,148 @@ +name: 'System Tests Backend' + +on: + push: + branches: + - main + pull_request: + paths-ignore: + - '*android*/**' + - 'sentry-compose/**' + - 'sentry-samples/sentry-samples-android/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + system-test: + runs-on: ubuntu-latest + continue-on-error: true + env: + SENTRY_URL: http://127.0.0.1:8000 + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + strategy: + fail-fast: false + matrix: + sample: [ "sentry-samples-spring-boot-jakarta" ] + agent: [ "false" ] + agent-auto-init: [ "true" ] + include: + - sample: "sentry-samples-spring-boot" + agent: "false" + agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-opentelemetry-noagent" + agent: "false" + agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-opentelemetry" + agent: "true" + agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-opentelemetry" + agent: "true" + agent-auto-init: "false" + - sample: "sentry-samples-spring-boot-webflux-jakarta" + agent: "false" + agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-webflux" + agent: "false" + agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-jakarta-opentelemetry-noagent" + agent: "false" + agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-jakarta-opentelemetry" + agent: "true" + agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-jakarta-opentelemetry" + agent: "true" + agent-auto-init: "false" + - 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" + - sample: "sentry-samples-log4j2" + agent: "false" + agent-auto-init: "true" + - sample: "sentry-samples-jul" + agent: "false" + agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-4" + agent: "false" + agent-auto-init: "true" + - 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" + 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" + - sample: "sentry-samples-spring-jakarta" + agent: "false" + agent-auto-init: "true" + - sample: "sentry-samples-spring" + agent: "false" + agent-auto-init: "true" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: 'recursive' + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.10.5' + + - name: Install Python dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt + + - name: Set up Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: test-results-${{ matrix.sample }}-${{ matrix.agent }}-${{ matrix.agent-auto-init }}-system-test + path: | + **/build/reports/* + sentry-mock-server.txt + spring-server.txt + + - name: Test Report + uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 + if: always() + with: + name: JUnit System Tests ${{ matrix.sample }} + path: | + **/build/test-results/**/*.xml + reporter: java-junit + output-to: step-summary + fail-on-error: false diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml index 24fce64050f..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: sentry-android-ndk/sentry-native - 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 92ea301ff84..f252087a5ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .DS_Store +.java-version .idea/ .gradle/ +.run/ build/ artifacts/ out/ @@ -11,6 +13,7 @@ local.properties **/sentry-native-local target/ .classpath +.factorypath .project .settings/ bin/ @@ -19,4 +22,21 @@ distributions/ *.vscode/ sentry-spring-boot-starter-jakarta/src/main/resources/META-INF/spring.factories sentry-samples/sentry-samples-spring-boot-jakarta/spy.log +sentry-mock-server.txt +tomcat-server.txt +spring-server.txt +*.pid 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/.gitmodules b/.gitmodules index fe6c3b7cc09..e69de29bb2d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "sentry-android-ndk/sentry-native"] - path = sentry-android-ndk/sentry-native - url = https://github.com/getsentry/sentry-native 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-benchmark-lite.yml b/.sauce/sentry-uitest-android-benchmark-lite.yml index b9408053ccb..fec4a141def 100644 --- a/.sauce/sentry-uitest-android-benchmark-lite.yml +++ b/.sauce/sentry-uitest-android-benchmark-lite.yml @@ -18,12 +18,13 @@ espresso: suites: - - name: "Android 11 (api 30)" + - name: "Android 15 Benchmark lite (api 35)" testOptions: clearPackageData: true useTestOrchestrator: true devices: - - id: Google_Pixel_3a_real # Google Pixel 3a - api 30 (11) + - name: ".*" + platformVersion: "15" artifacts: download: diff --git a/.sauce/sentry-uitest-android-benchmark.yml b/.sauce/sentry-uitest-android-benchmark.yml index da0873ba680..12995ea5e07 100644 --- a/.sauce/sentry-uitest-android-benchmark.yml +++ b/.sauce/sentry-uitest-android-benchmark.yml @@ -19,49 +19,34 @@ espresso: suites: # Devices are chosen so that there is a high-end and a low-end device for each api level - - name: "Android 12 (api 31)" + - name: "Android 15 (api 35)" testOptions: clearPackageData: true useTestOrchestrator: true devices: - - id: Google_Pixel_6_Pro_real_us # Google Pixel 6 Pro - api 31 (12) - high end - - id: Google_Pixel_5_12_real_us # Google Pixel 5 - api 31 (12) - low end + - id: Google_Pixel_9_Pro_XL_15_real_sjc1 # Google Pixel 9 Pro XL - api 35 (15) - high end + - id: Samsung_Galaxy_S23_15_real_sjc1 # Samsung Galaxy S23 - api 35 (15) - mid end + - id: Google_Pixel_6a_15_real_sjc1 # Google Pixel 6a - api 35 (15) - low end - - name: "Android 11 (api 30)" + - name: "Android 14 (api 34)" testOptions: clearPackageData: true useTestOrchestrator: true devices: - - id: Samsung_Galaxy_S10_Plus_11_real_us # Samsung Galaxy S10+ - api 30 (11) - high end - - id: Samsung_Galaxy_A71_5G_real_us # Samsung Galaxy A71 5G - api 30 (11) - mid end - - id: Google_Pixel_3a_real # Google Pixel 3a - api 30 (11) - low end + - id: Google_Pixel_9_Pro_XL_real_sjc1 # Google Pixel 9 Pro XL - api 34 (14) - high end + - id: Samsung_Galaxy_A54_real_sjc1 # Samsung Galaxy A54 - api 34 (14) - low end - - name: "Android 10 (api 29)" + - name: "Android 13 (api 33)" testOptions: clearPackageData: true useTestOrchestrator: true devices: - - id: Google_Pixel_3a_XL_real # Google Pixel 3a XL - api 29 (10) - - id: Nokia_7_1_real_us # Nokia 7.1 - api 29 (10) + - id: Google_Pixel_7_Pro_real_us # Google Pixel 7 Pro - api 33 (13) - high end + - id: Samsung_Galaxy_A32_5G_real_sjc1 # Samsung Galaxy A32 5G - api 33 (13) - low end -# At the time of writing (July, 4, 2022), the market share per android version is: -# 12.0 = 17.54%, 11.0 = 31.65%, 10.0 = 21.92% -# Using these 3 versions we cover 71,11% of all devices out there. Currently, this is enough for benchmarking scope -# Leaving these devices here in case we change mind on them -# devices: -# - id: Samsung_Galaxy_S8_plus_real_us # Samsung Galaxy S8+ - api 28 (9) -# - id: LG_G8_ThinQ_real_us # LG G8 ThinQ - api 28 (9) -# - id: OnePlus_5_real_us # OnePlus 5 - api 27 (8.1.0) -# - id: LG_K30_real_us1 # LG K30 - api 27 (8.1.0) -# - id: HTC_10_real_us # HTC 10 - api 26 (8.0.0) -# - id: Samsung_A3_real # Samsung Galaxy A3 2017 - api 26 (8.0.0) -# - id: ZTE_Axon_7_real2_us # ZTE Axon 7 - api 25 (7.1.1) -# - id: Motorola_Moto_X_Play_real # Motorola Moto X Play - api 25 (7.1.1) -# - id: Samsung_note_5_real_us # Samsung Galaxy Note 5 - api 24 (7.0) -# - id: LG_K10_real # LG K10 - api 24 (7.0) -# - id: Samsung_Galaxy_S6_Edge_Plus_real # Samsung Galaxy S6 Edge+ - api 23 (6.0.1) -# - id: Samsung_Tab_E_real_us # Samsung Tab E - api 23 (6.0.1) -# - id: Amazon_Kindle_Fire_HD_8_real_us # Amazon Kindle Fire HD 8 - api 22 (5.1.1) +# At the time of writing (August, 13, 2025), the market share per android version is: +# 15.0 = 26.75%, 14.0 = 19.5%, 13 = 15.95% +# Using these 3 versions we cover 62.2% of all devices out there. Currently, this is enough for benchmarking scope artifacts: download: diff --git a/.sauce/sentry-uitest-android-ui.yml b/.sauce/sentry-uitest-android-ui.yml index e806167faff..a00ee10614b 100644 --- a/.sauce/sentry-uitest-android-ui.yml +++ b/.sauce/sentry-uitest-android-ui.yml @@ -11,40 +11,20 @@ sauce: - android defaults: - timeout: 40m + timeout: 45m espresso: app: ./sentry-android-integration-tests/sentry-uitest-android/build/outputs/apk/release/sentry-uitest-android-release.apk testApp: ./sentry-android-integration-tests/sentry-uitest-android/build/outputs/apk/androidTest/release/sentry-uitest-android-release-androidTest.apk suites: - - name: "Android 13 Ui test (api 33)" + - name: "Android 15 Ui test (api 35)" testOptions: clearPackageData: true useTestOrchestrator: true devices: - - id: Google_Pixel_5_13_real_us # Google Pixel 5 - api 33 (13) - - - name: "Android 12 Ui test (api 31)" - testOptions: - clearPackageData: true - useTestOrchestrator: true - devices: - - id: Samsung_Galaxy_S22_Ultra_5G_real_us # Samsung Galaxy S22 Ultra 5G - api 31 (12) - - - name: "Android 11 Ui test (api 30)" - testOptions: - clearPackageData: true - useTestOrchestrator: true - devices: - - id: Samsung_Galaxy_S10_Plus_11_real_us # Samsung Galaxy S10+ - api 30 (11) - - - name: "Android 10 Ui test (api 29)" - testOptions: - clearPackageData: true - useTestOrchestrator: true - devices: - - id: OnePlus_7T_real_us # OnePlus 7T - api 29 (10) + - name: ".*" + platformVersion: "15" # Controls what artifacts to fetch when the suite on Sauce Cloud has finished. artifacts: @@ -52,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 3933632f7b9..0a45ec3fe37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,3165 @@ # Changelog +## 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 + +- Mark SentryClient(SentryOptions) constructor as not internal ([#4787](https://github.com/getsentry/sentry-java/pull/4787)) + +### Dependencies + +- Bump Native SDK from v0.10.1 to v0.11.2 ([#4775](https://github.com/getsentry/sentry-java/pull/4775)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0112) + - [diff](https://github.com/getsentry/sentry-native/compare/0.10.1...0.11.2) + +## 8.23.0 + +### Features + +- Add session replay id to Sentry Logs ([#4740](https://github.com/getsentry/sentry-java/pull/4740)) +- Add support for continuous profiling of JVM applications on macOS and Linux ([#4556](https://github.com/getsentry/sentry-java/pull/4556)) + - [Sentry continuous profiling](https://docs.sentry.io/product/explore/profiling/) on the JVM is using async-profiler under the hood. + - By default this feature is disabled. Set a profile sample rate and chose a lifecycle (see below) to enable it. + - Add the `sentry-async-profiler` dependency to your project + - Set a sample rate for profiles, e.g. `1.0` to send all of them. You may use `options.setProfileSessionSampleRate(1.0)` in code or `profile-session-sample-rate=1.0` in `sentry.properties` + - Set a profile lifecycle via `options.setProfileLifecycle(ProfileLifecycle.TRACE)` in code or `profile-lifecycle=TRACE` in `sentry.properties` + - By default the lifecycle is set to `MANUAL`, meaning you have to explicitly call `Sentry.startProfiler()` and `Sentry.stopProfiler()` + - You may change it to `TRACE` which will create a profile for each transaction + - To automatically upload Profiles for each transaction in a Spring Boot application + - set `sentry.profile-session-sample-rate=1.0` and `sentry.profile-lifecycle=TRACE` in `application.properties` + - or set `sentry.profile-session-sample-rate: 1.0` and `sentry.profile-lifecycle: TRACE` in `application.yml` + - Profiling can also be combined with our OpenTelemetry integration + +### Fixes + +- Start performance collection on AppStart continuous profiling ([#4752](https://github.com/getsentry/sentry-java/pull/4752)) +- Preserve modifiers in `SentryTraced` ([#4757](https://github.com/getsentry/sentry-java/pull/4757)) + +### Improvements + +- Handle `RejectedExecutionException` everywhere ([#4747](https://github.com/getsentry/sentry-java/pull/4747)) +- Mark `SentryEnvelope` as not internal ([#4748](https://github.com/getsentry/sentry-java/pull/4748)) + +## 8.22.0 + +### Features + +- Move SentryLogs out of experimental ([#4710](https://github.com/getsentry/sentry-java/pull/4710)) +- Add support for w3c traceparent header ([#4671](https://github.com/getsentry/sentry-java/pull/4671)) + - This feature is disabled by default. If enabled, outgoing requests will include the w3c `traceparent` header. + - See https://develop.sentry.dev/sdk/telemetry/traces/distributed-tracing/#w3c-trace-context-header for more details. + ```kotlin + Sentry(Android).init(context) { options -> + // ... + options.isPropagateTraceparent = true + } + ``` +- Sentry now supports Spring Boot 4 M3 pre-release ([#4739](https://github.com/getsentry/sentry-java/pull/4739)) + +### Improvements + +- Remove internal API status from get/setDistinctId ([#4708](https://github.com/getsentry/sentry-java/pull/4708)) +- Remove ApiStatus.Experimental annotation from check-in API ([#4721](https://github.com/getsentry/sentry-java/pull/4721)) + +### Fixes + +- Session Replay: Fix `NoSuchElementException` in `BufferCaptureStrategy` ([#4717](https://github.com/getsentry/sentry-java/pull/4717)) +- Session Replay: Fix continue recording in Session mode after Buffer is triggered ([#4719](https://github.com/getsentry/sentry-java/pull/4719)) + +### Dependencies + +- Bump Native SDK from v0.10.0 to v0.10.1 ([#4695](https://github.com/getsentry/sentry-java/pull/4695)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0101) + - [diff](https://github.com/getsentry/sentry-native/compare/0.10.0...0.10.1) + +## 8.21.1 + +### Fixes + +- Use Kotlin stdlib 1.9.24 dependency instead of 2.2.0 for all Android modules ([#4707](https://github.com/getsentry/sentry-java/pull/4707)) + - This fixes compile time issues if your app is using Kotlin < 2.x + +## 8.21.0 + +### Fixes + +- Only set log template for logging integrations if formatted message differs from template ([#4682](https://github.com/getsentry/sentry-java/pull/4682)) + +### Features + +- Add support for Spring Boot 4 and Spring 7 ([#4601](https://github.com/getsentry/sentry-java/pull/4601)) + - NOTE: Our `sentry-opentelemetry-agentless-spring` is not working yet for Spring Boot 4. Please use `sentry-opentelemetry-agent` until OpenTelemetry has support for Spring Boot 4. +- Replace `UUIDGenerator` implementation with Apache licensed code ([#4662](https://github.com/getsentry/sentry-java/pull/4662)) +- Replace `Random` implementation with MIT licensed code ([#4664](https://github.com/getsentry/sentry-java/pull/4664)) +- Add support for `vars` attribute in `SentryStackFrame` ([#4686](https://github.com/getsentry/sentry-java/pull/4686)) + - **Breaking change**: The type of the `vars` attribute has been changed from `Map` to `Map`. + +## 8.20.0 + +### Fixes + +- Do not use named capturing groups for regular expressions ([#4652](https://github.com/getsentry/sentry-java/pull/4652)) + - This fixes a crash on Android versions below 8.0 (API level 26) + +### Features + +- 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 + }); + }); + ``` + +## 8.19.1 + +> [!Warning] +> Android: This release is incompatible with API levels below 26. We recommend using SDK version 8.20.0 or higher instead. + +### Fixes + +- Do not store No-Op scopes onto OpenTelemetry Context when wrapping ([#4631](https://github.com/getsentry/sentry-java/pull/4631)) + - In 8.18.0 and 8.19.0 the SDK could break when initialized too late. + +## 8.19.0 + +> [!Warning] +> Android: This release is incompatible with API levels below 26. We recommend using SDK version 8.20.0 or higher instead. + +### Features + +- Add a `isEnableSystemEventBreadcrumbsExtras` option to disable reporting system events extras for breadcrumbs ([#4625](https://github.com/getsentry/sentry-java/pull/4625)) + +### Improvements + +- Session Replay: Use main thread looper to schedule replay capture ([#4542](https://github.com/getsentry/sentry-java/pull/4542)) +- Use single `LifecycleObserver` and multi-cast it to the integrations interested in lifecycle states ([#4567](https://github.com/getsentry/sentry-java/pull/4567)) +- Add `sentry.origin` attribute to logs ([#4618](https://github.com/getsentry/sentry-java/pull/4618)) + - This helps identify which integration captured a log event +- Prewarm `SentryExecutorService` for better performance at runtime ([#4606](https://github.com/getsentry/sentry-java/pull/4606)) + +### Fixes + +- Cache network capabilities and status to reduce IPC calls ([#4560](https://github.com/getsentry/sentry-java/pull/4560)) +- Deduplicate battery breadcrumbs ([#4561](https://github.com/getsentry/sentry-java/pull/4561)) +- Remove unused method in ManifestMetadataReader ([#4585](https://github.com/getsentry/sentry-java/pull/4585)) +- Have single `NetworkCallback` registered at a time to reduce IPC calls ([#4562](https://github.com/getsentry/sentry-java/pull/4562)) +- Do not register for SystemEvents and NetworkCallbacks immediately when launched with non-foreground importance ([#4579](https://github.com/getsentry/sentry-java/pull/4579)) +- Limit ProGuard keep rules for native methods within `sentry-android-ndk` to the `io.sentry.**` namespace. ([#4427](https://github.com/getsentry/sentry-java/pull/4427)) + - If you relied on the Sentry SDK to keep native method names for JNI compatibility within your namespace, please review your ProGuard rules and ensure the configuration still works. Especially when you're not consuming any of the default Android proguard rules (`proguard-android.txt` or `proguard-android-optimize.txt`) the following config should be present: + ``` + -keepclasseswithmembernames class * { + native ; + } + ``` +- Fix abstract method error in `SentrySupportSQLiteDatabase` ([#4597](https://github.com/getsentry/sentry-java/pull/4597)) +- Ensure frame metrics listeners are registered/unregistered on the main thread ([#4582](https://github.com/getsentry/sentry-java/pull/4582)) +- Do not report cached events as lost ([#4575](https://github.com/getsentry/sentry-java/pull/4575)) + - Previously events were recorded as lost early despite being retried later through the cache +- 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 + +### Dependencies + +- Bump Native SDK from v0.8.4 to v0.10.0 ([#4623](https://github.com/getsentry/sentry-java/pull/4623)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0100) + - [diff](https://github.com/getsentry/sentry-native/compare/0.8.4...0.10.0) + +## 8.18.0 + +### Features + +- Add `SentryUserFeedbackButton` Composable ([#4559](https://github.com/getsentry/sentry-java/pull/4559)) + - Also added `Sentry.showUserFeedbackDialog` static method +- Add deadlineTimeout option ([#4555](https://github.com/getsentry/sentry-java/pull/4555)) +- Add Ktor client integration ([#4527](https://github.com/getsentry/sentry-java/pull/4527)) + - To use the integration, add a dependency on `io.sentry:sentry-ktor-client`, then install the `SentryKtorClientPlugin` on your `HttpClient`, + e.g.: + ```kotlin + val client = + HttpClient(Java) { + install(io.sentry.ktorClient.SentryKtorClientPlugin) { + captureFailedRequests = true + failedRequestTargets = listOf(".*") + failedRequestStatusCodes = listOf(HttpStatusCodeRange(500, 599)) + } + } + ``` + +### Fixes + +- Allow multiple UncaughtExceptionHandlerIntegrations to be active at the same time ([#4462](https://github.com/getsentry/sentry-java/pull/4462)) +- Prevent repeated scroll target determination during a single scroll gesture ([#4557](https://github.com/getsentry/sentry-java/pull/4557)) + - This should reduce the number of ANRs seen in `SentryGestureListener` +- Do not use Sentry logging API in JUL if logs are disabled ([#4574](https://github.com/getsentry/sentry-java/pull/4574)) + - This was causing Sentry SDK to log warnings: "Sentry Log is disabled and this 'logger' call is a no-op." +- Do not use Sentry logging API in Log4j2 if logs are disabled ([#4573](https://github.com/getsentry/sentry-java/pull/4573)) + - This was causing Sentry SDK to log warnings: "Sentry Log is disabled and this 'logger' call is a no-op." +- SDKs send queue is no longer shutdown immediately on re-init ([#4564](https://github.com/getsentry/sentry-java/pull/4564)) + - This means we're no longer losing events that have been enqueued right before SDK re-init. +- Reduce scope forking when using OpenTelemetry ([#4565](https://github.com/getsentry/sentry-java/pull/4565)) + - `Sentry.withScope` now has the correct current scope passed to the callback. Previously our OpenTelemetry integration forked scopes an additional. + - Overall the SDK is now forking scopes a bit less often. + +## 8.17.0 + +### Features + +- Send Timber logs through Sentry Logs ([#4490](https://github.com/getsentry/sentry-java/pull/4490)) + - Enable the Logs feature in your `SentryOptions` or with the `io.sentry.logs.enabled` manifest option and the SDK will automatically send Timber logs to Sentry, if the TimberIntegration is enabled. + - The SDK will automatically detect Timber and use it to send logs to Sentry. +- Send logcat through Sentry Logs ([#4487](https://github.com/getsentry/sentry-java/pull/4487)) + - Enable the Logs feature in your `SentryOptions` or with the `io.sentry.logs.enabled` manifest option and the SDK will automatically send logcat logs to Sentry, if the Sentry Android Gradle plugin is applied. + - To set the logcat level check the [Logcat integration documentation](https://docs.sentry.io/platforms/android/integrations/logcat/#configure). +- Read build tool info from `sentry-debug-meta.properties` and attach it to events ([#4314](https://github.com/getsentry/sentry-java/pull/4314)) + +### Dependencies + +- Bump OpenTelemetry ([#4532](https://github.com/getsentry/sentry-java/pull/4532)) + - `opentelemetry-sdk` to `1.51.0` + - `opentelemetry-instrumentation` to `2.17.0` + - `opentelemetry-javaagent` to `2.17.0` + - `opentelemetry-semconv` to `1.34.0` + - We are now configuring OpenTelemetry to still behave the same way it did before for span names it generates in GraphQL auto instrumentation ([#4537](https://github.com/getsentry/sentry-java/pull/4537)) +- Bump Gradle from v8.14.2 to v8.14.3 ([#4540](https://github.com/getsentry/sentry-java/pull/4540)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v8143) + - [diff](https://github.com/gradle/gradle/compare/v8.14.2...v8.14.3) + +### Fixes + +- Use Spring Boot Starter 3 in `sentry-spring-boot-starter-jakarta` ([#4545](https://github.com/getsentry/sentry-java/pull/4545)) + - While refactoring our dependency management, we accidentally added Spring Boot 2 and Spring Boot Starter 2 as dependencies of `sentry-spring-boot-starter-jakarta`, which is intended for Spring Boot 3. + - Now, the correct dependencies (Spring Boot 3 and Spring Boot Starter 3) are being added. + +## 8.16.1-alpha.2 + +### Fixes + +- Optimize scope when maxBreadcrumb is 0 ([#4504](https://github.com/getsentry/sentry-java/pull/4504)) +- Fix javadoc on TransportResult ([#4528](https://github.com/getsentry/sentry-java/pull/4528)) +- Session Replay: Fix `IllegalArgumentException` when `Bitmap` is initialized with non-positive values ([#4536](https://github.com/getsentry/sentry-java/pull/4536)) +- Set thread information on transaction from OpenTelemetry attributes ([#4478](https://github.com/getsentry/sentry-java/pull/4478)) + +### Internal + +- Flattened PerformanceCollectionData ([#4505](https://github.com/getsentry/sentry-java/pull/4505)) + +## 8.16.0 + +### Features + +- Send JUL logs to Sentry as logs ([#4518](https://github.com/getsentry/sentry-java/pull/4518)) + - You need to enable the logs feature, either in `sentry.properties`: + ```properties + logs.enabled=true + ``` + - Or, if you manually initialize Sentry, you may also enable logs on `Sentry.init`: + ```java + Sentry.init(options -> { + ... + options.getLogs().setEnabled(true); + }); + ``` + - It is also possible to set the `minimumLevel` in `logging.properties`, meaning any log message >= the configured level will be sent to Sentry and show up under Logs: + ```properties + io.sentry.jul.SentryHandler.minimumLevel=CONFIG + ``` +- Send Log4j2 logs to Sentry as logs ([#4517](https://github.com/getsentry/sentry-java/pull/4517)) + - You need to enable the logs feature either in `sentry.properties`: + ```properties + logs.enabled=true + ``` + - If you manually initialize Sentry, you may also enable logs on `Sentry.init`: + ```java + Sentry.init(options -> { + ... + options.getLogs().setEnabled(true); + }); + ``` + - It is also possible to set the `minimumLevel` in `log4j2.xml`, meaning any log message >= the configured level will be sent to Sentry and show up under Logs: + ```xml + + ``` + +## 8.15.1 + +### Fixes + +- Enabling Sentry Logs through Logback in Spring Boot config did not work in 3.15.0 ([#4523](https://github.com/getsentry/sentry-java/pull/4523)) + +## 8.15.0 + +### Features + +- Add chipset to device context ([#4512](https://github.com/getsentry/sentry-java/pull/4512)) + +### Fixes + +- No longer send out empty log envelopes ([#4497](https://github.com/getsentry/sentry-java/pull/4497)) +- Session Replay: Expand fix for crash on devices to all Unisoc/Spreadtrum chipsets ([#4510](https://github.com/getsentry/sentry-java/pull/4510)) +- Log parameter objects are now turned into `String` via `toString` ([#4515](https://github.com/getsentry/sentry-java/pull/4515)) + - One of the two `SentryLogEventAttributeValue` constructors did not convert the value previously. +- Logs are now flushed on shutdown ([#4503](https://github.com/getsentry/sentry-java/pull/4503)) +- User Feedback: Do not redefine system attributes for `SentryUserFeedbackButton`, but reference them instead ([#4519](https://github.com/getsentry/sentry-java/pull/4519)) + +### Features + +- Send Logback logs to Sentry as logs ([#4502](https://github.com/getsentry/sentry-java/pull/4502)) + - You need to enable the logs feature and can also set the `minimumLevel` for log events: + ```xml + + + + https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563 + + true + + + + + WARN + + DEBUG + + INFO + + ``` + - For Spring Boot you may also enable it in `application.properties` / `application.yml`: + ```properties + sentry.logs.enabled=true + sentry.logging.minimum-level=error + ``` + - If you manually initialize Sentry, you may also enable logs on `Sentry.init`: + ```java + Sentry.init(options -> { + ... + options.getLogs().setEnabled(true); + }); + ``` + - Enabling via `sentry.properties` is also possible: + ```properties + logs.enabled=true + ``` +- Automatically use `SentryOptions.Logs.BeforeSendLogCallback` Spring beans ([#4509](https://github.com/getsentry/sentry-java/pull/4509)) + +### Dependencies + +- Bump Gradle from v8.14.1 to v8.14.2 ([#4473](https://github.com/getsentry/sentry-java/pull/4473)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v8142) + - [diff](https://github.com/gradle/gradle/compare/v8.14.1...v8.14.2) + +## 8.14.0 + +### Fixes + +- Fix Session Replay masking for newer versions of Jetpack Compose (1.8+) ([#4485](https://github.com/getsentry/sentry-java/pull/4485)) + +### 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 +- 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() + ``` + +- 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)) +- Serialize `preContext` and `postContext` in `SentryStackFrame` ([#4482](https://github.com/getsentry/sentry-java/pull/4482)) + +### Internal + +- User Feedback now uses SentryUser.username instead of SentryUser.name ([#4494](https://github.com/getsentry/sentry-java/pull/4494)) + +## 8.13.3 + +### Fixes + +- Send UI Profiling app start chunk when it finishes ([#4423](https://github.com/getsentry/sentry-java/pull/4423)) +- Republish Javadoc [#4457](https://github.com/getsentry/sentry-java/pull/4457) +- Finalize `OkHttpEvent` even if no active span in `SentryOkHttpInterceptor` [#4469](https://github.com/getsentry/sentry-java/pull/4469) +- Session Replay: Do not capture current replay for cached events from the past ([#4474](https://github.com/getsentry/sentry-java/pull/4474)) +- Session Replay: Correctly capture Dialogs and non full-sized windows ([#4354](https://github.com/getsentry/sentry-java/pull/4354)) +- Session Replay: Fix inconsistent `segment_id` ([#4471](https://github.com/getsentry/sentry-java/pull/4471)) +- Session Replay: Fix crash on devices with the Unisoc/Spreadtrum T606 chipset ([#4477](https://github.com/getsentry/sentry-java/pull/4477)) + +## 8.13.2 + +### Fixes + +- Don't apply Spring Boot plugin in `sentry-spring-boot-jakarta` ([#4456](https://github.com/getsentry/sentry-java/pull/4456)) + - The jar for `io.sentry:sentry-spring-boot-jakarta` is now correctly being built and published to Maven Central. + +## 8.13.1 + +### Fixes + +- Fix `SentryAndroid.init` crash if SDK is initialized from a background thread while an `Activity` is in resumed state ([#4449](https://github.com/getsentry/sentry-java/pull/4449)) + +### Dependencies + +- Bump Gradle from v8.14 to v8.14.1 ([#4437](https://github.com/getsentry/sentry-java/pull/4437)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v8141) + - [diff](https://github.com/gradle/gradle/compare/v8.14...v8.14.1) + +## 8.13.0 + +### 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). +- 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 + - Attribute values may be of type `string`, `boolean`, `integer` or `double`. + - Other types will be converted to `string`. Currently we simply call `toString()` but we might offer more in the future. + - You may manually flatten complex types into multiple separate attributes of simple types. + - e.g. intead of `SentryAttribute.named("point", Point(10, 20))` you may store it as `SentryAttribute.integerAttribute("point.x", point.x)` and `SentryAttribute.integerAttribute("point.y", point.y)` + - `SentryAttribute.named()` will automatically infer the type or fall back to `string`. + - `SentryAttribute.booleanAttribute()` takes a `Boolean` value + - `SentryAttribute.integerAttribute()` takes a `Integer` value + - `SentryAttribute.doubleAttribute()` takes a `Double` value + - `SentryAttribute.stringAttribute()` takes a `String` value + - We opted for handling parameters via `SentryLogParameters` to avoid creating tons of overloads that are ambiguous. + +### Fixes + +- Isolation scope is now forked in `OtelSentrySpanProcessor` instead of `OtelSentryPropagator` ([#4434](https://github.com/getsentry/sentry-java/pull/4434)) + - Since propagator may never be invoked we moved the location where isolation scope is forked. + - Not invoking `OtelSentryPropagator.extract` or having a `sentry-trace` header that failed to parse would cause isolation scope not to be forked. + - This in turn caused data to bleed between scopes, e.g. from one request into another + +### Dependencies + +- Bump Spring Boot to `3.5.0` ([#4111](https://github.com/getsentry/sentry-java/pull/4111)) + +## 8.12.0 + +### Features + +- Add new User Feedback API ([#4286](https://github.com/getsentry/sentry-java/pull/4286)) + - 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 + +### Fixes + +- Hook User Interaction integration into running Activity in case of deferred SDK init ([#4337](https://github.com/getsentry/sentry-java/pull/4337)) + +### Dependencies + +- Bump Gradle from v8.13 to v8.14.0 ([#4360](https://github.com/getsentry/sentry-java/pull/4360)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v8140) + - [diff](https://github.com/gradle/gradle/compare/v8.13...v8.14.0) + +## 8.11.1 + +### Fixes + +- Fix Android profile chunk envelope type for UI Profiling ([#4366](https://github.com/getsentry/sentry-java/pull/4366)) + +## 8.11.0 + +### Features + +- Make `RequestDetailsResolver` public ([#4326](https://github.com/getsentry/sentry-java/pull/4326)) + - `RequestDetailsResolver` is now public and has an additional constructor, making it easier to use a custom `TransportFactory` + +### Fixes + +- Session Replay: Fix masking of non-styled `Text` Composables ([#4361](https://github.com/getsentry/sentry-java/pull/4361)) +- Session Replay: Fix masking read-only `TextField` Composables ([#4362](https://github.com/getsentry/sentry-java/pull/4362)) + +## 8.10.0 + +### 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: ...` + - 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)) + +### Dependencies + +- Bump Native SDK from v0.8.3 to v0.8.4 ([#4343](https://github.com/getsentry/sentry-java/pull/4343)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#084) + - [diff](https://github.com/getsentry/sentry-native/compare/0.8.3...0.8.4) + +## 8.9.0 + +### Features + +- Add `SentryWrapper.wrapRunnable` to wrap `Runnable` for use with Sentry ([#4332](https://github.com/getsentry/sentry-java/pull/4332)) + +### Fixes + +- Fix TTFD measurement when API called too early ([#4297](https://github.com/getsentry/sentry-java/pull/4297)) +- Tag sockets traffic originating from Sentry's HttpConnection ([#4340](https://github.com/getsentry/sentry-java/pull/4340)) + - This should suppress the StrictMode's `UntaggedSocketViolation` +- Reduce debug logs verbosity ([#4341](https://github.com/getsentry/sentry-java/pull/4341)) +- 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 + +### Improvements + +- Make user interaction tracing faster and do fewer allocations ([#4347](https://github.com/getsentry/sentry-java/pull/4347)) +- Pre-load modules on a background thread upon SDK init ([#4348](https://github.com/getsentry/sentry-java/pull/4348)) + +## 8.8.0 + +### Features + +- Add `CoroutineExceptionHandler` for reporting uncaught exceptions in coroutines to Sentry ([#4259](https://github.com/getsentry/sentry-java/pull/4259)) + - This is now part of `sentry-kotlin-extensions` and can be used together with `SentryContext` when launching a coroutine + - Any exceptions thrown in a coroutine when using the handler will be captured (not rethrown!) and reported to Sentry + - It's also possible to extend `CoroutineExceptionHandler` to implement custom behavior in addition to the one we provide by default + +### Fixes + +- Use thread context classloader when available ([#4320](https://github.com/getsentry/sentry-java/pull/4320)) + - This ensures correct resource loading in environments like Spring Boot where the thread context classloader is used for resource loading. +- Improve low memory breadcrumb capturing ([#4325](https://github.com/getsentry/sentry-java/pull/4325)) +- Fix do not initialize SDK for Jetpack Compose Preview builds ([#4324](https://github.com/getsentry/sentry-java/pull/4324)) +- Fix Synchronize Baggage values ([#4327](https://github.com/getsentry/sentry-java/pull/4327)) + +### Improvements + +- Make `SystemEventsBreadcrumbsIntegration` faster ([#4330](https://github.com/getsentry/sentry-java/pull/4330)) + +## 8.7.0 + +### Features + +- 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. + + ```xml + + + + + + + + + ``` + + ```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); + // Set profiling lifecycle, can be `manual` (controlled through `Sentry.startProfiler()` and `Sentry.stopProfiler()`) or `trace` (automatically starts and stop a profile whenever a sampled trace starts and finishes) + options.setProfileLifecycle(ProfileLifecycle.TRACE); + // Enable profiling on app start. The app start profile will be stopped automatically when the app start root span finishes + options.setStartProfilerOnAppStart(true); + }); + ``` + + ```kotlin + 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.profileSessionSampleRate = 1.0 + // Set profiling lifecycle, can be `manual` (controlled through `Sentry.startProfiler()` and `Sentry.stopProfiler()`) or `trace` (automatically starts and stop a profile whenever a sampled trace starts and finishes) + options.profileLifecycle = ProfileLifecycle.TRACE + // Enable profiling on app start. The app start profile will be stopped automatically when the app start root span finishes + options.isStartProfilerOnAppStart = true + }) + ``` + + - Continuous Profiling - Stop when app goes in background ([#4311](https://github.com/getsentry/sentry-java/pull/4311)) + - Continuous Profiling - Add delayed stop ([#4293](https://github.com/getsentry/sentry-java/pull/4293)) + - Continuous Profiling - Out of Experimental ([#4310](https://github.com/getsentry/sentry-java/pull/4310)) + +### Fixes + +- Compress Screenshots on a background thread ([#4295](https://github.com/getsentry/sentry-java/pull/4295)) + +## 8.6.0 + +### Behavioral Changes + +- The Sentry SDK will now crash on startup if mixed versions have been detected ([#4277](https://github.com/getsentry/sentry-java/pull/4277)) + - On `Sentry.init` / `SentryAndroid.init` the SDK now checks if all Sentry Java / Android SDK dependencies have the same version. + - While this may seem like a bad idea at first glance, mixing versions of dependencies has a very high chance of causing a crash later. We opted for a controlled crash that's hard to miss. + - Note: This detection only works for new versions of the SDK, so please take this as a reminder to check your SDK version alignment manually when upgrading the SDK to this version and then you should be good. + - The SDK will also print log messages if mixed versions have been detected at a later point. ([#4270](https://github.com/getsentry/sentry-java/pull/4270)) + - This takes care of cases missed by the startup check above due to older versions. + +### Features + +- Increase http timeouts from 5s to 30s to have a better chance of events being delivered without retry ([#4276](https://github.com/getsentry/sentry-java/pull/4276)) +- Add `MANIFEST.MF` to Sentry JARs ([#4272](https://github.com/getsentry/sentry-java/pull/4272)) +- Retain baggage sample rate/rand values as doubles ([#4279](https://github.com/getsentry/sentry-java/pull/4279)) +- Introduce fatal SDK logger ([#4288](https://github.com/getsentry/sentry-java/pull/4288)) + - We use this to print out messages when there is a problem that prevents the SDK from working correctly. + - One example for this is when the SDK has been configured with mixed dependency versions where we print out details, which module and version are affected. + +### Fixes + +- Do not override user-defined `SentryOptions` ([#4262](https://github.com/getsentry/sentry-java/pull/4262)) +- Session Replay: Change bitmap config to `ARGB_8888` for screenshots ([#4282](https://github.com/getsentry/sentry-java/pull/4282)) +- The `MANIFEST.MF` of `sentry-opentelemetry-agent` now has `Implementation-Version` set to the raw version ([#4291](https://github.com/getsentry/sentry-java/pull/4291)) + - An example value would be `8.6.0` + - The value of the `Sentry-Version-Name` attribute looks like `sentry-8.5.0-otel-2.10.0` +- Fix tags missing for compose view hierarchies ([#4275](https://github.com/getsentry/sentry-java/pull/4275)) +- Do not leak SentryFileInputStream/SentryFileOutputStream descriptors and channels ([#4296](https://github.com/getsentry/sentry-java/pull/4296)) +- Remove "not yet implemented" from `Sentry.flush` comment ([#4305](https://github.com/getsentry/sentry-java/pull/4305)) + +### Internal + +- Added `platform` to SentryEnvelopeItemHeader ([#4287](https://github.com/getsentry/sentry-java/pull/4287)) + - Set `android` platform to ProfileChunk envelope item header + +### Dependencies + +- Bump Native SDK from v0.8.1 to v0.8.3 ([#4267](https://github.com/getsentry/sentry-java/pull/4267), [#4298](https://github.com/getsentry/sentry-java/pull/4298)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#083) + - [diff](https://github.com/getsentry/sentry-native/compare/0.8.1...0.8.3) +- Bump Spring Boot from 2.7.5 to 2.7.18 ([#3496](https://github.com/getsentry/sentry-java/pull/3496)) + +## 8.5.0 + +### 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 +- 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). + Note: Both `options.profilesSampler` and `options.profilesSampleRate` must **not** be set to enable Continuous Profiling. + + ```java + import io.sentry.ProfileLifecycle; + 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 + // In trace mode, the profiler will start and stop automatically whenever a sampled trace starts and finishes + options.getExperimental().setProfileLifecycle(ProfileLifecycle.MANUAL); + } + // 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 + // In trace mode, the profiler will start and stop automatically whenever a sampled trace starts and finishes + options.experimental.profileLifecycle = ProfileLifecycle.MANUAL + } + // Start profiling + Sentry.startProfiler() + + // After all profiling is done, stop the profiler. Profiles can last indefinitely if not stopped. + Sentry.stopProfiler() + ``` + + To learn more visit [Sentry's Continuous Profiling](https://docs.sentry.io/product/explore/profiling/transaction-vs-continuous-profiling/#continuous-profiling-mode) documentation page. + +### Fixes + +- Reduce excessive CPU usage when serializing breadcrumbs to disk for ANRs ([#4181](https://github.com/getsentry/sentry-java/pull/4181)) +- Ensure app start type is set, even when ActivityLifecycleIntegration is not running ([#4250](https://github.com/getsentry/sentry-java/pull/4250)) +- Use `SpringServletTransactionNameProvider` as fallback for Spring WebMVC ([#4263](https://github.com/getsentry/sentry-java/pull/4263)) + - In certain cases the SDK was not able to provide a transaction name automatically and thus did not finish the transaction for the request. + - We now first try `SpringMvcTransactionNameProvider` which would provide the route as transaction name. + - If that does not return anything, we try `SpringServletTransactionNameProvider` next, which returns the URL of the request. + +### Behavioral Changes + +- The user's `device.name` is not reported anymore via the device context, even if `options.isSendDefaultPii` is enabled ([#4179](https://github.com/getsentry/sentry-java/pull/4179)) + +### Dependencies + +- Bump Gradle from v8.12.1 to v8.13.0 ([#4209](https://github.com/getsentry/sentry-java/pull/4209)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v8130) + - [diff](https://github.com/gradle/gradle/compare/v8.12.1...v8.13.0) + +## 8.4.0 + +### Fixes + +- The SDK now handles `null` on many APIs instead of expecting a non `null` value ([#4245](https://github.com/getsentry/sentry-java/pull/4245)) + - Certain APIs like `setTag`, `setData`, `setExtra`, `setContext` previously caused a `NullPointerException` when invoked with either `null` key or value. + - The SDK now tries to have a sane fallback when `null` is passed and no longer throws `NullPointerException` + - If `null` is passed, the SDK will + - do nothing if a `null` key is passed, returning `null` for non void methods + - 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 +- 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 +- Pass OpenTelemetry span attributes into TracesSampler callback ([#4253](https://github.com/getsentry/sentry-java/pull/4253)) + - `SamplingContext` now has a `getAttribute` method that grants access to OpenTelemetry span attributes via their String key (e.g. `http.request.method`) +- Fix AbstractMethodError when using SentryTraced for Jetpack Compose ([#4255](https://github.com/getsentry/sentry-java/pull/4255)) +- Assume `http.client` for span `op` if not a root span ([#4257](https://github.com/getsentry/sentry-java/pull/4257)) +- Avoid unnecessary copies when using `CopyOnWriteArrayList` ([#4247](https://github.com/getsentry/sentry-java/pull/4247)) + - This affects in particular `SentryTracer.getLatestActiveSpan` which would have previously copied all child span references. This may have caused `OutOfMemoryError` on certain devices due to high frequency of calling the method. + +### Features + +- The SDK now automatically propagates the trace-context to the native layer. This allows to connect errors on different layers of the application. ([#4137](https://github.com/getsentry/sentry-java/pull/4137)) +- Capture OpenTelemetry span events ([#3564](https://github.com/getsentry/sentry-java/pull/3564)) + - OpenTelemetry spans may have exceptions attached to them (`openTelemetrySpan.recordException`). We can now send those to Sentry as errors. + - Set `capture-open-telemetry-events=true` in `sentry.properties` to enable it + - Set `sentry.capture-open-telemetry-events=true` in Springs `application.properties` to enable it + - Set `sentry.captureOpenTelemetryEvents: true` in Springs `application.yml` to enable it + +### Behavioural Changes + +- Use `java.net.URI` for parsing URLs in `UrlUtils` ([#4210](https://github.com/getsentry/sentry-java/pull/4210)) + - This could affect grouping for issues with messages containing URLs that fall in known corner cases that were handled incorrectly previously (e.g. email in URL path) + +### Internal + +- Also use port when checking if a request is made to Sentry DSN ([#4231](https://github.com/getsentry/sentry-java/pull/4231)) + - For our OpenTelemetry integration we check if a span is for a request to Sentry + - We now also consider the port when performing this check + +### Dependencies + +- Bump Native SDK from v0.7.20 to v0.8.1 ([#4137](https://github.com/getsentry/sentry-java/pull/4137)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0810) + - [diff](https://github.com/getsentry/sentry-native/compare/v0.7.20...0.8.1) + +## 8.3.0 + +### Features + +- Add HTTP server request headers from OpenTelemetry span attributes to sentry `request` in payload ([#4102](https://github.com/getsentry/sentry-java/pull/4102)) + - You have to explicitly enable each header by adding it to the [OpenTelemetry config](https://opentelemetry.io/docs/zero-code/java/agent/instrumentation/http/#capturing-http-request-and-response-headers) + - Please only enable headers you actually want to send to Sentry. Some may contain sensitive data like PII, cookies, tokens etc. + - We are no longer adding request/response headers to `contexts/otel/attributes` of the event. +- The `ignoredErrors` option is now configurable via the manifest property `io.sentry.traces.ignored-errors` ([#4178](https://github.com/getsentry/sentry-java/pull/4178)) +- A list of active Spring profiles is attached to payloads sent to Sentry (errors, traces, etc.) and displayed in the UI when using our Spring or Spring Boot integrations ([#4147](https://github.com/getsentry/sentry-java/pull/4147)) + - This consists of an empty list when only the default profile is active +- Added `enableTraceIdGeneration` to the AndroidOptions. This allows Hybrid SDKs to "freeze" and control the trace and connect errors on different layers of the application ([4188](https://github.com/getsentry/sentry-java/pull/4188)) +- Move to a single NetworkCallback listener to reduce number of IPC calls on Android ([#4164](https://github.com/getsentry/sentry-java/pull/4164)) +- Add GraphQL Apollo Kotlin 4 integration ([#4166](https://github.com/getsentry/sentry-java/pull/4166)) +- Add support for async dispatch requests to Spring Boot 2 and 3 ([#3983](https://github.com/getsentry/sentry-java/pull/3983)) + - To enable it, please set `sentry.keep-transactions-open-for-async-responses=true` in `application.properties` or `sentry.keepTransactionsOpenForAsyncResponses: true` in `application.yml` +- Add constructor to JUL `SentryHandler` for disabling external config ([#4208](https://github.com/getsentry/sentry-java/pull/4208)) + +### Fixes + +- Filter strings that cannot be parsed as Regex no longer cause an SDK crash ([#4213](https://github.com/getsentry/sentry-java/pull/4213)) + - This was the case e.g. for `ignoredErrors`, `ignoredTransactions` and `ignoredCheckIns` + - We now simply don't use such strings for Regex matching and only use them for String comparison +- `SentryOptions.setTracePropagationTargets` is no longer marked internal ([#4170](https://github.com/getsentry/sentry-java/pull/4170)) +- Session Replay: Fix crash when a navigation breadcrumb does not have "to" destination ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) +- Session Replay: Cap video segment duration to maximum 5 minutes to prevent endless video encoding in background ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) +- Check `tracePropagationTargets` in OpenTelemetry propagator ([#4191](https://github.com/getsentry/sentry-java/pull/4191)) + - If a URL can be retrieved from OpenTelemetry span attributes, we check it against `tracePropagationTargets` before attaching `sentry-trace` and `baggage` headers to outgoing requests + - If no URL can be retrieved we always attach the headers +- Fix `ignoredErrors`, `ignoredTransactions` and `ignoredCheckIns` being unset by external options like `sentry.properties` or ENV vars ([#4207](https://github.com/getsentry/sentry-java/pull/4207)) + - Whenever parsing of external options was enabled (`enableExternalConfiguration`), which is the default for many integrations, the values set on `SentryOptions` passed to `Sentry.init` would be lost + - Even if the value was not set in any external configuration it would still be set to an empty list + +### 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). + Therefore, if you're using one of those modules, changing your imports will suffice. + +## 8.2.0 + +### Breaking Changes + +- The Kotlin Language version is now set to 1.6 ([#3936](https://github.com/getsentry/sentry-java/pull/3936)) + +### Features + +- 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 +- (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 +- Update `sampleRate` that is sent to Sentry and attached to the `baggage` header on outgoing requests ([#4158](https://github.com/getsentry/sentry-java/pull/4158)) + - If the SDK uses its `sampleRate` or `tracesSampler` callback, it now updates the `sampleRate` in Dynamic Sampling Context. + +### Fixes + +- Log a warning when envelope or items are dropped due to rate limiting ([#4148](https://github.com/getsentry/sentry-java/pull/4148)) +- Do not log if `OtelContextScopesStorage` cannot be found ([#4127](https://github.com/getsentry/sentry-java/pull/4127)) + - Previously `java.lang.ClassNotFoundException: io.sentry.opentelemetry.OtelContextScopesStorage` was shown in the log if the class could not be found. + - This is just a lookup the SDK performs to configure itself. The SDK also works without OpenTelemetry. +- 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 +- Mention javadoc and sources for published artifacts in Gradle `.module` metadata ([#3936](https://github.com/getsentry/sentry-java/pull/3936)) +- (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 + +### Dependencies + +- Bump Native SDK from v0.7.19 to v0.7.20 ([#4128](https://github.com/getsentry/sentry-java/pull/4128)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0720) + - [diff](https://github.com/getsentry/sentry-native/compare/v0.7.19...0.7.20) +- Bump Gradle from v8.9.0 to v8.12.1 ([#4106](https://github.com/getsentry/sentry-java/pull/4106)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v8121) + - [diff](https://github.com/gradle/gradle/compare/v8.9.0...v8.12.1) + +## 8.1.0 + +### Features + +- Add `options.ignoredErrors` to filter out errors that match a certain String or Regex ([#4083](https://github.com/getsentry/sentry-java/pull/4083)) + - The matching is attempted on `event.message`, `event.formatted`, and `{event.throwable.class.name}: {event.throwable.message}` + - Can be set in `sentry.properties`, e.g. `ignored-errors=Some error,Another .*` + - Can be set in environment variables, e.g. `SENTRY_IGNORED_ERRORS=Some error,Another .*` + - For Spring Boot, it can be set in `application.properties`, e.g. `sentry.ignored-errors=Some error,Another .*` +- Log OpenTelemetry related Sentry config ([#4122](https://github.com/getsentry/sentry-java/pull/4122)) + +### Fixes + +- Avoid logging an error when a float is passed in the manifest ([#4031](https://github.com/getsentry/sentry-java/pull/4031)) +- Add `request` details to transactions created through OpenTelemetry ([#4098](https://github.com/getsentry/sentry-java/pull/4098)) + - We now add HTTP request method and URL where Sentry expects it to display it in Sentry UI +- Remove `java.lang.ClassNotFoundException` debug logs when searching for OpenTelemetry marker classes ([#4091](https://github.com/getsentry/sentry-java/pull/4091)) + - There was up to three of these, one for `io.sentry.opentelemetry.agent.AgentMarker`, `io.sentry.opentelemetry.agent.AgentlessMarker` and `io.sentry.opentelemetry.agent.AgentlessSpringMarker`. + - These were not indicators of something being wrong but rather the SDK looking at what is available at runtime to configure itself accordingly. +- Do not instrument File I/O operations if tracing is disabled ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Do not instrument User Interaction multiple times ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Speed up view traversal to find touched target in `UserInteractionIntegration` ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Reduce IPC/Binder calls performed by the SDK ([#4058](https://github.com/getsentry/sentry-java/pull/4058)) + +### Behavioural Changes + +- Reduce the number of broadcasts the SDK is subscribed for ([#4052](https://github.com/getsentry/sentry-java/pull/4052)) + - Drop `TempSensorBreadcrumbsIntegration` + - Drop `PhoneStateBreadcrumbsIntegration` + - Reduce number of broadcasts in `SystemEventsBreadcrumbsIntegration` + +Current list of the broadcast events can be found [here](https://github.com/getsentry/sentry-java/blob/9b8dc0a844d10b55ddeddf55d278c0ab0f86421c/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java#L131-L153). If you'd like to subscribe for more events, consider overriding the `SystemEventsBreadcrumbsIntegration` as follows: + +```kotlin +SentryAndroid.init(context) { options -> + options.integrations.removeAll { it is SystemEventsBreadcrumbsIntegration } + options.integrations.add(SystemEventsBreadcrumbsIntegration(context, SystemEventsBreadcrumbsIntegration.getDefaultActions() + listOf(/* your custom actions */))) +} +``` + +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 + +### Dependencies + +- Bump Spring Boot to `3.4.2` ([#4081](https://github.com/getsentry/sentry-java/pull/4081)) +- Bump Native SDK from v0.7.14 to v0.7.19 ([#4076](https://github.com/getsentry/sentry-java/pull/4076)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0719) + - [diff](https://github.com/getsentry/sentry-native/compare/v0.7.14...0.7.19) + +## 8.0.0 + +### Summary + +Version 8 of the Sentry Android/Java SDK brings a variety of features and fixes. The most notable changes are: + +- `Hub` has been replaced by `Scopes` +- New `Scope` types have been introduced, see "Behavioural Changes" for more details. +- 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. +- The SDK is now compatible with Spring Boot 3.4 +- We now support GraphQL v22 (`sentry-graphql-22`) +- Metrics have been removed + +Please take a look at [our migration guide in docs](https://docs.sentry.io/platforms/java/migration/7.x-to-8.0). + +### Sentry Self-hosted Compatibility + +This SDK version is compatible with a self-hosted version of Sentry `22.12.0` or higher. If you are using an older version of [self-hosted Sentry](https://develop.sentry.dev/self-hosted/) (aka onpremise), you will need to [upgrade](https://develop.sentry.dev/self-hosted/releases/). If you're using `sentry.io` no action is required. + +### Breaking Changes + +- The Android minSdk level for all Android modules is now 21 ([#3852](https://github.com/getsentry/sentry-java/pull/3852)) +- 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 +- `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. +- 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. +- `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)) +- `profilingTracesIntervalMillis` option for Android has been removed ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `io.sentry.session-tracking.enable` manifest option has been removed ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `Sentry.traceHeaders()` method has been removed, please use `Sentry.getTraceparent()` instead ([#3718](https://github.com/getsentry/sentry-java/pull/3718)) +- `Sentry.reportFullDisplayed()` method has been removed, please use `Sentry.reportFullyDisplayed()` instead ([#3717](https://github.com/getsentry/sentry-java/pull/3717)) +- `User.other` has been removed, please use `data` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `SdkVersion.getIntegrations()` has been removed, please use `getIntegrationSet` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `SdkVersion.getPackages()` has been removed, please use `getPackageSet()` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `Device.language` has been removed, please use `locale` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `TraceContext.user` and `TraceContextUser` class have been removed, please use `userId` on `TraceContext` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `TransactionContext.fromSentryTrace()` has been removed, please use `Sentry.continueTrace()` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `SentryDataFetcherExceptionHandler` has been removed, please use `SentryGenericDataFetcherExceptionHandler` in combination with `SentryInstrumentation` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `sentry-android-okhttp` has been removed in favor of `sentry-okhttp`, removing android dependency from the module ([#3510](https://github.com/getsentry/sentry-java/pull/3510)) +- `Contexts` no longer extends `ConcurrentHashMap`, instead we offer a selected set of methods. +- 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`. +- 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 +- `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) -> { ... })`. +- `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 +- 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. +- (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` +- 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 + +### Features + +- 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. +- 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. +- 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. +- `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` +- 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.*` +- 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. +- 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 +- 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 +- 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` +- 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. + - We are planning to improve how we visualize suppressed exceptions. See https://github.com/getsentry/sentry-java/issues/4059 +- 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. +- 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 +- 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 +- 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 +- 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 +- 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 +- 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 +- 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)) + +### 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` +- 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 +- 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 +- 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 +- 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 +- 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` +- 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. +- 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 +- 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)) +- `TracesSampler` is now only created once in `SentryOptions` instead of creating a new one for every `Hub` (which is now `Scopes`). This means we're now creating fewer `SecureRandom` instances. + +### Internal + +- Make `SentryClient` constructor public ([#4045](https://github.com/getsentry/sentry-java/pull/4045)) +- Warm starts cleanup ([#3954](https://github.com/getsentry/sentry-java/pull/3954)) + +### Changes in pre-releases + +These changes have been made during development of `8.0.0`. You may skip this section. We just put it here for sake of completeness. + +- 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. +- 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` +- 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` +- 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. + +- 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 +- 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. +- 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). +- 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 +- 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 +- 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 +- 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. +- 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 +- 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. +- 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. +- 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 +- 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. + +### 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) +- 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 + +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. +- `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: + +``` +try (final @NotNull ISentryLifecycleToken ignored = Sentry.pushScope()) { + // this block has its separate current scope +} +``` + +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 +- 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. +- Fix swallow NDK loadLibrary errors ([#4082](https://github.com/getsentry/sentry-java/pull/4082)) + +## 7.22.6 + +### Fixes + +- Compress Screenshots on a background thread ([#4295](https://github.com/getsentry/sentry-java/pull/4295)) +- 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 +- 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)) +- Session Replay: Fix crash on devices with the Unisoc/Spreadtrum T606 chipset ([#4477](https://github.com/getsentry/sentry-java/pull/4477)) +- Session Replay: Fix masking of non-styled `Text` Composables ([#4361](https://github.com/getsentry/sentry-java/pull/4361)) +- Session Replay: Fix masking read-only `TextField` Composables ([#4362](https://github.com/getsentry/sentry-java/pull/4362)) +- Fix Session Replay masking for newer versions of Jetpack Compose (1.8+) ([#4485](https://github.com/getsentry/sentry-java/pull/4485)) +- Session Replay: Expand fix for crash on devices to all Unisoc/Spreadtrum chipsets ([#4510](https://github.com/getsentry/sentry-java/pull/4510)) + +## 7.22.5 + +### Fixes + +- Session Replay: Change bitmap config to `ARGB_8888` for screenshots ([#4282](https://github.com/getsentry/sentry-java/pull/4282)) + +## 7.22.4 + +### Fixes + +- Session Replay: Fix crash when a navigation breadcrumb does not have "to" destination ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) +- Session Replay: Cap video segment duration to maximum 5 minutes to prevent endless video encoding in background ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) +- Avoid logging an error when a float is passed in the manifest ([#4266](https://github.com/getsentry/sentry-java/pull/4266)) + +## 7.22.3 + +### Fixes + +- Reduce excessive CPU usage when serializing breadcrumbs to disk for ANRs ([#4181](https://github.com/getsentry/sentry-java/pull/4181)) + +## 7.22.2 + +### Fixes + +- Fix AbstractMethodError when using SentryTraced for Jetpack Compose ([#4256](https://github.com/getsentry/sentry-java/pull/4256)) + +## 7.22.1 + +### Fixes + +- Fix Ensure app start type is set, even when ActivityLifecycleIntegration is not running ([#4216](https://github.com/getsentry/sentry-java/pull/4216)) +- Fix properly reset application/content-provider timespans for warm app starts ([#4244](https://github.com/getsentry/sentry-java/pull/4244)) + +## 7.22.0 + +### 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 +- (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 + +## 7.21.0 + +### Fixes + +- Do not instrument File I/O operations if tracing is disabled ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Do not instrument User Interaction multiple times ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Speed up view traversal to find touched target in `UserInteractionIntegration` ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Reduce IPC/Binder calls performed by the SDK ([#4058](https://github.com/getsentry/sentry-java/pull/4058)) + +### 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 +- Reduce the number of broadcasts the SDK is subscribed for ([#4052](https://github.com/getsentry/sentry-java/pull/4052)) + - Drop `TempSensorBreadcrumbsIntegration` + - Drop `PhoneStateBreadcrumbsIntegration` + - Reduce number of broadcasts in `SystemEventsBreadcrumbsIntegration` + +Current list of the broadcast events can be found [here](https://github.com/getsentry/sentry-java/blob/9b8dc0a844d10b55ddeddf55d278c0ab0f86421c/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java#L131-L153). If you'd like to subscribe for more events, consider overriding the `SystemEventsBreadcrumbsIntegration` as follows: + +```kotlin +SentryAndroid.init(context) { options -> + options.integrations.removeAll { it is SystemEventsBreadcrumbsIntegration } + options.integrations.add(SystemEventsBreadcrumbsIntegration(context, SystemEventsBreadcrumbsIntegration.getDefaultActions() + listOf(/* your custom actions */))) +} +``` + +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). + +## 7.21.0-beta.1 + +### Fixes + +- Do not instrument File I/O operations if tracing is disabled ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Do not instrument User Interaction multiple times ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Speed up view traversal to find touched target in `UserInteractionIntegration` ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Reduce IPC/Binder calls performed by the SDK ([#4058](https://github.com/getsentry/sentry-java/pull/4058)) + +### Behavioural Changes + +- Reduce the number of broadcasts the SDK is subscribed for ([#4052](https://github.com/getsentry/sentry-java/pull/4052)) + - Drop `TempSensorBreadcrumbsIntegration` + - Drop `PhoneStateBreadcrumbsIntegration` + - Reduce number of broadcasts in `SystemEventsBreadcrumbsIntegration` + +Current list of the broadcast events can be found [here](https://github.com/getsentry/sentry-java/blob/9b8dc0a844d10b55ddeddf55d278c0ab0f86421c/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java#L131-L153). If you'd like to subscribe for more events, consider overriding the `SystemEventsBreadcrumbsIntegration` as follows: + +```kotlin +SentryAndroid.init(context) { options -> + options.integrations.removeAll { it is SystemEventsBreadcrumbsIntegration } + options.integrations.add(SystemEventsBreadcrumbsIntegration(context, SystemEventsBreadcrumbsIntegration.getDefaultActions() + listOf(/* your custom actions */))) +} +``` + +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). + +## 7.20.1 + +### 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 + +## 7.20.0 + +### Features + +- Session Replay GA ([#4017](https://github.com/getsentry/sentry-java/pull/4017)) + +To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onErrorSampleRate` options. + +```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) +} +``` + +### Fixes + +- Fix warm start detection ([#3937](https://github.com/getsentry/sentry-java/pull/3937)) +- Session Replay: Reduce memory allocations, disk space consumption, and payload size ([#4016](https://github.com/getsentry/sentry-java/pull/4016)) +- Session Replay: Do not try to encode corrupted frames multiple times ([#4016](https://github.com/getsentry/sentry-java/pull/4016)) + +### Internal + +- Session Replay: Allow overriding `SdkVersion` for replay events ([#4014](https://github.com/getsentry/sentry-java/pull/4014)) +- Session Replay: Send replay options as tags ([#4015](https://github.com/getsentry/sentry-java/pull/4015)) + +### Breaking changes + +- Session Replay options were moved from under `experimental` to the main `options` object ([#4017](https://github.com/getsentry/sentry-java/pull/4017)) + +## 7.19.1 + +### Fixes + +- Change TTFD timeout to 25 seconds ([#3984](https://github.com/getsentry/sentry-java/pull/3984)) +- Session Replay: Fix memory leak when masking Compose screens ([#3985](https://github.com/getsentry/sentry-java/pull/3985)) +- Session Replay: Fix potential ANRs in `GestureRecorder` ([#4001](https://github.com/getsentry/sentry-java/pull/4001)) + +### Internal + +- Session Replay: Flutter improvements ([#4007](https://github.com/getsentry/sentry-java/pull/4007)) + +## 7.19.0 + +### 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` +- 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) + +## 7.18.1 + +### Fixes + +- Fix testTag not working for Jetpack Compose user interaction tracking ([#3878](https://github.com/getsentry/sentry-java/pull/3878)) + +## 7.18.0 + +### 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 +- 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)) + +### Fixes + +- 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 +- 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) + +## 7.17.0 + +### Features + +- Add meta option to set the maximum amount of breadcrumbs to be logged. ([#3836](https://github.com/getsentry/sentry-java/pull/3836)) +- Use a separate `Random` instance per thread to improve SDK performance ([#3835](https://github.com/getsentry/sentry-java/pull/3835)) + +### Fixes + +- 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 + +## 7.16.0 + +### Features + +- Add meta option to attach ANR thread dumps ([#3791](https://github.com/getsentry/sentry-java/pull/3791)) + +### Fixes + +- Cache parsed Dsn ([#3796](https://github.com/getsentry/sentry-java/pull/3796)) +- fix invalid profiles when the transaction name is empty ([#3747](https://github.com/getsentry/sentry-java/pull/3747)) +- Deprecate `enableTracing` option ([#3777](https://github.com/getsentry/sentry-java/pull/3777)) +- Vendor `java.util.Random` and replace `java.security.SecureRandom` usages ([#3783](https://github.com/getsentry/sentry-java/pull/3783)) +- Fix potential ANRs due to NDK scope sync ([#3754](https://github.com/getsentry/sentry-java/pull/3754)) +- Fix potential ANRs due to NDK System.loadLibrary calls ([#3670](https://github.com/getsentry/sentry-java/pull/3670)) +- Fix slow `Log` calls on app startup ([#3793](https://github.com/getsentry/sentry-java/pull/3793)) +- Fix slow Integration name parsing ([#3794](https://github.com/getsentry/sentry-java/pull/3794)) +- Session Replay: Reduce startup and capture overhead ([#3799](https://github.com/getsentry/sentry-java/pull/3799)) +- Load lazy fields on init in the background ([#3803](https://github.com/getsentry/sentry-java/pull/3803)) +- Replace setOf with HashSet.add ([#3801](https://github.com/getsentry/sentry-java/pull/3801)) + +### 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. + +## 7.16.0-alpha.1 + +### Features + +- Add meta option to attach ANR thread dumps ([#3791](https://github.com/getsentry/sentry-java/pull/3791)) + +### Fixes + +- Cache parsed Dsn ([#3796](https://github.com/getsentry/sentry-java/pull/3796)) +- fix invalid profiles when the transaction name is empty ([#3747](https://github.com/getsentry/sentry-java/pull/3747)) +- Deprecate `enableTracing` option ([#3777](https://github.com/getsentry/sentry-java/pull/3777)) +- Vendor `java.util.Random` and replace `java.security.SecureRandom` usages ([#3783](https://github.com/getsentry/sentry-java/pull/3783)) +- Fix potential ANRs due to NDK scope sync ([#3754](https://github.com/getsentry/sentry-java/pull/3754)) +- Fix potential ANRs due to NDK System.loadLibrary calls ([#3670](https://github.com/getsentry/sentry-java/pull/3670)) +- Fix slow `Log` calls on app startup ([#3793](https://github.com/getsentry/sentry-java/pull/3793)) +- Fix slow Integration name parsing ([#3794](https://github.com/getsentry/sentry-java/pull/3794)) +- Session Replay: Reduce startup and capture overhead ([#3799](https://github.com/getsentry/sentry-java/pull/3799)) + +## 7.15.0 + +### Features + +- 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 +- 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)) + +### Fixes + +- Avoid stopping appStartProfiler after application creation ([#3630](https://github.com/getsentry/sentry-java/pull/3630)) +- Session Replay: Correctly detect dominant color for `TextView`s with Spans ([#3682](https://github.com/getsentry/sentry-java/pull/3682)) +- Fix ensure Application Context is used even when SDK is initialized via Activity Context ([#3669](https://github.com/getsentry/sentry-java/pull/3669)) +- Fix potential ANRs due to `Calendar.getInstance` usage in Breadcrumbs constructor ([#3736](https://github.com/getsentry/sentry-java/pull/3736)) +- 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_: + +- `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)) +- Change `redactAllText` and `redactAllImages` to `maskAllText` and `maskAllImages` ([#3741](https://github.com/getsentry/sentry-java/pull/3741)) + +## 7.14.0 + +### Features + +- Session Replay: Gesture/touch support for Flutter ([#3623](https://github.com/getsentry/sentry-java/pull/3623)) + +### Fixes + +- Fix app start spans missing from Pixel devices ([#3634](https://github.com/getsentry/sentry-java/pull/3634)) +- Avoid ArrayIndexOutOfBoundsException on Android cpu data collection ([#3598](https://github.com/getsentry/sentry-java/pull/3598)) +- Fix lazy select queries instrumentation ([#3604](https://github.com/getsentry/sentry-java/pull/3604)) +- Session Replay: buffer mode improvements ([#3622](https://github.com/getsentry/sentry-java/pull/3622)) + - Align next segment timestamp with the end of the buffered segment when converting from buffer mode to session mode + - Persist `buffer` replay type for the entire replay when converting from buffer mode to session mode + - Properly store screen names for `buffer` mode +- Session Replay: fix various crashes and issues ([#3628](https://github.com/getsentry/sentry-java/pull/3628)) + - Fix video not being encoded on Pixel devices + - Fix SIGABRT native crashes on Xiaomi devices when encoding a video + - Fix `RejectedExecutionException` when redacting a screenshot + - Fix `FileNotFoundException` when persisting segment values + +### Chores + +- Introduce `ReplayShadowMediaCodec` and refactor tests using custom encoder ([#3612](https://github.com/getsentry/sentry-java/pull/3612)) + +## 7.13.0 + +### Features + +- Session Replay: ([#3565](https://github.com/getsentry/sentry-java/pull/3565)) ([#3609](https://github.com/getsentry/sentry-java/pull/3609)) + - Capture remaining replay segment for ANRs on next app launch + - Capture remaining replay segment for unhandled crashes on next app launch + +### Fixes + +- Session Replay: ([#3565](https://github.com/getsentry/sentry-java/pull/3565)) ([#3609](https://github.com/getsentry/sentry-java/pull/3609)) + - Fix stopping replay in `session` mode at 1 hour deadline + - Never encode full frames for a video segment, only do partial updates. This further reduces size of the replay segment + - Use propagation context when no active transaction for ANRs + +### Dependencies + +- Bump Spring Boot to 3.3.2 ([#3541](https://github.com/getsentry/sentry-java/pull/3541)) + +## 7.12.1 + +### Fixes + +- Check app start spans time and ignore background app starts ([#3550](https://github.com/getsentry/sentry-java/pull/3550)) + - This should eliminate long-lasting App Start transactions + +## 7.12.0 + +### Features + +- Session Replay Public Beta ([#3339](https://github.com/getsentry/sentry-java/pull/3339)) + + To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.errorSampleRate` experimental options. + + ```kotlin + import io.sentry.SentryReplayOptions + 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) + } + ``` + + To learn more visit [Sentry's Mobile Session Replay](https://docs.sentry.io/product/explore/session-replay/mobile/) documentation page. + +## 7.11.0 + +### Features + +- Report dropped spans ([#3528](https://github.com/getsentry/sentry-java/pull/3528)) + +### Fixes + +- Fix duplicate session start for React Native ([#3504](https://github.com/getsentry/sentry-java/pull/3504)) +- Move onFinishCallback before span or transaction is finished ([#3459](https://github.com/getsentry/sentry-java/pull/3459)) +- Add timestamp when a profile starts ([#3442](https://github.com/getsentry/sentry-java/pull/3442)) +- Move fragment auto span finish to onFragmentStarted ([#3424](https://github.com/getsentry/sentry-java/pull/3424)) +- Remove profiling timeout logic and disable profiling on API 21 ([#3478](https://github.com/getsentry/sentry-java/pull/3478)) +- Properly reset metric flush flag on metric emission ([#3493](https://github.com/getsentry/sentry-java/pull/3493)) +- Use SecureRandom in favor of Random for Metrics ([#3495](https://github.com/getsentry/sentry-java/pull/3495)) +- Fix UncaughtExceptionHandlerIntegration Memory Leak ([#3398](https://github.com/getsentry/sentry-java/pull/3398)) +- Deprecated `User.segment`. Use a custom tag or context instead. ([#3511](https://github.com/getsentry/sentry-java/pull/3511)) +- Fix duplicated http spans ([#3526](https://github.com/getsentry/sentry-java/pull/3526)) +- When capturing unhandled hybrid exception session should be ended and new start if need ([#3480](https://github.com/getsentry/sentry-java/pull/3480)) + +### Dependencies + +- Bump Native SDK from v0.7.0 to v0.7.2 ([#3314](https://github.com/getsentry/sentry-java/pull/3314)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#072) + - [diff](https://github.com/getsentry/sentry-native/compare/0.7.0...0.7.2) + +## 7.10.0 + +### Features + +- Publish Gradle module metadata ([#3422](https://github.com/getsentry/sentry-java/pull/3422)) + +### Fixes + +- Fix faulty `span.frame_delay` calculation for early app start spans ([#3427](https://github.com/getsentry/sentry-java/pull/3427)) +- Fix crash when installing `ShutdownHookIntegration` and the VM is shutting down ([#3456](https://github.com/getsentry/sentry-java/pull/3456)) + +## 7.9.0 + +### Features + +- Add start_type to app context ([#3379](https://github.com/getsentry/sentry-java/pull/3379)) +- Add ttid/ttfd contribution flags ([#3386](https://github.com/getsentry/sentry-java/pull/3386)) + +### Fixes + +- (Internal) Metrics code cleanup ([#3403](https://github.com/getsentry/sentry-java/pull/3403)) +- Fix Frame measurements in app start transactions ([#3382](https://github.com/getsentry/sentry-java/pull/3382)) +- Fix timing metric value different from span duration ([#3368](https://github.com/getsentry/sentry-java/pull/3368)) +- Do not always write startup crash marker ([#3409](https://github.com/getsentry/sentry-java/pull/3409)) + - This may have been causing the SDK init logic to block the main thread + +## 7.8.0 + +### Features + +- Add description to OkHttp spans ([#3320](https://github.com/getsentry/sentry-java/pull/3320)) +- Enable backpressure management by default ([#3284](https://github.com/getsentry/sentry-java/pull/3284)) + +### Fixes + +- Add rate limit to Metrics ([#3334](https://github.com/getsentry/sentry-java/pull/3334)) +- Fix java.lang.ClassNotFoundException: org.springframework.web.servlet.HandlerMapping in Spring Boot Servlet mode without WebMVC ([#3336](https://github.com/getsentry/sentry-java/pull/3336)) +- Fix normalization of metrics keys, tags and values ([#3332](https://github.com/getsentry/sentry-java/pull/3332)) + +## 7.7.0 + +### Features + +- Add support for Spring Rest Client ([#3199](https://github.com/getsentry/sentry-java/pull/3199)) +- Extend Proxy options with proxy type ([#3326](https://github.com/getsentry/sentry-java/pull/3326)) + +### Fixes + +- Fixed default deadline timeout to 30s instead of 300s ([#3322](https://github.com/getsentry/sentry-java/pull/3322)) +- Fixed `Fix java.lang.ClassNotFoundException: org.springframework.web.servlet.HandlerExceptionResolver` in Spring Boot Servlet mode without WebMVC ([#3333](https://github.com/getsentry/sentry-java/pull/3333)) + +## 7.6.0 + +### 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)) + 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() + .increment( + "button_login_click", // key + 1.0, // value + null, // unit + mapOf( // tags + "provider" to "e-mail" + ) + ) + ``` + To learn more about Sentry Developer Metrics, head over to our [Java](https://docs.sentry.io/platforms/java/metrics/) and [Android](https://docs.sentry.io//platforms/android/metrics/) docs page. + +## 7.5.0 + +### Features + +- Add support for measurements at span level ([#3219](https://github.com/getsentry/sentry-java/pull/3219)) +- Add `enableScopePersistence` option to disable `PersistingScopeObserver` used for ANR reporting which may increase performance overhead. Defaults to `true` ([#3218](https://github.com/getsentry/sentry-java/pull/3218)) + - When disabled, the SDK will not enrich ANRv2 events with scope data (e.g. breadcrumbs, user, tags, etc.) +- Configurable defaults for Cron - MonitorConfig ([#3195](https://github.com/getsentry/sentry-java/pull/3195)) +- We now display a warning on startup if an incompatible version of Spring Boot is detected ([#3233](https://github.com/getsentry/sentry-java/pull/3233)) + - This should help notice a mismatching Sentry dependency, especially when upgrading a Spring Boot application +- Experimental: Add Metrics API ([#3205](https://github.com/getsentry/sentry-java/pull/3205)) + +### Fixes + +- Ensure performance measurement collection is not taken too frequently ([#3221](https://github.com/getsentry/sentry-java/pull/3221)) +- Fix old profiles deletion on SDK init ([#3216](https://github.com/getsentry/sentry-java/pull/3216)) +- Fix hub restore point in wrappers: SentryWrapper, SentryTaskDecorator and SentryScheduleHook ([#3225](https://github.com/getsentry/sentry-java/pull/3225)) + - We now reset the hub to its previous value on the thread where the `Runnable`/`Callable`/`Supplier` is executed instead of setting it to the hub that was used on the thread where the `Runnable`/`Callable`/`Supplier` was created. +- Fix add missing thread name/id to app start spans ([#3226](https://github.com/getsentry/sentry-java/pull/3226)) + +## 7.4.0 + +### Features + +- Add new threshold parameters to monitor config ([#3181](https://github.com/getsentry/sentry-java/pull/3181)) +- Report process init time as a span for app start performance ([#3159](https://github.com/getsentry/sentry-java/pull/3159)) +- (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 + +### Fixes + +- Don't wait on main thread when SDK restarts ([#3200](https://github.com/getsentry/sentry-java/pull/3200)) +- Fix Jetpack Compose widgets are not being correctly identified for user interaction tracing ([#3209](https://github.com/getsentry/sentry-java/pull/3209)) +- Fix issue title on Android when a wrapping `RuntimeException` is thrown by the system ([#3212](https://github.com/getsentry/sentry-java/pull/3212)) + - This will change grouping of the issues that were previously titled `RuntimeInit$MethodAndArgsCaller` to have them split up properly by the original root cause exception + +## 7.3.0 + +### 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)) +- 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)) + +### Fixes + +- Avoid multiple breadcrumbs from OkHttpEventListener ([#3175](https://github.com/getsentry/sentry-java/pull/3175)) +- Apply OkHttp listener auto finish timestamp to all running spans ([#3167](https://github.com/getsentry/sentry-java/pull/3167)) +- Fix not eligible for auto proxying warnings ([#3154](https://github.com/getsentry/sentry-java/pull/3154)) +- Set default fingerprint for ANRv2 events to correctly group background and foreground ANRs ([#3164](https://github.com/getsentry/sentry-java/pull/3164)) + - This will improve grouping of ANRs that have similar stacktraces but differ in background vs foreground state. Only affects newly-ingested ANR events with `mechanism:AppExitInfo` +- Fix UserFeedback disk cache name conflicts with linked events ([#3116](https://github.com/getsentry/sentry-java/pull/3116)) + +### Breaking changes + +- Remove `HostnameVerifier` option as it's flagged by security tools of some app stores ([#3150](https://github.com/getsentry/sentry-java/pull/3150)) + - If you were using this option, you have 3 possible paths going forward: + - Provide a custom `ITransportFactory` through `SentryOptions.setTransportFactory()`, where you can copy over most of the parts like `HttpConnection` and `AsyncHttpTransport` from the SDK with necessary modifications + - Get a certificate for your server through e.g. [Let's Encrypt](https://letsencrypt.org/) + - Fork the SDK and add the hostname verifier back + +### Dependencies + +- Bump Native SDK from v0.6.7 to v0.7.0 ([#3133](https://github.com/getsentry/sentry-java/pull/3133)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#070) + - [diff](https://github.com/getsentry/sentry-native/compare/0.6.7...0.7.0) + +## 7.2.0 + +### Features + +- Handle `monitor`/`check_in` in client reports and rate limiter ([#3096](https://github.com/getsentry/sentry-java/pull/3096)) +- Add support for `graphql-java` version 21 ([#3090](https://github.com/getsentry/sentry-java/pull/3090)) + +### Fixes + +- Avoid concurrency in AndroidProfiler performance data collection ([#3130](https://github.com/getsentry/sentry-java/pull/3130)) +- Improve thresholds for network changes breadcrumbs ([#3083](https://github.com/getsentry/sentry-java/pull/3083)) +- SchedulerFactoryBeanCustomizer now runs first so user customization is not overridden ([#3095](https://github.com/getsentry/sentry-java/pull/3095)) + - If you are setting global job listeners please also add `SentryJobListener` +- Ensure serialVersionUID of Exception classes are unique ([#3115](https://github.com/getsentry/sentry-java/pull/3115)) +- Get rid of "is not eligible for getting processed by all BeanPostProcessors" warnings in Spring Boot ([#3108](https://github.com/getsentry/sentry-java/pull/3108)) +- Fix missing `release` and other fields for ANRs reported with `mechanism:AppExitInfo` ([#3074](https://github.com/getsentry/sentry-java/pull/3074)) + +### Dependencies + +- Bump `opentelemetry-sdk` to `1.33.0` and `opentelemetry-javaagent` to `1.32.0` ([#3112](https://github.com/getsentry/sentry-java/pull/3112)) + +## 7.1.0 + +### Features + +- Support multiple debug-metadata.properties ([#3024](https://github.com/getsentry/sentry-java/pull/3024)) +- Automatically downsample transactions when the system is under load ([#3072](https://github.com/getsentry/sentry-java/pull/3072)) + - You can opt into this behaviour by setting `enable-backpressure-handling=true`. + - We're happy to receive feedback, e.g. [in this GitHub issue](https://github.com/getsentry/sentry-java/issues/2829) + - When the system is under load we start reducing the `tracesSampleRate` automatically. + - Once the system goes back to healthy, we reset the `tracesSampleRate` to its original value. +- (Android) Experimental: Provide more detailed cold app start information ([#3057](https://github.com/getsentry/sentry-java/pull/3057)) + - Attaches spans for Application, ContentProvider, and Activities to app-start timings + - Application and ContentProvider timings are added using bytecode instrumentation, which requires sentry-android-gradle-plugin version `4.1.0` or newer + - Uses Process.startUptimeMillis to calculate app-start timings + - To enable this feature set `options.isEnablePerformanceV2 = true` +- Move slow+frozen frame calculation, as well as frame delay inside SentryFrameMetricsCollector ([#3100](https://github.com/getsentry/sentry-java/pull/3100)) +- Extract Activity Breadcrumbs generation into own Integration ([#3064](https://github.com/getsentry/sentry-java/pull/3064)) + +### Fixes + +- Send breadcrumbs and client error in `SentryOkHttpEventListener` even without transactions ([#3087](https://github.com/getsentry/sentry-java/pull/3087)) +- Keep `io.sentry.exception.SentryHttpClientException` from obfuscation to display proper issue title on Sentry ([#3093](https://github.com/getsentry/sentry-java/pull/3093)) +- (Android) Fix wrong activity transaction duration in case SDK init is deferred ([#3092](https://github.com/getsentry/sentry-java/pull/3092)) + +### Dependencies + +- Bump Gradle from v8.4.0 to v8.5.0 ([#3070](https://github.com/getsentry/sentry-java/pull/3070)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v850) + - [diff](https://github.com/gradle/gradle/compare/v8.4.0...v8.5.0) + +## 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 +- `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 + +## Sentry Self-hosted Compatibility + +This SDK version is compatible with a self-hosted version of Sentry `22.12.0` or higher. If you are using an older version of [self-hosted Sentry](https://develop.sentry.dev/self-hosted/) (aka onpremise), you will need to [upgrade](https://develop.sentry.dev/self-hosted/releases/). If you're using `sentry.io` no action is required. + +## Sentry Integrations Version Compatibility (Android) + +Make sure to align _all_ Sentry dependencies to the same version when bumping the SDK to 7.+, otherwise it will crash at runtime due to binary incompatibility. (E.g. if you're using `-timber`, `-okhttp` or other packages) + +For example, if you're using the [Sentry Android Gradle plugin](https://github.com/getsentry/sentry-android-gradle-plugin) with the `autoInstallation` [feature](https://docs.sentry.io/platforms/android/configuration/gradle/#auto-installation) (enabled by default), make sure to use version 4.+ of the gradle plugin together with version 7.+ of the SDK. If you can't do that for some reason, you can specify sentry version via the plugin config block: + +```kotlin +sentry { + autoInstallation { + sentryVersion.set("7.0.0") + } +} +``` + +Similarly, if you have a Sentry SDK (e.g. `sentry-android-core`) dependency on one of your Gradle modules and you're updating it to 7.+, make sure the Gradle plugin is at 4.+ or specify the SDK version as shown in the snippet above. + +## Breaking Changes + +- Bump min API to 19 ([#2883](https://github.com/getsentry/sentry-java/pull/2883)) +- If you're using `sentry-kotlin-extensions`, it requires `kotlinx-coroutines-core` version `1.6.1` or higher now ([#2838](https://github.com/getsentry/sentry-java/pull/2838)) +- Move enableNdk from SentryOptions to SentryAndroidOptions ([#2793](https://github.com/getsentry/sentry-java/pull/2793)) +- Apollo v2 BeforeSpanCallback now allows returning null ([#2890](https://github.com/getsentry/sentry-java/pull/2890)) +- `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: + +```kotlin +// old +val transaction = Sentry.startTransaction("name", "op", bindToScope = true) +// new +val transaction = Sentry.startTransaction("name", "op", TransactionOptions().apply { isBindToScope = true }) +``` + +## Behavioural Changes + +- 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. +- 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 +- 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 +- 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 +- 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)) + +## Deprecations + +- `sentry-android-okhttp` was deprecated in favour of the new `sentry-okhttp` module. Make sure to replace `io.sentry.android.okhttp` package name with `io.sentry.okhttp` before the next major, where the classes will be removed ([#3005](https://github.com/getsentry/sentry-java/pull/3005)) + +## Other Changes + +### 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` +- 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)) + +### 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 +- 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 +- 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) +- 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)) + +## 6.34.0 + +### Features + +- Add current activity name to app context ([#2999](https://github.com/getsentry/sentry-java/pull/2999)) +- Add `MonitorConfig` param to `CheckInUtils.withCheckIn` ([#3038](https://github.com/getsentry/sentry-java/pull/3038)) + - This makes it easier to automatically create or update (upsert) monitors. +- (Internal) Extract Android Profiler and Measurements for Hybrid SDKs ([#3016](https://github.com/getsentry/sentry-java/pull/3016)) +- (Internal) Remove SentryOptions dependency from AndroidProfiler ([#3051](https://github.com/getsentry/sentry-java/pull/3051)) +- (Internal) Add `readBytesFromFile` for use in Hybrid SDKs ([#3052](https://github.com/getsentry/sentry-java/pull/3052)) +- (Internal) Add `getProguardUuid` for use in Hybrid SDKs ([#3054](https://github.com/getsentry/sentry-java/pull/3054)) + +### 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) +- Ensure DSN uses http/https protocol ([#3044](https://github.com/getsentry/sentry-java/pull/3044)) + +### Dependencies + +- Bump Native SDK from v0.6.6 to v0.6.7 ([#3048](https://github.com/getsentry/sentry-java/pull/3048)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#067) + - [diff](https://github.com/getsentry/sentry-native/compare/0.6.6...0.6.7) + +## 6.33.2-beta.1 + +### 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) + +### Dependencies + +- Bump Native SDK from v0.6.6 to v0.6.7 ([#3048](https://github.com/getsentry/sentry-java/pull/3048)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#067) + - [diff](https://github.com/getsentry/sentry-native/compare/0.6.6...0.6.7) + +## 6.33.1 + +### Fixes + +- Do not register `sentrySpringFilter` in ServletContext for Spring Boot ([#3027](https://github.com/getsentry/sentry-java/pull/3027)) + +## 6.33.0 + +### Features + +- Add thread information to spans ([#2998](https://github.com/getsentry/sentry-java/pull/2998)) +- Use PixelCopy API for capturing screenshots on API level 24+ ([#3008](https://github.com/getsentry/sentry-java/pull/3008)) + +### Fixes + +- Fix crash when HTTP connection error message contains formatting symbols ([#3002](https://github.com/getsentry/sentry-java/pull/3002)) +- Cap max number of stack frames to 100 to not exceed payload size limit ([#3009](https://github.com/getsentry/sentry-java/pull/3009)) + - This will ensure we report errors with a big number of frames such as `StackOverflowError` +- Fix user interaction tracking not working for Jetpack Compose 1.5+ ([#3010](https://github.com/getsentry/sentry-java/pull/3010)) +- Make sure to close all Closeable resources ([#3000](https://github.com/getsentry/sentry-java/pull/3000)) + +## 6.32.0 + +### Features + +- Make `DebugImagesLoader` public ([#2993](https://github.com/getsentry/sentry-java/pull/2993)) + +### Fixes + +- Make `SystemEventsBroadcastReceiver` exported on API 33+ ([#2990](https://github.com/getsentry/sentry-java/pull/2990)) + - This will fix the `SystemEventsBreadcrumbsIntegration` crashes that you might have encountered on Play Console + +## 6.31.0 + +### Features + +- Improve default debouncing mechanism ([#2945](https://github.com/getsentry/sentry-java/pull/2945)) +- Add `CheckInUtils.withCheckIn` which abstracts away some of the manual check-ins complexity ([#2959](https://github.com/getsentry/sentry-java/pull/2959)) +- Add `@SentryCaptureExceptionParameter` annotation which captures exceptions passed into an annotated method ([#2764](https://github.com/getsentry/sentry-java/pull/2764)) + - This can be used to replace `Sentry.captureException` calls in `@ExceptionHandler` of a `@ControllerAdvice` +- Add `ServerWebExchange` to `Hint` for WebFlux as `WEBFLUX_EXCEPTION_HANDLER_EXCHANGE` ([#2977](https://github.com/getsentry/sentry-java/pull/2977)) +- Allow filtering GraphQL errors ([#2967](https://github.com/getsentry/sentry-java/pull/2967)) + - This list can be set directly when calling the constructor of `SentryInstrumentation` + - For Spring Boot it can also be set in `application.properties` as `sentry.graphql.ignored-error-types=SOME_ERROR,ANOTHER_ERROR` + +### Fixes + +- Add OkHttp span auto-close when response body is not read ([#2923](https://github.com/getsentry/sentry-java/pull/2923)) +- Fix json parsing of nullable/empty fields for Hybrid SDKs ([#2968](https://github.com/getsentry/sentry-java/pull/2968)) + - (Internal) Rename `nextList` to `nextListOrNull` to actually match what the method does + - (Hybrid) Check if there's any object in a collection before trying to parse it (which prevents the "Failed to deserilize object in list" log message) + - (Hybrid) If a date can't be parsed as an ISO timestamp, attempts to parse it as millis silently, without printing a log message + - (Hybrid) If `op` is not defined as part of `SpanContext`, fallback to an empty string, because the filed is optional in the spec +- Always attach OkHttp errors and Http Client Errors only to call root span ([#2961](https://github.com/getsentry/sentry-java/pull/2961)) +- Fixed crash accessing Choreographer instance ([#2970](https://github.com/getsentry/sentry-java/pull/2970)) + +### Dependencies + +- Bump Native SDK from v0.6.5 to v0.6.6 ([#2975](https://github.com/getsentry/sentry-java/pull/2975)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#066) + - [diff](https://github.com/getsentry/sentry-native/compare/0.6.5...0.6.6) +- Bump Gradle from v8.3.0 to v8.4.0 ([#2966](https://github.com/getsentry/sentry-java/pull/2966)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v840) + - [diff](https://github.com/gradle/gradle/compare/v8.3.0...v8.4.0) + +## 6.30.0 + +### Features + +- Add `sendModules` option for disable sending modules ([#2926](https://github.com/getsentry/sentry-java/pull/2926)) +- Send `db.system` and `db.name` in span data for androidx.sqlite spans ([#2928](https://github.com/getsentry/sentry-java/pull/2928)) +- Check-ins (CRONS) support ([#2952](https://github.com/getsentry/sentry-java/pull/2952)) + - Add API for sending check-ins (CRONS) manually ([#2935](https://github.com/getsentry/sentry-java/pull/2935)) + - Support check-ins (CRONS) for Quartz ([#2940](https://github.com/getsentry/sentry-java/pull/2940)) + - `@SentryCheckIn` annotation and advice config for Spring ([#2946](https://github.com/getsentry/sentry-java/pull/2946)) + - Add option for ignoring certain monitor slugs ([#2943](https://github.com/getsentry/sentry-java/pull/2943)) + +### Fixes + +- Always send memory stats for transactions ([#2936](https://github.com/getsentry/sentry-java/pull/2936)) + - This makes it possible to query transactions by the `device.class` tag on Sentry +- Add `sentry.enable-aot-compatibility` property to SpringBoot Jakarta `SentryAutoConfiguration` to enable building for GraalVM ([#2915](https://github.com/getsentry/sentry-java/pull/2915)) + +### Dependencies + +- Bump Gradle from v8.2.1 to v8.3.0 ([#2900](https://github.com/getsentry/sentry-java/pull/2900)) + - [changelog](https://github.com/gradle/gradle/blob/master release-test/CHANGELOG.md#v830) + - [diff](https://github.com/gradle/gradle/compare/v8.2.1...v8.3.0) + +## 6.29.0 + +### Features + +- Send `db.system` and `db.name` in span data ([#2894](https://github.com/getsentry/sentry-java/pull/2894)) +- Send `http.request.method` in span data ([#2896](https://github.com/getsentry/sentry-java/pull/2896)) +- Add `enablePrettySerializationOutput` option for opting out of pretty print ([#2871](https://github.com/getsentry/sentry-java/pull/2871)) + +## 6.28.0 + +### Features + +- 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` +- 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 +- You can now disable Sentry by setting the `enabled` option to `false` ([#2840](https://github.com/getsentry/sentry-java/pull/2840)) + +### Fixes + +- Propagate OkHttp status to parent spans ([#2872](https://github.com/getsentry/sentry-java/pull/2872)) + +## 6.27.0 + +### Features + +- Add TraceOrigin to Transactions and Spans ([#2803](https://github.com/getsentry/sentry-java/pull/2803)) + +### Fixes + +- Deduplicate events happening in multiple threads simultaneously (e.g. `OutOfMemoryError`) ([#2845](https://github.com/getsentry/sentry-java/pull/2845)) + - This will improve Crash-Free Session Rate as we no longer will send multiple Session updates with `Crashed` status, but only the one that is relevant +- Ensure no Java 8 method reference sugar is used for Android ([#2857](https://github.com/getsentry/sentry-java/pull/2857)) +- Do not send session updates for terminated sessions ([#2849](https://github.com/getsentry/sentry-java/pull/2849)) + +## 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 + +- Fix ANRv2 thread dump parsing for native-only threads ([#2839](https://github.com/getsentry/sentry-java/pull/2839)) +- Derive `TracingContext` values from event for ANRv2 events ([#2839](https://github.com/getsentry/sentry-java/pull/2839)) + +## 6.25.2 + +### Fixes + +- Change Spring Boot, Apollo, Apollo 3, JUL, Logback, Log4j2, OpenFeign, GraphQL and Kotlin coroutines core dependencies to compileOnly ([#2837](https://github.com/getsentry/sentry-java/pull/2837)) + ## 6.25.1 ### Fixes @@ -47,13 +3207,13 @@ - 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 @@ -69,7 +3229,7 @@ import io.sentry.apollo3.sentryTracing val apolloClient = ApolloClient.Builder() .serverUrl("https://example.com/graphql") - .sentryTracing(captureFailedRequests = true) + .sentryTracing(captureFailedRequests = true) .build() ``` @@ -100,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 @@ -125,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 @@ -154,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) @@ -198,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)) @@ -211,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) @@ -225,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)) @@ -263,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 @@ -288,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)) @@ -317,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 @@ -553,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 @@ -614,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)) @@ -648,6 +3808,10 @@ val apolloClient = ApolloClient.Builder() - New package `sentry-compose` for Jetpack Compose support (Navigation) ([#2136](https://github.com/getsentry/sentry-java/pull/2136)) - 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 ### Fixes @@ -709,19 +3873,19 @@ val apolloClient = ApolloClient.Builder() - 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 @@ -763,7 +3927,7 @@ val apolloClient = ApolloClient.Builder() ### 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 @@ -1157,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)) @@ -1316,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)) @@ -1469,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 @@ -1590,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 @@ -1629,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 @@ -1637,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 @@ -1648,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/) @@ -1732,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 -> { @@ -1754,7 +4917,7 @@ SentryAndroid.init(this, options -> { }); ``` -4) Use the Timber integration: +4. Use the Timber integration: ```java try { @@ -2043,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 @@ -2105,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 @@ -2143,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 @@ -2244,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/) @@ -2274,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 new file mode 100644 index 00000000000..f59e5a152f3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +# CLAUDE.md + +## STOP — Required Reading (Do This First) + +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 375f5cdc3ed..f4354c72a89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,49 @@ To run the build and tests: make compile ``` +# Format + +To format the changed code and make CI happy you can run: + +```shell +make format +``` + +or + +```shell +./gradlew spotlessApply +``` + +# Binary compatibility validation + +To prevent breaking ABI changes and exposing things we should not, we make use of https://github.com/Kotlin/binary-compatibility-validator. If your change intended to introduce a new public method/property or modify the existing one you can overwrite the API declarations to make CI happy as follows (overwrites them from scratch): + +```shell +make api +``` + +or + +```shell +./gradlew apiDump +``` + +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 f66cfce7757..3967ff856ad 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,16 @@ -.PHONY: all clean compile javadocs dryRelease update stop checkFormat format api assembleBenchmarkTestRelease assembleUiTestRelease +.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleBenchmarkTestRelease assembleUiTestRelease assembleUiTestCriticalRelease runUiTestCritical setupPython systemTest systemTestInteractive check preMerge publish all: stop clean javadocs compile -assembleBenchmarks: stop clean assembleBenchmarkTestRelease -assembleUiTests: stop clean assembleUiTestRelease +assembleBenchmarks: assembleBenchmarkTestRelease +assembleUiTests: assembleUiTestRelease +preMerge: check +publish: clean dryRelease # deep clean clean: - ./gradlew clean + ./gradlew clean --no-configuration-cache rm -rf distributions + rm -rf .venv # build and run tests compile: @@ -18,35 +21,50 @@ javadocs: # do a dry release (like a local deploy) dryRelease: - ./gradlew aggregateJavadocs publishToMavenLocal --no-daemon --no-parallel + ./gradlew aggregateJavadocs distZip --no-build-cache --no-configuration-cache # check for dependencies update update: ./gradlew dependencyUpdates -Drevision=release -# We stop gradle at the end to make sure the cache folders -# don't contain any lock files and are free to be cached. -stop: - ./gradlew --stop - # Spotless check's code checkFormat: ./gradlew spotlessJavaCheck spotlessKotlinCheck -# Spotless format's code -format: - ./gradlew spotlessApply - # Binary compatibility validator api: ./gradlew apiDump # 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: + ./gradlew :sentry-android-integration-tests:sentry-uitest-android-critical:assembleRelease + +# Run Maestro tests for the uitest-android-critical module +runUiTestCritical: + ./scripts/test-ui-critical.sh + +# Create the Python virtual environment for system tests, and install the necessary dependencies +setupPython: + @test -d .venv || python3 -m venv .venv + .venv/bin/pip install --upgrade pip + .venv/bin/pip install -r requirements.txt + +# Run system tests for sample applications +systemTest: setupPython + .venv/bin/python test/system-test-runner.py test --all + +# Run system tests with interactive module selection +systemTestInteractive: setupPython + .venv/bin/python test/system-test-runner.py test --interactive + +# Run tests and lint +check: + ./gradlew check diff --git a/README.md b/README.md index 2ba982923c4..849aaf74457 100644 --- a/README.md +++ b/README.md @@ -13,48 +13,77 @@ _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 | Android API | -|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------- | -| 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) | 16 | -| 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) | 14 | -| 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) | 16 | -| sentry-android-okhttp | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-okhttp/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-okhttp) | 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) | 14 | -| 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) | 14 | -| 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) | 14 | -| 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) | 14 | -| 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) | 14 | -| 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) | 14 | -| 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) | 14 | -| 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) | 14 | -| 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-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-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-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) | - - +| 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 + +This repo uses the following ways to release SDK updates: + +- `Pre-release`: We create pre-releases (alpha, beta, RC,…) for larger and potentially more impactful changes, such as new features or major versions. +- `Latest`: We continuously release major/minor/hotfix versions from the `main` branch. These releases go through all our internal quality gates and are very safe to use and intended to be the default for most teams. +- `Stable`: We promote releases from `Latest` when they have been used in the field for some time and in scale, considering time since release, adoption, and other quality and stability metrics. These releases will be indicated on the releases page (https://github.com/getsentry/sentry-java/releases/) with the `Stable` suffix. # Useful links and docs +* A deep dive into how we built [Session Replay for Android](https://www.droidcon.com/2024/11/22/rewind-and-resolve-a-deep-dive-into-building-session-replay-for-android/) at Droidcon London 2024. * Current Javadocs [generated from source code](https://getsentry.github.io/sentry-java/). * Java SDK version 1.x [can still be found here](https://docs.sentry.io/clients/java/). * Migration page from [sentry-android 1.x and 2.x to sentry-android 4.x](https://docs.sentry.io/platforms/android/migration/). @@ -85,23 +114,14 @@ Sentry SDK for Java and Android * [Sample App. with Sentry Java SDK](https://github.com/getsentry/examples/tree/master/java). * [Sample for Development](https://github.com/getsentry/sentry-java/tree/main/sentry-samples). -# Development - -This repository includes [`sentry-native`](https://github.com/getsentry/sentry-native/) as a git submodule. -To build against `sentry-native` checked-out elsewhere in your file system, create a symlink `sentry-android-ndk/sentry-native-local` that points to your `sentry-native` directory. -For example, if you had `sentry-native` checked-out in a sibling directory to this repo: - -`ln -s ../../sentry-native sentry-android-ndk/sentry-native-local` - -which will be picked up by `gradle` and used instead of the git submodule. -This directory is also included in `.gitignore` not to be shown as pending changes. - # Sentry Self Hosted Compatibility Since version 3.0.0 of this SDK, Sentry version >= v20.6.0 is required. This only applies to self-hosted Sentry, if you are using [sentry.io](http://sentry.io/) no action is needed. Since version 6.0.0 of this SDK, Sentry version >= v21.9.0 is required or you have to manually disable sending client reports via the `sendClientReports` option. This only applies to self-hosted Sentry, if you are using [sentry.io](http://sentry.io/) no action is needed. +Since version 7.0.0 of this SDK, Sentry version >= 22.12.0 is required to properly ingest transactions with unfinished spans. This only applies to self-hosted Sentry, if you are using [sentry.io](http://sentry.io/) no action is needed. + # Resources * [![Java Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg?label=java%20docs)](https://docs.sentry.io/platforms/java/) 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 new file mode 100644 index 00000000000..bba758f9b79 --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + `kotlin-dsl` +} + +repositories { + gradlePluginPortal() +} + +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/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 00000000000..aa5e146f1c7 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,9 @@ +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" 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 new file mode 100644 index 00000000000..8fde556d751 --- /dev/null +++ b/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts @@ -0,0 +1,27 @@ +import io.sentry.gradle.AggregateJavadoc +import org.gradle.api.attributes.Category +import org.gradle.api.attributes.LibraryElements +import org.gradle.kotlin.dsl.named + +val javadocPublisher = configurations.create("javadocPublisher") { + isCanBeConsumed = false + isCanBeResolved = true + attributes { + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.DOCUMENTATION)) + attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named("javadoc")) + } +} + +subprojects { + javadocPublisher.dependencies.add(rootProject.dependencies.project(path)) +} + +val javadocCollection = javadocPublisher.incoming.artifactView { lenient(true) }.files + +tasks.register("aggregateJavadocs", AggregateJavadoc::class) { + group = "documentation" + description = "Aggregates Javadocs from all subprojects into a single directory." + javadocFiles.set(javadocCollection) + rootDir.set(layout.projectDirectory) + outputDir.set(layout.buildDirectory.dir("docs/javadoc")) +} diff --git a/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts b/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts new file mode 100644 index 00000000000..21f81fec36a --- /dev/null +++ b/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts @@ -0,0 +1,27 @@ +val javadocConfig: Configuration = configurations.create("javadocConfig") { + isCanBeResolved = false + isCanBeConsumed = true + + attributes { + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.DOCUMENTATION)) + attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named("javadoc")) + } +} + +tasks.withType().configureEach { + setDestinationDir(project.layout.buildDirectory.file("docs/javadoc").get().asFile) + title = "${project.name} $version API" + val opts = options as StandardJavadocDocletOptions + opts.quiet() + opts.encoding = "UTF-8" + opts.memberLevel = JavadocMemberLevel.PROTECTED + opts.links = listOf( + "https://docs.oracle.com/javase/8/docs/api/", + "https://docs.spring.io/spring-framework/docs/current/javadoc-api/", + "https://docs.spring.io/spring-boot/docs/current/api/" + ) +} + +artifacts { + add(javadocConfig.name, tasks.named("javadoc")) +} diff --git a/build-logic/src/main/kotlin/io.sentry.spotless.gradle.kts b/build-logic/src/main/kotlin/io.sentry.spotless.gradle.kts new file mode 100644 index 00000000000..9b53fd8a4cd --- /dev/null +++ b/build-logic/src/main/kotlin/io.sentry.spotless.gradle.kts @@ -0,0 +1,25 @@ +import com.diffplug.spotless.LineEnding + +plugins { + id("com.diffplug.spotless") +} + +spotless { + lineEndings = LineEnding.UNIX + java { + target("src/*/java/**/*.java") + removeUnusedImports() + googleJavaFormat() + targetExclude("src/**/java/io/sentry/vendor/**") + } + kotlin { + target("src/*/kotlin/**/*.kt", "src/*/java/**/*.kt") + ktfmt().googleStyle() + targetExclude("src/test/java/io/sentry/apollo4/generated/**", "src/test/java/io/sentry/apollo3/adapter/**") + } + kotlinGradle { + target("*.gradle.kts") + ktfmt().googleStyle() + } +} + 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/AggregateJavadoc.kt b/build-logic/src/main/kotlin/io/sentry/gradle/AggregateJavadoc.kt new file mode 100644 index 00000000000..f6b9ec6a0ff --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/AggregateJavadoc.kt @@ -0,0 +1,41 @@ +package io.sentry.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileCollection +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.provider.Property +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import javax.inject.Inject + +abstract class AggregateJavadoc @Inject constructor( + @get:Internal val fs: FileSystemOperations +) : DefaultTask() { + @get:InputFiles + abstract val javadocFiles: Property + + // Marked as Internal since this is only used to relativize the paths for the output directories + @get:Internal + abstract val rootDir: DirectoryProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun aggregate() { + javadocFiles.get().forEach { file -> + fs.copy { + // Get the relative path of the project directory to the root directory + val relativePath = file.relativeTo(rootDir.get().asFile) + // Remove the 'build/docs/javadoc' part from the path + val projectPath = relativePath.path.replace("build/docs/javadoc", "") + from(file) + // Use the project name as the output directory name so that each javadoc goes into its own directory + into(outputDir.get().file(projectPath)) + } + } + } +} 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 8c4cd4fd386..a663628b467 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,6 @@ -import com.diffplug.spotless.LineEnding +import com.vanniktech.maven.publish.JavaLibrary +import com.vanniktech.maven.publish.JavadocJar import com.vanniktech.maven.publish.MavenPublishBaseExtension -import com.vanniktech.maven.publish.MavenPublishPlugin -import com.vanniktech.maven.publish.MavenPublishPluginExtension import groovy.util.Node import io.gitlab.arturbosch.detekt.extensions.DetektExtension import org.gradle.api.tasks.testing.logging.TestExceptionFormat @@ -9,11 +8,25 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { `java-library` - id(Config.QualityPlugins.spotless) version Config.QualityPlugins.spotlessVersion apply true - jacoco - id(Config.QualityPlugins.detekt) version Config.QualityPlugins.detektVersion + alias(libs.plugins.spotless) apply false + alias(libs.plugins.detekt) `maven-publish` - id(Config.QualityPlugins.binaryCompatibilityValidator) version Config.QualityPlugins.binaryCompatibilityValidatorVersion + alias(libs.plugins.binary.compatibility.validator) + alias(libs.plugins.vanniktech.maven.publish) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.spring) apply false + alias(libs.plugins.buildconfig) apply false + // dokka is required by gradle-maven-publish-plugin. + alias(libs.plugins.dokka) apply false + alias(libs.plugins.dokka.javadoc) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.errorprone) apply false + 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 { @@ -22,22 +35,11 @@ buildscript { } dependencies { classpath(Config.BuildPlugins.androidGradle) - classpath(kotlin(Config.BuildPlugins.kotlinGradlePlugin, version = Config.kotlinVersion)) - classpath(Config.BuildPlugins.gradleMavenPublishPlugin) - // dokka is required by gradle-maven-publish-plugin. - classpath(Config.BuildPlugins.dokkaPlugin) - classpath(Config.QualityPlugins.errorpronePlugin) - classpath(Config.QualityPlugins.gradleVersionsPlugin) - - // add classpath of androidNativeBundle - // com.ydq.android.gradle.build.tool:nativeBundle:{version}} - classpath(Config.NativePlugins.nativeBundlePlugin) // add classpath of sentry android gradle plugin // classpath("io.sentry:sentry-android-gradle-plugin:{version}") - classpath(Config.QualityPlugins.binaryCompatibilityValidatorPlugin) - classpath(Config.BuildPlugins.composeGradlePlugin) + classpath(libs.commons.compress) } } @@ -51,6 +53,7 @@ apiValidation { listOf( "sentry-samples-android", "sentry-samples-console", + "sentry-samples-console-opentelemetry-noagent", "sentry-samples-jul", "sentry-samples-log4j2", "sentry-samples-logback", @@ -58,29 +61,41 @@ apiValidation { "sentry-samples-servlet", "sentry-samples-spring", "sentry-samples-spring-jakarta", + "sentry-samples-spring-7", "sentry-samples-spring-boot", + "sentry-samples-spring-boot-opentelemetry", + "sentry-samples-spring-boot-opentelemetry-noagent", "sentry-samples-spring-boot-jakarta", + "sentry-samples-spring-boot-jakarta-opentelemetry", + "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "sentry-samples-spring-boot-webflux", "sentry-samples-spring-boot-webflux-jakarta", - "sentry-samples-netflix-dgs", + "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", "sentry-uitest-android-benchmark", + "sentry-uitest-android-critical", "test-app-plain", - "test-app-sentry" + "test-app-sentry", + "test-app-size", + "sentry-samples-netflix-dgs", + "sentry-samples-console-otlp", + "sentry-test-support", + "sentry-system-test-support" ) ) } allprojects { - repositories { - google() - mavenCentral() - } group = Config.Sentry.group - version = properties[Config.Sentry.versionNameProp].toString() + version = providers.gradleProperty(Config.Sentry.versionNameProp).get() description = Config.Sentry.description tasks { - withType { + withType().configureEach { testLogging.showStandardStreams = true testLogging.exceptionFormat = TestExceptionFormat.FULL testLogging.events = setOf( @@ -88,15 +103,16 @@ allprojects { TestLogEvent.PASSED, TestLogEvent.FAILED ) - dependsOn("cleanTest") } - withType { - options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing")) + withType().configureEach { + options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing", "-Xlint:-try", "-Xlint:-options")) } } } subprojects { + apply { plugin("io.sentry.spotless") } + plugins.withId(Config.QualityPlugins.detektPlugin) { configure { buildUponDefaultConfig = true @@ -105,8 +121,9 @@ subprojects { } } - if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-test-support" && this.name != "sentry-compose-helper") { + if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-system-test-support" && this.name != "sentry-test-support") { apply() + apply() val sep = File.separator @@ -114,48 +131,65 @@ subprojects { if (this@subprojects.name.contains("-compose")) { this.configureForMultiplatform(this@subprojects) } else { - this.getByName("main").contents { - // non android modules - from("build${sep}libs") - from("build${sep}publications${sep}maven") - // android modules - from("build${sep}outputs${sep}aar") { - include("*-release*") - } - from("build${sep}publications${sep}release") - } + this.configureForJvm(this@subprojects) } // craft only uses zip archives this.forEach { dist -> if (dist.name == DistributionPlugin.MAIN_DISTRIBUTION_NAME) { - tasks.getByName("distTar").enabled = false + tasks.named("distTar").configure { enabled = false } } else { - tasks.getByName(dist.name + "DistTar").enabled = false + tasks.named(dist.name + "DistTar").configure { enabled = false } } } } tasks.named("distZip").configure { this.dependsOn("publishToMavenLocal") + val file = this.project.layout.buildDirectory.file("distributions${sep}${this.project.name}-${this.project.version}.zip").get().asFile this.doLast { - val distributionFilePath = - "${this.project.buildDir}${sep}distributions${sep}${this.project.name}-${this.project.version}.zip" - val file = File(distributionFilePath) - if (!file.exists()) throw IllegalStateException("Distribution file: $distributionFilePath does not exist") - if (file.length() == 0L) throw IllegalStateException("Distribution file: $distributionFilePath is empty") + if (!file.exists()) throw IllegalStateException("Distribution file: ${file.absolutePath} does not exist") + if (file.length() == 0L) throw IllegalStateException("Distribution file: ${file.absolutePath} is empty") } } - afterEvaluate { - apply() + plugins.withId("java-library") { + configure { + // we have to disable javadoc publication in maven-publish plugin as it's not + // including it in the .module file https://github.com/vanniktech/gradle-maven-publish-plugin/issues/861 + // and do it ourselves + configure(JavaLibrary(JavadocJar.None(), sourcesJar = true)) + } + + configure { + withJavadocJar() - configure { - // signing is done when uploading files to MC - // via gpg:sign-and-deploy-file (release.kts) - releaseSigningEnabled = false + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + } + + // 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() - @Suppress("UnstableApiUsage") + afterEvaluate { configure { assignAarTypes() } @@ -165,7 +199,7 @@ subprojects { repositories { maven { name = "unityMaven" - url = file("${rootProject.buildDir}/unityMaven").toURI() + url = rootProject.layout.buildDirectory.file("unityMaven").get().asFile.toURI() } } } @@ -180,75 +214,27 @@ subprojects { } } -spotless { - lineEndings = LineEnding.UNIX - java { - target("**/*.java") - removeUnusedImports() - googleJavaFormat() - targetExclude("**/generated/**", "**/vendor/**") - } - kotlin { - target("**/*.kt") - ktlint() - targetExclude("**/sentry-native/**") - } - kotlinGradle { - target("**/*.kts") - ktlint() - targetExclude("**/sentry-native/**") - } -} - -gradle.projectsEvaluated { - tasks.create("aggregateJavadocs", Javadoc::class.java) { - setDestinationDir(file("$buildDir/docs/javadoc")) - title = "${project.name} $version API" - val opts = options as StandardJavadocDocletOptions - opts.quiet() - opts.encoding = "UTF-8" - opts.memberLevel = JavadocMemberLevel.PROTECTED - opts.stylesheetFile(file("$projectDir/docs/stylesheet.css")) - opts.links = listOf( - "https://docs.oracle.com/javase/8/docs/api/", - "https://docs.spring.io/spring-framework/docs/current/javadoc-api/", - "https://docs.spring.io/spring-boot/docs/current/api/" - ) - subprojects - .filter { !it.name.contains("sample") && !it.name.contains("integration-tests") } - .forEach { proj -> - proj.tasks.withType().forEach { javadocTask -> - source += javadocTask.source - classpath += javadocTask.classpath - excludes += javadocTask.excludes - includes += javadocTask.includes +tasks.register("buildForCodeQL") { + subprojects + .filter { + !it.displayName.contains("sample") && + !it.displayName.contains("integration-tests") && + !it.displayName.contains("bom") && + it.name != "sentry-opentelemetry" + } + .forEach { proj -> + if (proj.plugins.hasPlugin("com.android.library")) { + proj.tasks.findByName("compileReleaseUnitTestSources")?.let { testTask -> + this.dependsOn(testTask) + } + } else { + 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) - } + } } -private val androidLibs = setOf( - "sentry-android-core", - "sentry-android-ndk", - "sentry-android-fragment", - "sentry-android-navigation", - "sentry-android-okhttp", - "sentry-android-timber", - "sentry-compose-android" -) - -private val androidXLibs = listOf( - "androidx.core:core" -) - /* * Adapted from https://github.com/androidx/androidx/blob/c799cba927a71f01ea6b421a8f83c181682633fb/buildSrc/private/src/main/kotlin/androidx/build/MavenUploadHelper.kt#L524-L549 * @@ -268,7 +254,6 @@ private val androidXLibs = listOf( */ // Workaround for https://github.com/gradle/gradle/issues/3170 -@Suppress("UnstableApiUsage") fun MavenPublishBaseExtension.assignAarTypes() { pom { withXml { @@ -290,9 +275,9 @@ fun MavenPublishBaseExtension.assignAarTypes() { } as? Node val artifactIdValue = artifactId?.children()?.firstOrNull() as? String - if (artifactIdValue in androidLibs) { + if (artifactIdValue in Config.BuildScript.androidLibs) { dep.appendNode("type", "aar") - } else if ("$groupValue:$artifactIdValue" in androidXLibs) { + } else if ("$groupValue:$artifactIdValue" in Config.BuildScript.androidXLibs) { dep.appendNode("type", "aar") } } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 5d8cbb335fc..451e5827ed9 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -9,5 +9,5 @@ repositories { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 00000000000..12b905adc13 --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "sentry-buildSrc" diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 6d54ef9296a..09d2869988b 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -1,157 +1,34 @@ -import java.math.BigDecimal object Config { - val AGP = System.getenv("VERSION_AGP") ?: "7.4.2" - val kotlinVersion = "1.8.0" + val AGP = System.getenv("VERSION_AGP") ?: "9.2.1" val kotlinStdLib = "stdlib-jdk8" - - val springBootVersion = "2.7.5" - val springBoot3Version = "3.0.3" - val kotlinCompatibleLanguageVersion = "1.4" - - val composeVersion = "1.3.0" - val androidComposeCompilerVersion = "1.4.0" + val kotlinStdLibVersionAndroid = "1.9.24" + val kotlinTestJunit = "test-junit" object BuildPlugins { val androidGradle = "com.android.tools.build:gradle:$AGP" - val kotlinGradlePlugin = "gradle-plugin" - val buildConfig = "com.github.gmazzo.buildconfig" - val buildConfigVersion = "3.0.3" - val springBoot = "org.springframework.boot" - val springDependencyManagement = "io.spring.dependency-management" - val springDependencyManagementVersion = "1.0.11.RELEASE" - val gretty = "org.gretty" - val grettyVersion = "4.0.0" - val gradleMavenPublishPlugin = "com.vanniktech:gradle-maven-publish-plugin:0.18.0" - val dokkaPlugin = "org.jetbrains.dokka:dokka-gradle-plugin:1.7.10" - val dokkaPluginAlias = "org.jetbrains.dokka" - val composeGradlePlugin = "org.jetbrains.compose:compose-gradle-plugin:$composeVersion" } object Android { - private val sdkVersion = 33 - - val minSdkVersion = 14 - val minSdkVersionOkHttp = 21 - val minSdkVersionNdk = 16 - val minSdkVersionCompose = 21 - val targetSdkVersion = sdkVersion - val compileSdkVersion = sdkVersion - val abiFilters = listOf("x86", "armeabi-v7a", "x86_64", "arm64-v8a") - fun shouldSkipDebugVariant(name: String): Boolean { - return System.getenv("CI")?.toBoolean() ?: false && name == "debug" + // 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 name == "debug" } } object Libs { - val okHttpVersion = "4.9.2" - val appCompat = "androidx.appcompat:appcompat:1.3.0" - val timber = "com.jakewharton.timber:timber:4.7.1" - val okhttp = "com.squareup.okhttp3:okhttp:$okHttpVersion" - val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.8.1" - val constraintLayout = "androidx.constraintlayout:constraintlayout:2.1.3" - - private val lifecycleVersion = "2.2.0" - val lifecycleProcess = "androidx.lifecycle:lifecycle-process:$lifecycleVersion" - val lifecycleCommonJava8 = "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" - val androidxCore = "androidx.core:core:1.3.2" - val androidxSqlite = "androidx.sqlite:sqlite:2.3.1" - val androidxRecylerView = "androidx.recyclerview:recyclerview:1.2.1" - - val slf4jApi = "org.slf4j:slf4j-api:1.7.30" - val slf4jApi2 = "org.slf4j:slf4j-api:2.0.5" - val slf4jJdk14 = "org.slf4j:slf4j-jdk14:1.7.30" - val logbackVersion = "1.2.9" - val logbackClassic = "ch.qos.logback:logback-classic:$logbackVersion" - - val log4j2Version = "2.20.0" - val log4j2Api = "org.apache.logging.log4j:log4j-api:$log4j2Version" - val log4j2Core = "org.apache.logging.log4j:log4j-core:$log4j2Version" - - val jacksonDatabind = "com.fasterxml.jackson.core:jackson-databind" - - val springBootStarter = "org.springframework.boot:spring-boot-starter:$springBootVersion" - val springBootStarterTest = "org.springframework.boot:spring-boot-starter-test:$springBootVersion" - val springBootStarterWeb = "org.springframework.boot:spring-boot-starter-web:$springBootVersion" - val springBootStarterWebflux = "org.springframework.boot:spring-boot-starter-webflux:$springBootVersion" - val springBootStarterAop = "org.springframework.boot:spring-boot-starter-aop:$springBootVersion" - val springBootStarterSecurity = "org.springframework.boot:spring-boot-starter-security:$springBootVersion" - val springBootStarterJdbc = "org.springframework.boot:spring-boot-starter-jdbc:$springBootVersion" - - val springBoot3Starter = "org.springframework.boot:spring-boot-starter:$springBoot3Version" - val springBoot3StarterTest = "org.springframework.boot:spring-boot-starter-test:$springBoot3Version" - val springBoot3StarterWeb = "org.springframework.boot:spring-boot-starter-web:$springBoot3Version" - val springBoot3StarterWebflux = "org.springframework.boot:spring-boot-starter-webflux:$springBoot3Version" - val springBoot3StarterAop = "org.springframework.boot:spring-boot-starter-aop:$springBoot3Version" - val springBoot3StarterSecurity = "org.springframework.boot:spring-boot-starter-security:$springBoot3Version" - val springBoot3StarterJdbc = "org.springframework.boot:spring-boot-starter-jdbc:$springBoot3Version" - val springWeb = "org.springframework:spring-webmvc" val springWebflux = "org.springframework:spring-webflux" val springSecurityWeb = "org.springframework.security:spring-security-web" val springSecurityConfig = "org.springframework.security:spring-security-config" val springAop = "org.springframework:spring-aop" val aspectj = "org.aspectj:aspectjweaver" - val servletApi = "javax.servlet:javax.servlet-api:3.1.0" - val servletApiJakarta = "jakarta.servlet:jakarta.servlet-api:5.0.0" - - val apacheHttpClient = "org.apache.httpcomponents.client5:httpclient5:5.0.4" - - private val retrofit2Version = "2.9.0" - private val retrofit2Group = "com.squareup.retrofit2" - val retrofit2 = "$retrofit2Group:retrofit:$retrofit2Version" - val retrofit2Gson = "$retrofit2Group:converter-gson:$retrofit2Version" - - val coroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3" - - val fragment = "androidx.fragment:fragment-ktx:1.3.5" - - val reactorCore = "io.projectreactor:reactor-core:3.5.3" - val contextPropagation = "io.micrometer:context-propagation:1.0.2" - - private val feignVersion = "11.6" - val feignCore = "io.github.openfeign:feign-core:$feignVersion" - val feignGson = "io.github.openfeign:feign-gson:$feignVersion" - - private val apolloVersion = "2.5.9" - val apolloAndroid = "com.apollographql.apollo:apollo-runtime:$apolloVersion" - val apolloCoroutines = "com.apollographql.apollo:apollo-coroutines-support:$apolloVersion" - - val p6spy = "p6spy:p6spy:3.9.1" - - val graphQlJava = "com.graphql-java:graphql-java:17.3" val kotlinReflect = "org.jetbrains.kotlin:kotlin-reflect" val kotlinStdLib = "org.jetbrains.kotlin:kotlin-stdlib" - - private val navigationVersion = "2.4.2" - val navigationRuntime = "androidx.navigation:navigation-runtime:$navigationVersion" - - // compose deps - val composeNavigation = "androidx.navigation:navigation-compose:$navigationVersion" - val composeActivity = "androidx.activity:activity-compose:1.4.0" - val composeFoundation = "androidx.compose.foundation:foundation:$composeVersion" - val composeUi = "androidx.compose.ui:ui:$composeVersion" - val composeFoundationLayout = "androidx.compose.foundation:foundation-layout:$composeVersion" - val composeMaterial = "androidx.compose.material3:material3:1.0.0-alpha13" - - val apolloKotlin = "com.apollographql.apollo3:apollo-runtime:3.3.0" - - object OpenTelemetry { - val otelVersion = "1.23.1" - val otelAlphaVersion = "$otelVersion-alpha" - val otelJavaagentVersion = "1.23.0" - val otelJavaagentAlphaVersion = "$otelJavaagentVersion-alpha" - - val otelSdk = "io.opentelemetry:opentelemetry-sdk:$otelVersion" - val otelSemconv = "io.opentelemetry:opentelemetry-semconv:$otelAlphaVersion" - val otelJavaAgent = "io.opentelemetry.javaagent:opentelemetry-javaagent:$otelJavaagentVersion" - val otelJavaAgentExtensionApi = "io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api:$otelJavaagentAlphaVersion" - val otelJavaAgentTooling = "io.opentelemetry.javaagent:opentelemetry-javaagent-tooling:$otelJavaagentAlphaVersion" - val otelExtensionAutoconfigureSpi = "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:$otelVersion" - } } object AnnotationProcessors { @@ -159,48 +36,9 @@ object Config { val springBootConfiguration = "org.springframework.boot:spring-boot-configuration-processor" } - object TestLibs { - private val androidxTestVersion = "1.5.0" - private val espressoVersion = "3.5.0" - - val androidJUnitRunner = "androidx.test.runner.AndroidJUnitRunner" - val kotlinTestJunit = "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" - val androidxCore = "androidx.test:core:$androidxTestVersion" - val androidxRunner = "androidx.test:runner:$androidxTestVersion" - val androidxTestCoreKtx = "androidx.test:core-ktx:$androidxTestVersion" - val androidxTestRules = "androidx.test:rules:$androidxTestVersion" - val espressoCore = "androidx.test.espresso:espresso-core:$espressoVersion" - val espressoIdlingResource = "androidx.test.espresso:espresso-idling-resource:$espressoVersion" - val androidxTestOrchestrator = "androidx.test:orchestrator:1.4.2" - val androidxJunit = "androidx.test.ext:junit:1.1.3" - val androidxCoreKtx = "androidx.core:core-ktx:1.7.0" - val robolectric = "org.robolectric:robolectric:4.7.3" - val mockitoKotlin = "org.mockito.kotlin:mockito-kotlin:4.0.0" - val mockitoInline = "org.mockito:mockito-inline:4.8.0" - val awaitility = "org.awaitility:awaitility-kotlin:4.1.1" - val mockWebserver = "com.squareup.okhttp3:mockwebserver:${Libs.okHttpVersion}" - val jsonUnit = "net.javacrumbs.json-unit:json-unit:2.32.0" - val hsqldb = "org.hsqldb:hsqldb:2.6.1" - val javaFaker = "com.github.javafaker:javafaker:1.0.2" - } - object QualityPlugins { - object Jacoco { - val version = "0.8.7" - val minimumCoverage = BigDecimal.valueOf(0.6) - } - val spotless = "com.diffplug.spotless" - val spotlessVersion = "6.11.0" - val errorProne = "net.ltgt.errorprone" - val errorpronePlugin = "net.ltgt.gradle:gradle-errorprone-plugin:3.0.1" - val gradleVersionsPlugin = "com.github.ben-manes:gradle-versions-plugin:0.42.0" - val gradleVersions = "com.github.ben-manes.versions" - val detekt = "io.gitlab.arturbosch.detekt" - val detektVersion = "1.19.0" + // 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" - val binaryCompatibilityValidatorVersion = "0.13.0" - val binaryCompatibilityValidatorPlugin = "org.jetbrains.kotlinx:binary-compatibility-validator:$binaryCompatibilityValidatorVersion" - val binaryCompatibilityValidator = "org.jetbrains.kotlinx.binary-compatibility-validator" } object Sentry { @@ -212,33 +50,62 @@ object Config { val SENTRY_LOG4J2_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.log4j2" val SENTRY_SPRING_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring" val SENTRY_SPRING_JAKARTA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring.jakarta" + val SENTRY_SPRING_7_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-7" val SENTRY_SPRING_BOOT_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot" + val SENTRY_SPRING_BOOT_STARTER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot-starter" val SENTRY_SPRING_BOOT_JAKARTA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot.jakarta" + val SENTRY_SPRING_BOOT_STARTER_JAKARTA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot-starter.jakarta" + val SENTRY_SPRING_BOOT_4_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot-4" + 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" + val SENTRY_OPENTELEMETRY_AGENTCUSTOMIZATION_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.agentcustomization" + val SENTRY_OPENFEIGN_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.openfeign" val SENTRY_APOLLO3_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo3" + val SENTRY_APOLLO4_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo4" val SENTRY_APOLLO_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo" 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" + val SENTRY_OKHTTP_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.okhttp" + val SENTRY_REACTOR_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.reactor" + val SENTRY_KOTLIN_EXTENSIONS_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.kotlin-extensions" + val SENTRY_ASYNC_PROFILER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.async-profiler" + val SENTRY_KTOR_CLIENT_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.ktor-client" val group = "io.sentry" val description = "SDK for sentry.io" val versionNameProp = "versionName" } - object CompileOnly { - private val nopenVersion = "1.0.1" - - val jetbrainsAnnotations = "org.jetbrains:annotations:23.0.0" - val nopen = "com.jakewharton.nopen:nopen-annotations:$nopenVersion" - val nopenChecker = "com.jakewharton.nopen:nopen-checker:$nopenVersion" - val errorprone = "com.google.errorprone:error_prone_core:2.11.0" - val errorProneNullAway = "com.uber.nullaway:nullaway:0.9.5" - } - - object NativePlugins { - val nativeBundlePlugin = "io.github.howardpang:androidNativeBundle:1.1.1" - val nativeBundleExport = "com.ydq.android.gradle.native-aar.export" + object BuildScript { + val androidLibs = setOf( + "sentry-android-core", + "sentry-android-ndk", + "sentry-android-fragment", + "sentry-android-navigation", + "sentry-android-timber", + "sentry-compose-android", + "sentry-android-sqlite", + "sentry-android-replay" + ) + + val androidXLibs = listOf( + "androidx.core:core" + ) } } 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 1362e96522a..d545e6e32dc 100644 --- a/buildSrc/src/main/java/Publication.kt +++ b/buildSrc/src/main/java/Publication.kt @@ -7,12 +7,19 @@ 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.versionName() + val name = project.name this.maybeCreate("android").contents { - from("build${sep}publications${sep}androidRelease") + from("build${sep}publications${sep}androidRelease") { + renameModule(name, "android", version = version) + } from("build${sep}outputs${sep}aar") { include("*-release*") rename { @@ -21,11 +28,16 @@ fun DistributionContainer.configureForMultiplatform(project: Project) { } from("build${sep}libs") { include("*android*") - withJavadoc(renameTo = "compose-android") + include("*androidRelease-javadoc*") + rename { + it.replace("androidRelease-javadoc", "android") + } } } this.getByName("main").contents { - from("build${sep}publications${sep}kotlinMultiplatform") + from("build${sep}publications${sep}kotlinMultiplatform") { + renameModule(name, version = version) + } from("build${sep}kotlinToolingMetadata") from("build${sep}libs") { include("*compose-kotlin*") @@ -33,16 +45,21 @@ fun DistributionContainer.configureForMultiplatform(project: Project) { rename { it.replace("-kotlin", "") .replace("-metadata", "") + .replace("Multiplatform-javadoc", "") } - withJavadoc() } } this.maybeCreate("desktop").contents { // kotlin multiplatform modules - from("build${sep}publications${sep}desktop") + from("build${sep}publications${sep}desktop") { + renameModule(name, "desktop", version = version) + } from("build${sep}libs") { include("*desktop*") - withJavadoc(renameTo = "compose-desktop") + include("*desktop-javadoc*") + rename { + it.replace("desktop-javadoc", "desktop") + } } } @@ -53,13 +70,41 @@ fun DistributionContainer.configureForMultiplatform(project: Project) { project.tasks.getByName("distZip").finalizedBy(*platformDists) } -private fun CopySpec.withJavadoc(renameTo: String = "compose") { - include("*javadoc*") - rename { - if (it.contains("javadoc")) { - it.replace("compose", renameTo) - } else { - it +fun DistributionContainer.configureForJvm(project: Project) { + val sep = File.separator + val version = project.versionName() + val name = project.name + + this.getByName("main").contents { + // non android modules + from("build${sep}libs") + from("build${sep}publications${sep}maven") { + renameModule(name, version = version) } + // android modules + from("build${sep}outputs${sep}aar") { + include("*-release*") + } + from("build${sep}publications${sep}release") { + renameModule(name, version = version) + } + from("build${sep}intermediates${sep}java_doc_jar${sep}release") { + include("*javadoc*") + rename { it.replace("release", "$name-$version") } + } + from("build${sep}intermediates${sep}source_jar${sep}release") { + include("*sources*") + rename { it.replace("release", "$name-$version") } + } + } +} + +private fun CopySpec.renameModule(projectName: String, renameTo: String = "", version: String) { + var target = "" + if (renameTo.isNotEmpty()) { + target = "-$renameTo" + } + rename { + it.replace("module.json", "$projectName$target-$version.module") } } 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/docs/stylesheet.css b/docs/stylesheet.css deleted file mode 100644 index 9ce22b2627b..00000000000 --- a/docs/stylesheet.css +++ /dev/null @@ -1,569 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Rubik&display=swap'); - -body { - background-color:#ffffff; - color:#353833; - font-family: 'Rubik', sans-serif; - font-size:14px; - margin:0; -} -a:link, a:visited { - text-decoration:none; - color:#6c5fc7; -} -a:hover, a:focus { - text-decoration:none; - color:#EEA911; -} -a:active { - text-decoration:none; - color:#6c5fc7; -} -a[name] { - color:#353833; -} -a[name]:hover { - text-decoration:none; - color:#353833; -} -pre { - font-family:'DejaVu Sans Mono', monospace; - font-size:14px; -} -h1 { - font-size:20px; -} -h2 { - font-size:18px; -} -h3 { - font-size:16px; - font-style:italic; -} -h4 { - font-size:13px; -} -h5 { - font-size:12px; -} -h6 { - font-size:11px; -} -ul { - list-style-type:disc; -} -code, tt { - font-family:'DejaVu Sans Mono', monospace; - font-size:14px; - padding-top:4px; - margin-top:8px; - line-height:1.4em; -} -dt code { - font-family:'DejaVu Sans Mono', monospace; - font-size:14px; - padding-top:4px; -} -table tr td dt code { - font-family:'DejaVu Sans Mono', monospace; - font-size:14px; - vertical-align:top; - padding-top:4px; -} -sup { - font-size:8px; -} -/* -Document title and Copyright styles -*/ -.clear { - clear:both; - height:0px; - overflow:hidden; -} -.aboutLanguage { - float:right; - padding:0px 21px; - font-size:11px; - z-index:200; - margin-top:-9px; -} -.legalCopy { - margin-left:.5em; -} -.bar a, .bar a:link, .bar a:visited, .bar a:active { - color:#FFFFFF; - text-decoration:none; -} -.bar a:hover, .bar a:focus { - color:#EEA911; -} -.tab { - background-color:#0066FF; - color:#ffffff; - padding:8px; - width:5em; - font-weight:bold; -} -/* -Navigation bar styles -*/ -.bar { - background-color:#8c5393; - color:#FFFFFF; - padding:.8em .5em .4em .8em; - height:auto;/*height:1.8em;*/ - font-size:11px; - margin:0; -} -.topNav { - background-color:#8c5393; - color:#FFFFFF; - float:left; - padding:0; - width:100%; - clear:right; - height:2.8em; - padding-top:10px; - overflow:hidden; - font-size:12px; -} -.bottomNav { - margin-top:10px; - background-color:#8c5393; - color:#FFFFFF; - float:left; - padding:0; - width:100%; - clear:right; - height:2.8em; - padding-top:10px; - overflow:hidden; - font-size:12px; -} -.subNav { - background-color:#dee3e9; - float:left; - width:100%; - overflow:hidden; - font-size:12px; -} -.subNav div { - clear:left; - float:left; - padding:0 0 5px 6px; - text-transform:uppercase; -} -ul.navList, ul.subNavList { - float:left; - margin:0 25px 0 0; - padding:0; -} -ul.navList li{ - list-style:none; - float:left; - padding: 5px 6px; - text-transform:uppercase; -} -ul.subNavList li{ - list-style:none; - float:left; -} -.topNav a:link, .topNav a:active, .topNav a:visited, .bottomNav a:link, .bottomNav a:active, .bottomNav a:visited { - color:#FFFFFF; - text-decoration:none; - text-transform:uppercase; -} -.topNav a:hover, .bottomNav a:hover { - text-decoration:none; - color:#EEA911; - text-transform:uppercase; -} -.navBarCell1Rev { - background-color:#F8981D; - color:white; - margin: auto 5px; -} -.skipNav { - position:absolute; - top:auto; - left:-9999px; - overflow:hidden; -} -/* -Page header and footer styles -*/ -.header, .footer { - clear:both; - margin:0 20px; - padding:5px 0 0 0; -} -.indexHeader { - margin:10px; - position:relative; -} -.indexHeader span{ - margin-right:15px; -} -.indexHeader h1 { - font-size:13px; -} -.title { - color:#2c4557; - margin:10px 0; -} -.subTitle { - margin:5px 0 0 0; -} -.header ul { - margin:0 0 15px 0; - padding:0; -} -.footer ul { - margin:20px 0 5px 0; -} -.header ul li, .footer ul li { - list-style:none; - font-size:13px; -} -/* -Heading styles -*/ -div.details ul.blockList ul.blockList ul.blockList li.blockList h4, div.details ul.blockList ul.blockList ul.blockListLast li.blockList h4 { - background-color:#dee3e9; - border:1px solid #d0d9e0; - margin:0 0 6px -8px; - padding:7px 5px; -} -ul.blockList ul.blockList ul.blockList li.blockList h3 { - background-color:#dee3e9; - border:1px solid #d0d9e0; - margin:0 0 6px -8px; - padding:7px 5px; -} -ul.blockList ul.blockList li.blockList h3 { - padding:0; - margin:15px 0; -} -ul.blockList li.blockList h2 { - padding:0px 0 20px 0; -} -/* -Page layout container styles -*/ -.contentContainer, .sourceContainer, .classUseContainer, .serializedFormContainer, .constantValuesContainer { - clear:both; - padding:10px 20px; - position:relative; -} -.indexContainer { - margin:10px; - position:relative; - font-size:12px; -} -.indexContainer h2 { - font-size:13px; - padding:0 0 3px 0; -} -.indexContainer ul { - margin:0; - padding:0; -} -.indexContainer ul li { - list-style:none; - padding-top:2px; -} -.contentContainer .description dl dt, .contentContainer .details dl dt, .serializedFormContainer dl dt { - font-size:12px; - font-weight:bold; - margin:10px 0 0 0; - color:#4E4E4E; -} -.contentContainer .description dl dd, .contentContainer .details dl dd, .serializedFormContainer dl dd { - margin:5px 0 10px 0px; - font-size:14px; - font-family:'DejaVu Sans Mono',monospace; -} -.serializedFormContainer dl.nameValue dt { - margin-left:1px; - font-size:1.1em; - display:inline; - font-weight:bold; -} -.serializedFormContainer dl.nameValue dd { - margin:0 0 0 1px; - font-size:1.1em; - display:inline; -} -/* -List styles -*/ -ul.horizontal li { - display:inline; - font-size:0.9em; -} -ul.inheritance { - margin:0; - padding:0; -} -ul.inheritance li { - display:inline; - list-style:none; -} -ul.inheritance li ul.inheritance { - margin-left:15px; - padding-left:15px; - padding-top:1px; -} -ul.blockList, ul.blockListLast { - margin:10px 0 10px 0; - padding:0; -} -ul.blockList li.blockList, ul.blockListLast li.blockList { - list-style:none; - margin-bottom:15px; - line-height:1.4; -} -ul.blockList ul.blockList li.blockList, ul.blockList ul.blockListLast li.blockList { - padding:0px 20px 5px 10px; - border:1px solid #ededed; - background-color:#f8f8f8; -} -ul.blockList ul.blockList ul.blockList li.blockList, ul.blockList ul.blockList ul.blockListLast li.blockList { - padding:0 0 5px 8px; - background-color:#ffffff; - border:none; -} -ul.blockList ul.blockList ul.blockList ul.blockList li.blockList { - margin-left:0; - padding-left:0; - padding-bottom:15px; - border:none; -} -ul.blockList ul.blockList ul.blockList ul.blockList li.blockListLast { - list-style:none; - border-bottom:none; - padding-bottom:0; -} -table tr td dl, table tr td dl dt, table tr td dl dd { - margin-top:0; - margin-bottom:1px; -} -/* -Table styles -*/ -.overviewSummary, .memberSummary, .typeSummary, .useSummary, .constantsSummary, .deprecatedSummary { - width:100%; - border-left:1px solid #EEE; - border-right:1px solid #EEE; - border-bottom:1px solid #EEE; -} -.overviewSummary, .memberSummary { - padding:0px; -} -.overviewSummary caption, .memberSummary caption, .typeSummary caption, -.useSummary caption, .constantsSummary caption, .deprecatedSummary caption { - position:relative; - text-align:left; - background-repeat:no-repeat; - color:white; - font-weight:bold; - clear:none; - overflow:hidden; - padding:0px; - padding-top:10px; - padding-left:1px; - margin:0px; - white-space:pre; -} -.overviewSummary caption a:link, .memberSummary caption a:link, .typeSummary caption a:link, -.useSummary caption a:link, .constantsSummary caption a:link, .deprecatedSummary caption a:link, -.overviewSummary caption a:hover, .memberSummary caption a:hover, .typeSummary caption a:hover, -.useSummary caption a:hover, .constantsSummary caption a:hover, .deprecatedSummary caption a:hover, -.overviewSummary caption a:active, .memberSummary caption a:active, .typeSummary caption a:active, -.useSummary caption a:active, .constantsSummary caption a:active, .deprecatedSummary caption a:active, -.overviewSummary caption a:visited, .memberSummary caption a:visited, .typeSummary caption a:visited, -.useSummary caption a:visited, .constantsSummary caption a:visited, .deprecatedSummary caption a:visited { - color:#FFFFFF; -} -.overviewSummary caption span, .memberSummary caption span, .typeSummary caption span, -.useSummary caption span, .constantsSummary caption span, .deprecatedSummary caption span { - white-space:nowrap; - padding-top:5px; - padding-left:12px; - padding-right:12px; - padding-bottom:7px; - display:inline-block; - float:left; - background-color:#F8981D; - border: none; - height:16px; -} -.memberSummary caption span.activeTableTab span { - white-space:nowrap; - padding-top:5px; - padding-left:12px; - padding-right:12px; - margin-right:3px; - display:inline-block; - float:left; - background-color:#F8981D; - height:16px; -} -.memberSummary caption span.tableTab span { - white-space:nowrap; - padding-top:5px; - padding-left:12px; - padding-right:12px; - margin-right:3px; - display:inline-block; - float:left; - background-color:#8c5393; - height:16px; -} -.memberSummary caption span.tableTab, .memberSummary caption span.activeTableTab { - padding-top:0px; - padding-left:0px; - padding-right:0px; - background-image:none; - float:none; - display:inline; -} -.overviewSummary .tabEnd, .memberSummary .tabEnd, .typeSummary .tabEnd, -.useSummary .tabEnd, .constantsSummary .tabEnd, .deprecatedSummary .tabEnd { - display:none; - width:5px; - position:relative; - float:left; - background-color:#F8981D; -} -.memberSummary .activeTableTab .tabEnd { - display:none; - width:5px; - margin-right:3px; - position:relative; - float:left; - background-color:#F8981D; -} -.memberSummary .tableTab .tabEnd { - display:none; - width:5px; - margin-right:3px; - position:relative; - background-color:#8c5393; - float:left; - -} -.overviewSummary td, .memberSummary td, .typeSummary td, -.useSummary td, .constantsSummary td, .deprecatedSummary td { - text-align:left; - padding:0px 0px 12px 10px; -} -th.colOne, th.colFirst, th.colLast, .useSummary th, .constantsSummary th, -td.colOne, td.colFirst, td.colLast, .useSummary td, .constantsSummary td{ - vertical-align:top; - padding-right:0px; - padding-top:8px; - padding-bottom:3px; -} -th.colFirst, th.colLast, th.colOne, .constantsSummary th { - background:#dee3e9; - text-align:left; - padding:8px 3px 3px 7px; -} -td.colFirst, th.colFirst { - white-space:nowrap; - font-size:13px; -} -td.colLast, th.colLast { - font-size:13px; -} -td.colOne, th.colOne { - font-size:13px; -} -.overviewSummary td.colFirst, .overviewSummary th.colFirst, -.useSummary td.colFirst, .useSummary th.colFirst, -.overviewSummary td.colOne, .overviewSummary th.colOne, -.memberSummary td.colFirst, .memberSummary th.colFirst, -.memberSummary td.colOne, .memberSummary th.colOne, -.typeSummary td.colFirst{ - width:25%; - vertical-align:top; -} -td.colOne a:link, td.colOne a:active, td.colOne a:visited, td.colOne a:hover, td.colFirst a:link, td.colFirst a:active, td.colFirst a:visited, td.colFirst a:hover, td.colLast a:link, td.colLast a:active, td.colLast a:visited, td.colLast a:hover, .constantValuesContainer td a:link, .constantValuesContainer td a:active, .constantValuesContainer td a:visited, .constantValuesContainer td a:hover { - font-weight:bold; -} -.tableSubHeadingColor { - background-color:#EEEEFF; -} -.altColor { - background-color:#FFFFFF; -} -.rowColor { - background-color:#EEEEEF; -} -/* -Content styles -*/ -.description pre { - margin-top:0; -} -.deprecatedContent { - margin:0; - padding:10px 0; -} -.docSummary { - padding:0; -} - -ul.blockList ul.blockList ul.blockList li.blockList h3 { - font-style:normal; -} - -div.block { - font-size:14px; - font-family: 'Rubik', sans-serif; -} - -td.colLast div { - padding-top:0px; -} - - -td.colLast a { - padding-bottom:3px; -} -/* -Formatting effect styles -*/ -.sourceLineNo { - color:green; - padding:0 30px 0 0; -} -h1.hidden { - visibility:hidden; - overflow:hidden; - font-size:10px; -} -.block { - display:block; - margin:3px 10px 2px 0px; - color:#474747; -} -.deprecatedLabel, .descfrmTypeLabel, .memberNameLabel, .memberNameLink, -.overrideSpecifyLabel, .packageHierarchyLabel, .paramLabel, .returnLabel, -.seeLabel, .simpleTagLabel, .throwsLabel, .typeNameLabel, .typeNameLink { - font-weight:bold; -} -.deprecationComment, .emphasizedPhrase, .interfaceName { - font-style:italic; -} - -div.block div.block span.deprecationComment, div.block div.block span.emphasizedPhrase, -div.block div.block span.interfaceName { - font-style:normal; -} - -div.contentContainer ul.blockList li.blockList h2{ - padding-bottom:0px; -} diff --git a/gradle.properties b/gradle.properties index 309bae0af68..e9bfc0e8156 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,16 +1,22 @@ -# Daemon’s heap size -org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1536m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC - +# Daemons heap size +org.gradle.jvmargs=-Xmx12g -XX:MaxMetaspaceSize=4g -XX:+CrashOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC +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 - -# Required by AGP >= 8.0.x -android.defaults.buildfeatures.buildconfig=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=6.25.1 +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 new file mode 100644 index 00000000000..bb4d18c7a0e --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,276 @@ +[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" +gummyBears = "0.12.0" +java8Signature = "1.0" +jackson = "2.18.3" +jetbrainsCompose = "1.6.11" +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" +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" +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.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.1.0" +sqldelight = "2.3.2" + +# Android +targetSdk = "37" +compileSdk = "37" +minSdk = "21" + +[plugins] +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" } +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" } +binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.13.0" } +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" } +vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.30.0" } +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.1.7" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } +gretty = { id = "org.gretty", version = "4.0.0" } +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" } +apollo3-kotlin = { module = "com.apollographql.apollo3:apollo-runtime", version = "3.8.2" } +apollo4-kotlin = { module = "com.apollographql.apollo:apollo-runtime", version = "4.1.1" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version = "1.3.0" } +androidx-annotation = { module = "androidx.annotation:annotation", version = "1.9.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.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.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" } +androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidxLifecycle" } +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-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" } +errorprone-core = { module = "com.google.errorprone:error_prone_core", version = "2.11.0" } +feign-core = { module = "io.github.openfeign:feign-core", version.ref = "feign" } +feign-gson = { module = "io.github.openfeign:feign-gson", version.ref = "feign" } +graphql-java17 = { module = "com.graphql-java:graphql-java", version = "17.3" } +graphql-java22 = { module = "com.graphql-java:graphql-java", version = "22.1" } +graphql-java24 = { module = "com.graphql-java:graphql-java", version = "24.0" } +jackson-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jackson" } +jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } +jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } +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" } +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.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" } +springboot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "springboot2" } +springboot-starter-websocket = { module = "org.springframework.boot:spring-boot-starter-websocket", version.ref = "springboot2" } +springboot-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "springboot2" } +springboot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop", version.ref = "springboot2" } +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" } +springboot3-starter-quartz = { module = "org.springframework.boot:spring-boot-starter-quartz", version.ref = "springboot3" } +springboot3-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "springboot3" } +springboot3-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "springboot3" } +springboot3-starter-websocket = { module = "org.springframework.boot:spring-boot-starter-websocket", version.ref = "springboot3" } +springboot3-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "springboot3" } +springboot3-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop", version.ref = "springboot3" } +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" } +springboot4-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "springboot4" } +springboot4-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "springboot4" } +springboot4-starter-websocket = { module = "org.springframework.boot:spring-boot-starter-websocket", version.ref = "springboot4" } +springboot4-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "springboot4" } +springboot4-starter-aspectj = { module = "org.springframework.boot:spring-boot-starter-aspectj", version.ref = "springboot4" } +springboot4-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "springboot4" } +springboot4-starter-restclient = { module = "org.springframework.boot:spring-boot-starter-restclient", version.ref = "springboot4" } +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.22" } +tomcat-embed-jasper-jakarta = { module = "org.apache.tomcat.embed:tomcat-embed-jasper", version = "11.0.22" } + +# test libraries +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.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.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" } + +# CameraX dependencies +camerax-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } +camerax-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } +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" } +leakcanary-instrumentation = { module = "com.squareup.leakcanary:leakcanary-android-instrumentation", version = "2.14" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "4.1.0" } +mockito-kotlin-spring7 = { module = "org.mockito.kotlin:mockito-kotlin", version = "6.0.0" } +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.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 033e24c4cdf..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 9f4197d5f4b..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.2.1-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 fcb6fca147c..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. @@ -15,10 +15,12 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -27,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: @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/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/. @@ -83,7 +85,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -111,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -144,7 +146,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +154,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -169,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" ) @@ -201,16 +202,15 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 93e3f59f135..a51ec4f5886 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,16 +13,18 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @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=. @@ -43,13 +45,13 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +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:"=% @@ -57,36 +59,24 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +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=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -: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 +@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 -: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 new file mode 100644 index 00000000000..c573fa72259 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +certifi==2025.7.14 +charset-normalizer==3.4.2 +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/settings.xml b/scripts/settings.xml index 8b0800d0f47..8031c3ddf20 100755 --- a/scripts/settings.xml +++ b/scripts/settings.xml @@ -4,7 +4,7 @@ https://maven.apache.org/xsd/settings-1.0.0.xsd"> - ossrh + ossrh-staging-api ${env.OSSRH_USERNAME} ${env.OSSRH_PASSWORD} diff --git a/scripts/test-ui-critical.sh b/scripts/test-ui-critical.sh new file mode 100755 index 00000000000..7bb36eebec7 --- /dev/null +++ b/scripts/test-ui-critical.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -e + +echo "Checking if ADB is installed..." +if ! command -v adb &> /dev/null; then + echo "ADB is not installed or not in PATH. Please install Android SDK platform tools and ensure ADB is in your PATH." + exit 1 +fi + +echo "Checking if an Android emulator is running..." +if ! adb devices | grep -q "emulator"; then + echo "No Android emulator is currently running. Please start an emulator before running this script." + exit 1 +fi + +echo "Checking if Maestro is installed..." +if ! command -v maestro &> /dev/null; then + echo "Maestro is not installed. Please install Maestro before running this script." + exit 1 +fi + +echo "Building the UI Test Critical app..." +make assembleUiTestCriticalRelease + +echo "Installing the UI Test Critical app on the emulator..." +baseDir="sentry-android-integration-tests/sentry-uitest-android-critical" +buildDir="build/outputs/apk/release" +apkName="sentry-uitest-android-critical-release.apk" +appPath="${baseDir}/${buildDir}/${apkName}" +adb install -r -d "$appPath" + +echo "Running the Maestro tests..." +maestro test \ + "${baseDir}/maestro" \ + --debug-output "${baseDir}/maestro-logs" diff --git a/scripts/toggle-codec-logs.sh b/scripts/toggle-codec-logs.sh new file mode 100755 index 00000000000..d54728818a3 --- /dev/null +++ b/scripts/toggle-codec-logs.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +# --- Functions --- + +print_usage() { + echo "Usage: $0 [enable|disable]" + exit 1 +} + +# Check for adb +if ! command -v adb &> /dev/null; then + echo "❌ adb not found. Please install Android Platform Tools and ensure adb is in your PATH." + exit 1 +fi + +# Check for connected device +DEVICE_COUNT=$(adb devices | grep -w "device" | wc -l) +if [ "$DEVICE_COUNT" -eq 0 ]; then + echo "❌ No device connected. Please connect a device and enable USB debugging." + exit 1 +fi + +# --- Handle Argument --- + +ACTION=$(echo "$1" | tr '[:upper:]' '[:lower:]') + +case "$ACTION" in + enable) + echo "✅ Enabling native logs (DEBUG)..." + adb shell setprop log.tag.MPEG4Writer D + adb shell setprop log.tag.CCodec D + adb shell setprop log.tag.VQApply D + adb shell setprop log.tag.ColorUtils D + adb shell setprop log.tag.MediaCodec D + adb shell setprop log.tag.MediaCodecList D + adb shell setprop log.tag.MediaWriter D + adb shell setprop log.tag.CCodecConfig D + adb shell setprop log.tag.Codec2Client D + adb shell setprop log.tag.CCodecBufferChannel D + adb shell setprop log.tag.CodecProperties D + adb shell setprop log.tag.CodecSeeding D + adb shell setprop log.tag.C2Store D + adb shell setprop log.tag.C2NodeImpl D + adb shell setprop log.tag.GraphicBufferSource D + adb shell setprop log.tag.BufferQueueProducer D + adb shell setprop log.tag.ReflectedParamUpdater D + adb shell setprop log.tag.hw-BpHwBinder D + adb shell setprop log.tag.ACodec D + adb shell setprop log.tag.VideoCapabilities D + adb shell setprop log.tag.OMXUtils D + adb shell setprop log.tag.OMXClient D + echo "✅ Logs ENABLED" + ;; + disable) + echo "🚫 Disabling native logs (SILENT)..." + adb shell setprop log.tag.MPEG4Writer SILENT + adb shell setprop log.tag.CCodec SILENT + adb shell setprop log.tag.VQApply SILENT + adb shell setprop log.tag.ColorUtils SILENT + adb shell setprop log.tag.MediaCodec SILENT + adb shell setprop log.tag.MediaCodecList SILENT + adb shell setprop log.tag.MediaWriter SILENT + adb shell setprop log.tag.CCodecConfig SILENT + adb shell setprop log.tag.Codec2Client SILENT + adb shell setprop log.tag.CCodecBufferChannel SILENT + adb shell setprop log.tag.CodecProperties SILENT + adb shell setprop log.tag.CodecSeeding SILENT + adb shell setprop log.tag.C2Store SILENT + adb shell setprop log.tag.C2NodeImpl SILENT + adb shell setprop log.tag.GraphicBufferSource SILENT + adb shell setprop log.tag.BufferQueueProducer SILENT + adb shell setprop log.tag.ReflectedParamUpdater SILENT + adb shell setprop log.tag.hw-BpHwBinder SILENT + adb shell setprop log.tag.ACodec SILENT + adb shell setprop log.tag.VideoCapabilities SILENT + adb shell setprop log.tag.OMXUtils SILENT + adb shell setprop log.tag.OMXClient SILENT + echo "🚫 Logs DISABLED" + ;; + *) + echo "❓ Unknown or missing argument: '$1'" + print_usage + ;; +esac diff --git a/scripts/update-gradle.sh b/scripts/update-gradle.sh deleted file mode 100755 index 33de2b5f97a..00000000000 --- a/scripts/update-gradle.sh +++ /dev/null @@ -1,51 +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 - - # Remove trailing ".0" - gradlew expects '7.1' instead of '7.1.0' - if [[ "$version" == *".0" ]]; then - version="${version:0:${#version}-2}" - 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/scripts/update-sentry-native-ndk.sh b/scripts/update-sentry-native-ndk.sh new file mode 100755 index 00000000000..0aef9ebb257 --- /dev/null +++ b/scripts/update-sentry-native-ndk.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd $(dirname "$0")/../ +GRADLE_NDK_FILEPATH=gradle/libs.versions.toml + +case $1 in +get-version) + perl -ne 'print "$1\n" if ( m/module = "io\.sentry:sentry-native-ndk", version = "([0-9.]+)"/ )' "$GRADLE_NDK_FILEPATH" + ;; +get-repo) + echo "https://github.com/getsentry/sentry-native.git" + ;; +set-version) + version=$2 + + echo "Setting sentry-native-ndk version to '$version'" + + PATTERN='(module = "io\.sentry:sentry-native-ndk", version = ")[0-9.]+(")' + perl -pi -e "s/$PATTERN/\${1}$version\${2}/" "$GRADLE_NDK_FILEPATH" + ;; +*) + 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 ccfe1950016..65bf072f0a0 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -1,6 +1,19 @@ +public final class io/sentry/android/core/ActivityBreadcrumbsIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable { + public fun (Landroid/app/Application;)V + public fun close ()V + 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 final class io/sentry/android/core/ActivityFramesTracker { - public fun (Lio/sentry/android/core/LoadClass;Lio/sentry/android/core/SentryAndroidOptions;)V - public fun (Lio/sentry/android/core/LoadClass;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/MainLooperHandler;)V + public fun (Lio/sentry/util/LoadClass;Lio/sentry/android/core/SentryAndroidOptions;)V + public fun (Lio/sentry/util/LoadClass;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/MainLooperHandler;)V public fun addActivity (Landroid/app/Activity;)V public fun isFrameMetricsAggregatorAvailable ()Z public fun setMetrics (Landroid/app/Activity;Lio/sentry/protocol/SentryId;)V @@ -14,17 +27,34 @@ public final class io/sentry/android/core/ActivityLifecycleIntegration : android 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 onActivityPostCreated (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityPostResumed (Landroid/app/Activity;)V + public fun onActivityPostStarted (Landroid/app/Activity;)V + public fun onActivityPreCreated (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityPrePaused (Landroid/app/Activity;)V + public fun onActivityPreStarted (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/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V +} + +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/util/LazyEvaluator$Evaluator;)V + public fun close (Z)V + public fun getChunkId ()Lio/sentry/protocol/SentryId; + public fun getProfilerId ()Lio/sentry/protocol/SentryId; + public fun getRootSpanCounter ()I + 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 final class io/sentry/android/core/AndroidCpuCollector : io/sentry/ICollector { - public fun (Lio/sentry/ILogger;Lio/sentry/android/core/BuildInfoProvider;)V +public final class io/sentry/android/core/AndroidCpuCollector : io/sentry/IPerformanceSnapshotCollector { + public fun (Lio/sentry/ILogger;)V public fun collect (Lio/sentry/PerformanceCollectionData;)V public fun setup ()V } @@ -34,6 +64,15 @@ public final class io/sentry/android/core/AndroidDateUtils { public static fun getCurrentSentryDateTime ()Lio/sentry/SentryDate; } +public final class io/sentry/android/core/AndroidFatalLogger : io/sentry/ILogger { + public fun ()V + public fun (Ljava/lang/String;)V + public fun isEnabled (Lio/sentry/SentryLevel;)Z + public fun log (Lio/sentry/SentryLevel;Ljava/lang/String;Ljava/lang/Throwable;)V + public fun log (Lio/sentry/SentryLevel;Ljava/lang/String;[Ljava/lang/Object;)V + public fun log (Lio/sentry/SentryLevel;Ljava/lang/Throwable;Ljava/lang/String;[Ljava/lang/Object;)V +} + public final class io/sentry/android/core/AndroidLogger : io/sentry/ILogger { public fun ()V public fun (Ljava/lang/String;)V @@ -43,16 +82,70 @@ 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 class io/sentry/android/core/AndroidMemoryCollector : io/sentry/ICollector { +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/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; +} + +public class io/sentry/android/core/AndroidProfiler$ProfileEndData { + public final field didTimeout Z + public final field endCpuMillis J + public final field endNanos J + public final field measurementsMap Ljava/util/Map; + public final field traceFile Ljava/io/File; + public fun (JJZLjava/io/File;Ljava/util/Map;)V +} + +public class io/sentry/android/core/AndroidProfiler$ProfileStartData { + public final field startCpuMillis J + public final field startNanos J + public final field startTimestamp Ljava/util/Date; + public fun (JJLjava/util/Date;)V +} + +public final class io/sentry/android/core/AndroidSocketTagger : io/sentry/ISocketTagger { + public static fun getInstance ()Lio/sentry/android/core/AndroidSocketTagger; + public fun tagSockets ()V + public fun untagSockets ()V +} + public final class io/sentry/android/core/AnrIntegration : io/sentry/Integration, java/io/Closeable { public fun (Landroid/content/Context;)V public fun close ()V - public final fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/AnrIntegrationFactory { @@ -60,20 +153,18 @@ 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 process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; -} - public class io/sentry/android/core/AnrV2Integration : io/sentry/Integration, java/io/Closeable { public fun (Landroid/content/Context;)V public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/AnrV2Integration$AnrV2Hint : io/sentry/hints/BlockingFlushHint, io/sentry/hints/AbnormalExit, io/sentry/hints/Backfillable { public fun (JLio/sentry/ILogger;JZZ)V + public fun ignoreCurrentThread ()Z + public fun isFlushable (Lio/sentry/protocol/SentryId;)Z public fun mechanism ()Ljava/lang/String; + public fun setFlushable (Lio/sentry/protocol/SentryId;)V public fun shouldEnrich ()Z public fun timestamp ()Ljava/lang/Long; } @@ -84,29 +175,68 @@ public final class io/sentry/android/core/AppComponentsBreadcrumbsIntegration : public fun onConfigurationChanged (Landroid/content/res/Configuration;)V public fun onLowMemory ()V public fun onTrimMemory (I)V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/Integration, java/io/Closeable { public fun ()V public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/AppStartState { - public fun getAppStartEndTime ()Lio/sentry/SentryDate; - public fun getAppStartInterval ()Ljava/lang/Long; - public fun getAppStartMillis ()Ljava/lang/Long; - public fun getAppStartTime ()Lio/sentry/SentryDate; - public static fun getInstance ()Lio/sentry/android/core/AppStartState; - public fun isColdStart ()Ljava/lang/Boolean; - public fun reset ()V - public fun setAppStartMillis (J)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/AppState { +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 public static fun getInstance ()Lio/sentry/android/core/AppState; + public fun getLifecycleObserver ()Lio/sentry/android/core/AppState$LifecycleObserver; public fun isInBackground ()Ljava/lang/Boolean; + public fun registerLifecycleObserver (Lio/sentry/SentryOptions;)V + public fun removeAppStateListener (Lio/sentry/android/core/AppState$AppStateListener;)V + public fun resetInstance ()V + public fun unregisterLifecycleObserver ()V +} + +public abstract interface class io/sentry/android/core/AppState$AppStateListener { + public abstract fun onBackground ()V + public abstract fun onForeground ()V +} + +public final class io/sentry/android/core/AppState$LifecycleObserver : androidx/lifecycle/DefaultLifecycleObserver { + public fun (Lio/sentry/android/core/AppState;)V + public fun getListeners ()Ljava/util/List; + public fun onStart (Landroidx/lifecycle/LifecycleOwner;)V + 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 { @@ -128,16 +258,47 @@ public final class io/sentry/android/core/BuildInfoProvider { public fun isEmulator ()Ljava/lang/Boolean; } +public final class io/sentry/android/core/ContextUtils { + public static fun appIsLibraryForComposePreview (Landroid/content/Context;)Z + public static fun getApplicationContext (Landroid/content/Context;)Landroid/content/Context; + public static fun isForegroundImportance ()Z +} + public class io/sentry/android/core/CurrentActivityHolder { public fun clearActivity ()V + public fun clearActivity (Landroid/app/Activity;)V public fun getActivity ()Landroid/app/Activity; public static fun getInstance ()Lio/sentry/android/core/CurrentActivityHolder; public fun setActivity (Landroid/app/Activity;)V } -public final class io/sentry/android/core/CurrentActivityIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable { +public final class io/sentry/android/core/DeviceInfoUtil { + public fun (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)V + public fun collectDeviceInformation (ZZ)Lio/sentry/protocol/Device; + public static fun getBatteryLevel (Landroid/content/Intent;Lio/sentry/SentryOptions;)Ljava/lang/Float; + public static fun getInstance (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/DeviceInfoUtil; + public fun getOperatingSystem ()Lio/sentry/protocol/OperatingSystem; + public fun getSideLoadedInfo ()Lio/sentry/android/core/ContextUtils$SideLoadedInfo; + public fun getSplitApksInfo ()Lio/sentry/android/core/ContextUtils$SplitApksInfo; + public fun getTotalMemory ()Ljava/lang/Long; + public static fun isCharging (Landroid/content/Intent;Lio/sentry/SentryOptions;)Ljava/lang/Boolean; + public static fun resetInstance ()V +} + +public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : io/sentry/Integration, java/io/Closeable { + protected final field startLock Lio/sentry/util/AutoClosableReentrantLock; + public fun ()V + public fun close ()V + public static fun getOutboxFileObserver ()Lio/sentry/android/core/EnvelopeFileObserverIntegration; + 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 @@ -145,50 +306,88 @@ public final class io/sentry/android/core/CurrentActivityIntegration : android/a 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/IHub;Lio/sentry/SentryOptions;)V -} - -public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : io/sentry/Integration, java/io/Closeable { - public fun ()V - public fun close ()V - public static fun getOutboxFileObserver ()Lio/sentry/android/core/EnvelopeFileObserverIntegration; - public final fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)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; + public abstract fun loadDebugImagesForAddresses (Ljava/util/Set;)Ljava/util/Set; +} + +public final class io/sentry/android/core/InternalSentrySdk { + public fun ()V + public static fun captureEnvelope ([BZ)Lio/sentry/protocol/SentryId; + public static fun getAppStartMeasurement ()Ljava/util/Map; + public static fun getCurrentScope ()Lio/sentry/IScope; + public static fun serializeScope (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/IScope;)Ljava/util/Map; + public static fun setTrace (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;)V } -public final class io/sentry/android/core/LoadClass { +public final class io/sentry/android/core/LoadClass : io/sentry/util/LoadClass { public fun ()V public fun isClassAvailable (Ljava/lang/String;Lio/sentry/ILogger;)Z public fun isClassAvailable (Ljava/lang/String;Lio/sentry/SentryOptions;)Z 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; + public fun getValue ()I + public static fun valueOf (Ljava/lang/String;)Lio/sentry/android/core/NdkHandlerStrategy; + public static fun values ()[Lio/sentry/android/core/NdkHandlerStrategy; +} + public final class io/sentry/android/core/NdkIntegration : io/sentry/Integration, java/io/Closeable { public static final field SENTRY_NDK_CLASS_NAME Ljava/lang/String; public fun (Ljava/lang/Class;)V public fun close ()V - public final fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable { - public fun (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/ILogger;)V + public fun (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)V public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/PhoneStateBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable { - public fun (Landroid/content/Context;)V - public fun close ()V - public fun register (Lio/sentry/IHub;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, io/sentry/IntegrationName { - public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V +public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor { + 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; } public final class io/sentry/android/core/SentryAndroid { @@ -206,59 +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 getProfilingTracesHz ()I - public fun getProfilingTracesIntervalMillis ()I + 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 setProfilingTracesHz (I)V - public fun setProfilingTracesIntervalMillis (I)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 @@ -285,37 +520,129 @@ public final class io/sentry/android/core/SentryLogcatAdapter { public static fun wtf (Ljava/lang/String;Ljava/lang/Throwable;)I } -public final class io/sentry/android/core/SentryPerformanceProvider : android/app/Application$ActivityLifecycleCallbacks { +public final class io/sentry/android/core/SentryPerformanceProvider { public fun ()V public fun attachInfo (Landroid/content/Context;Landroid/content/pm/ProviderInfo;)V public fun getType (Landroid/net/Uri;)Ljava/lang/String; - 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 onCreate ()Z + public fun shutdown ()V } -public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable { +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 + public fun (Landroid/content/Context;Landroid/util/AttributeSet;I)V + public fun (Landroid/content/Context;Landroid/util/AttributeSet;II)V + public fun setOnClickListener (Landroid/view/View$OnClickListener;)V +} + +public final class io/sentry/android/core/SentryUserFeedbackDialog : io/sentry/android/core/SentryUserFeedbackForm { +} + +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 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 +} + +public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerformanceContinuousCollector, io/sentry/android/core/internal/util/SentryFrameMetricsCollector$FrameMetricsCollectorListener { + protected final field lock Lio/sentry/util/AutoClosableReentrantLock; + public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V + public fun clear ()V + public fun onFrameMetricCollected (JJJJZZF)V + public fun onSpanFinished (Lio/sentry/ISpan;)V + public fun onSpanStarted (Lio/sentry/ISpan;)V +} + +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 fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public static fun getDefaultActions ()Ljava/util/List; + public fun onBackground ()V + public fun onForeground ()V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/TempSensorBreadcrumbsIntegration : android/hardware/SensorEventListener, io/sentry/Integration, java/io/Closeable { +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 onAccuracyChanged (Landroid/hardware/Sensor;I)V - public fun onSensorChanged (Landroid/hardware/SensorEvent;)V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)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/android/core/LoadClass;)V + public fun (Landroid/app/Application;Lio/sentry/util/LoadClass;)V public fun close ()V public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityDestroyed (Landroid/app/Activity;)V @@ -324,25 +651,248 @@ public final class io/sentry/android/core/UserInteractionIntegration : android/a 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/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentry/EventProcessor, io/sentry/IntegrationName { +public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentry/EventProcessor { public fun (Lio/sentry/android/core/SentryAndroidOptions;)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 static fun snapshotViewHierarchy (Landroid/app/Activity;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; - public static fun snapshotViewHierarchy (Landroid/app/Activity;Ljava/util/List;Lio/sentry/util/thread/IMainThreadChecker;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; + public static fun snapshotViewHierarchy (Landroid/app/Activity;Ljava/util/List;Lio/sentry/util/thread/IThreadChecker;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; public static fun snapshotViewHierarchy (Landroid/view/View;)Lio/sentry/protocol/ViewHierarchy; public static fun snapshotViewHierarchy (Landroid/view/View;Ljava/util/List;)Lio/sentry/protocol/ViewHierarchy; - public static fun snapshotViewHierarchyAsData (Landroid/app/Activity;Lio/sentry/util/thread/IMainThreadChecker;Lio/sentry/ISerializer;Lio/sentry/ILogger;)[B + 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 +} + +public class io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter : android/app/Application$ActivityLifecycleCallbacks { + public fun ()V + 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 class io/sentry/android/core/performance/ActivityLifecycleSpanHelper { + public fun (Ljava/lang/String;)V + public fun clear ()V + public fun createAndStopOnCreateSpan (Lio/sentry/ISpan;)V + public fun createAndStopOnStartSpan (Lio/sentry/ISpan;)V + public fun getOnCreateSpan ()Lio/sentry/ISpan; + public fun getOnCreateStartTimestamp ()Lio/sentry/SentryDate; + public fun getOnStartSpan ()Lio/sentry/ISpan; + public fun getOnStartStartTimestamp ()Lio/sentry/SentryDate; + public fun saveSpanToAppStartMetrics ()V + public fun setOnCreateStartTimestamp (Lio/sentry/SentryDate;)V + public fun setOnStartStartTimestamp (Lio/sentry/SentryDate;)V +} + +public class io/sentry/android/core/performance/ActivityLifecycleTimeSpan : java/lang/Comparable { + public fun ()V + public fun compareTo (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)I + public synthetic fun compareTo (Ljava/lang/Object;)I + public final fun getOnCreate ()Lio/sentry/android/core/performance/TimeSpan; + public final fun getOnStart ()Lio/sentry/android/core/performance/TimeSpan; +} + +public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter { + 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 + public fun getContentProviderOnCreateTimeSpans ()Ljava/util/List; + public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics; + public fun getSdkInitTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun isAppLaunchedInForeground ()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 onActivityStarted (Landroid/app/Activity;)V + public fun onActivityStopped (Landroid/app/Activity;)V + public fun onAppStartSpansSent ()V + public static fun onApplicationCreate (Landroid/app/Application;)V + public static fun onApplicationPostCreate (Landroid/app/Application;)V + public static fun onContentProviderCreate (Landroid/content/ContentProvider;)V + 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 { + public static final field COLD Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; + public static final field UNKNOWN Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; + public static final field WARM Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; + 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 + public synthetic fun compareTo (Ljava/lang/Object;)I + public fun getDescription ()Ljava/lang/String; + public fun getDurationMs ()J + public fun getProjectedStopTimestamp ()Lio/sentry/SentryDate; + public fun getProjectedStopTimestampMs ()J + public fun getProjectedStopTimestampSecs ()D + public fun getStartTimestamp ()Lio/sentry/SentryDate; + public fun getStartTimestampMs ()J + public fun getStartTimestampSecs ()D + public fun getStartUptimeMs ()J + public fun hasNotStarted ()Z + public fun hasNotStopped ()Z + public fun hasStarted ()Z + public fun hasStopped ()Z + public fun reset ()V + public fun setDescription (Ljava/lang/String;)V + public fun setStartUnixTimeMs (J)V + public fun setStartedAt (J)V + public fun setStoppedAt (J)V + public fun setup (Ljava/lang/String;JJJ)V + public fun start ()V + public fun stop ()V +} + +public class io/sentry/android/core/performance/WindowContentChangedCallback : io/sentry/android/core/internal/gestures/WindowCallbackAdapter { + public fun (Landroid/view/Window$Callback;Ljava/lang/Runnable;)V + public fun onContentChanged ()V +} + +public final class io/sentry/android/core/util/AndroidLazyEvaluator { + public fun (Lio/sentry/android/core/util/AndroidLazyEvaluator$AndroidEvaluator;)V + public fun getValue (Landroid/content/Context;)Ljava/lang/Object; + public fun resetValue ()V + public fun setValue (Ljava/lang/Object;)V +} + +public abstract interface class io/sentry/android/core/util/AndroidLazyEvaluator$AndroidEvaluator { + public abstract fun evaluate (Landroid/content/Context;)Ljava/lang/Object; } diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index d7478786fe3..0e3708a89bf 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -1,116 +1,136 @@ 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") - kotlin("android") - jacoco - id(Config.QualityPlugins.errorProne) - id(Config.QualityPlugins.gradleVersions) + id("com.android.library") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) } android { - compileSdk = Config.Android.compileSdkVersion - namespace = "io.sentry.android.core" - - defaultConfig { - targetSdk = Config.Android.targetSdkVersion - minSdk = Config.Android.minSdkVersion - - testInstrumentationRunner = Config.TestLibs.androidJUnitRunner - - buildConfigField("String", "SENTRY_ANDROID_SDK_NAME", "\"${Config.Sentry.SENTRY_ANDROID_SDK_NAME}\"") - - // for AGP 4.1 - buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "io.sentry.android.core" + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField( + "String", + "SENTRY_ANDROID_SDK_NAME", + "\"${Config.Sentry.SENTRY_ANDROID_SDK_NAME}\"", + ) + + // for AGP 4.1 + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + } + + 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 = 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" + } } + } - buildTypes { - getByName("debug") - getByName("release") { - consumerProguardFiles("proguard-rules.pro") - } - } + lint { + warningsAsErrors = true + checkDependencies = true - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() - } - - testOptions { - animationsDisabled = true - unitTests.apply { - isReturnDefaultValues = true - isIncludeAndroidResources = true - } - } + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. + checkReleaseBuilds = false + } - lint { - warningsAsErrors = true - checkDependencies = true + buildFeatures { buildConfig = true } - // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. - checkReleaseBuilds = false - } + // needed because of Kotlin 1.4.x + configurations.all { resolutionStrategy.force(libs.jetbrains.annotations.get()) } - // needed because of Kotlin 1.4.x - configurations.all { - resolutionStrategy.force(Config.CompileOnly.jetbrainsAnnotations) - } - - variantFilter { - if (Config.Android.shouldSkipDebugVariant(buildType.name)) { - ignore = true - } - } -} - -tasks.withType { - configure { - isIncludeNoLocationClasses = false - } + androidComponents.beforeVariants { + it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + } } tasks.withType().configureEach { - options.errorprone { - check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) - option("NullAway:AnnotatedPackages", "io.sentry") - } + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } } +// 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(projects.sentryAndroidFragment) - compileOnly(projects.sentryAndroidTimber) - compileOnly(projects.sentryCompose) - - // lifecycle processor, session tracking - implementation(Config.Libs.lifecycleProcess) - implementation(Config.Libs.lifecycleCommonJava8) - implementation(Config.Libs.androidxCore) - - compileOnly(Config.CompileOnly.nopen) - errorprone(Config.CompileOnly.nopenChecker) - errorprone(Config.CompileOnly.errorprone) - errorprone(Config.CompileOnly.errorProneNullAway) - compileOnly(Config.CompileOnly.jetbrainsAnnotations) - - // tests - testImplementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - testImplementation(Config.TestLibs.robolectric) - testImplementation(Config.TestLibs.kotlinTestJunit) - testImplementation(Config.TestLibs.androidxCore) - testImplementation(Config.TestLibs.androidxRunner) - testImplementation(Config.TestLibs.androidxJunit) - testImplementation(Config.TestLibs.androidxCoreKtx) - testImplementation(Config.TestLibs.mockitoKotlin) - testImplementation(Config.TestLibs.mockitoInline) - testImplementation(Config.TestLibs.awaitility) - testImplementation(projects.sentryTestSupport) - testImplementation(projects.sentryAndroidFragment) - testImplementation(projects.sentryAndroidTimber) - testImplementation(projects.sentryComposeHelper) - testImplementation(projects.sentryAndroidNdk) - testRuntimeOnly(Config.Libs.composeUi) - testRuntimeOnly(Config.Libs.timber) - testRuntimeOnly(Config.Libs.fragment) + api(projects.sentry) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + compileOnly(projects.sentryAndroidFragment) + compileOnly(projects.sentryAndroidTimber) + compileOnly(projects.sentryAndroidReplay) + compileOnly(projects.sentryCompose) + compileOnly(projects.sentryAndroidDistribution) + + // lifecycle processor, session tracking + 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) + errorprone(libs.nullaway) + + // tests + testImplementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + testImplementation(libs.roboelectric) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.androidx.core.ktx) + testImplementation(libs.androidx.test.core) + 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) + + 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 79daaace033..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,8 +29,28 @@ # 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.IntegrationName +-keepnames class * implements io.sentry.Integration + +-dontwarn io.sentry.apollo.SentryApolloInterceptor +-keepnames class io.sentry.apollo.SentryApolloInterceptor + +-dontwarn io.sentry.apollo3.SentryApollo3HttpInterceptor +-keepnames class io.sentry.apollo3.SentryApollo3HttpInterceptor + +-dontwarn io.sentry.android.okhttp.SentryOkHttpInterceptor +-keepnames class io.sentry.android.okhttp.SentryOkHttpInterceptor + +-dontwarn io.sentry.android.navigation.SentryNavigationListener +-keepnames class io.sentry.android.navigation.SentryNavigationListener + +-keepnames class io.sentry.android.core.ScreenshotEventProcessor +-keepnames class io.sentry.android.core.ViewHierarchyEventProcessor # Keep any custom option classes like SentryAndroidOptions, as they're loaded via reflection # Also keep method names, as they're e.g. used by native via JNI calls @@ -39,6 +58,7 @@ -keepnames class io.sentry.android.core.ApplicationNotResponding + ##---------------End: proguard configuration for android-core ---------- ##---------------Begin: proguard configuration for sentry-apollo-3 ---------- @@ -50,3 +70,30 @@ -keep class io.sentry.apollo3.SentryApollo3ClientException { (...); } ##---------------End: proguard configuration for sentry-apollo-3 ---------- + +##---------------Begin: proguard configuration for sentry-okhttp ---------- + +# we don't want this class to be obfuscated, otherwise issue's titles are obfuscated as well. +-keepnames class io.sentry.exception.SentryHttpClientException + +##---------------End: proguard configuration for sentry-okhttp ---------- + +##---------------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 ---------- + +##---------------Begin: proguard configuration for sentry-android-distribution ---------- +-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/AndroidManifest.xml b/sentry-android-core/src/main/AndroidManifest.xml index dba3e7df8e7..a304ee075bb 100644 --- a/sentry-android-core/src/main/AndroidManifest.xml +++ b/sentry-android-core/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + SystemClock.uptimeMillis(), timeoutIntervalMillis, diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityBreadcrumbsIntegration.java new file mode 100644 index 00000000000..3cd7709c4be --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityBreadcrumbsIntegration.java @@ -0,0 +1,142 @@ +package io.sentry.android.core; + +import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import io.sentry.Breadcrumb; +import io.sentry.Hint; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.Integration; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.Objects; +import java.io.Closeable; +import java.io.IOException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Automatically adds breadcrumbs for Activity Lifecycle Events. */ +public final class ActivityBreadcrumbsIntegration + implements Integration, Closeable, Application.ActivityLifecycleCallbacks { + + private final @NotNull Application application; + private @Nullable IScopes scopes; + private boolean enabled; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + + // TODO check if locking is even required at all for lifecycle methods + public ActivityBreadcrumbsIntegration(final @NotNull Application application) { + this.application = Objects.requireNonNull(application, "Application is required"); + } + + @Override + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + final SentryAndroidOptions androidOptions = + Objects.requireNonNull( + (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, + "SentryAndroidOptions is required"); + + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); + this.enabled = androidOptions.isEnableActivityLifecycleBreadcrumbs(); + options + .getLogger() + .log(SentryLevel.DEBUG, "ActivityBreadcrumbsIntegration enabled: %s", enabled); + + if (enabled) { + application.registerActivityLifecycleCallbacks(this); + options.getLogger().log(SentryLevel.DEBUG, "ActivityBreadcrumbIntegration installed."); + addIntegrationToSdkVersion("ActivityBreadcrumbs"); + } + } + + @Override + public void close() throws IOException { + if (enabled) { + application.unregisterActivityLifecycleCallbacks(this); + if (scopes != null) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.DEBUG, "ActivityBreadcrumbsIntegration removed."); + } + } + } + + @Override + public void onActivityCreated( + final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "created"); + } + } + + @Override + public void onActivityStarted(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "started"); + } + } + + @Override + public void onActivityResumed(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "resumed"); + } + } + + @Override + public void onActivityPaused(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "paused"); + } + } + + @Override + public void onActivityStopped(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "stopped"); + } + } + + @Override + public void onActivitySaveInstanceState( + final @NotNull Activity activity, final @NotNull Bundle outState) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "saveInstanceState"); + } + } + + @Override + public void onActivityDestroyed(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "destroyed"); + } + } + + private void addBreadcrumb(final @NotNull Activity activity, final @NotNull String state) { + if (scopes == null) { + return; + } + + final Breadcrumb breadcrumb = new Breadcrumb(); + breadcrumb.setType("navigation"); + breadcrumb.setData("state", state); + breadcrumb.setData("screen", getActivityName(activity)); + breadcrumb.setCategory("ui.lifecycle"); + breadcrumb.setLevel(SentryLevel.INFO); + + final Hint hint = new Hint(); + hint.set(ANDROID_ACTIVITY, activity); + + scopes.addBreadcrumb(breadcrumb, hint); + } + + private @NotNull String getActivityName(final @NotNull Activity activity) { + return activity.getClass().getSimpleName(); + } +} 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 79ebff9f20c..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 @@ -3,11 +3,14 @@ import android.app.Activity; import android.util.SparseIntArray; import androidx.core.app.FrameMetricsAggregator; +import io.sentry.ISentryLifecycleToken; import io.sentry.MeasurementUnit; import io.sentry.SentryLevel; -import io.sentry.android.core.internal.util.AndroidMainThreadChecker; +import io.sentry.android.core.internal.util.AndroidThreadChecker; 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; @@ -21,10 +24,14 @@ * A class that tracks slow and frozen frames using the FrameMetricsAggregator class from * androidx.core package. It also checks if the FrameMetricsAggregator class is available at * runtime. + * + *

If performance-v2 is enabled, frame metrics are recorded using {@link + * io.sentry.android.core.internal.util.SentryFrameMetricsCollector} via {@link + * SpanFrameMetricsCollector} instead and this implementation will no-op. */ public final class ActivityFramesTracker { - private @Nullable FrameMetricsAggregator frameMetricsAggregator = null; + private @NotNull LazyEvaluator frameMetricsAggregator; private @NotNull final SentryAndroidOptions options; private final @NotNull Map> @@ -33,51 +40,59 @@ public final class ActivityFramesTracker { new WeakHashMap<>(); private final @NotNull MainLooperHandler handler; + protected @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + + private final @NotNull LazyEvaluator androidXAvailable; public ActivityFramesTracker( - final @NotNull LoadClass loadClass, + 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; } public ActivityFramesTracker( - final @NotNull LoadClass loadClass, final @NotNull SentryAndroidOptions options) { + final @NotNull io.sentry.util.LoadClass loadClass, + final @NotNull SentryAndroidOptions options) { this(loadClass, options, new MainLooperHandler()); } @TestOnly ActivityFramesTracker( - final @NotNull LoadClass loadClass, + 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 && options.isEnableFramesTracking(); + return androidXAvailable.getValue() + && options.isEnableFramesTracking() + && !options.isEnablePerformanceV2(); } @SuppressWarnings("NullAway") - public synchronized void addActivity(final @NotNull Activity activity) { - if (!isFrameMetricsAggregatorAvailable()) { - return; - } + public void addActivity(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!isFrameMetricsAggregatorAvailable()) { + return; + } - runSafelyOnUiThread(() -> frameMetricsAggregator.add(activity), "FrameMetricsAggregator.add"); - snapshotFrameCountsAtStart(activity); + runSafelyOnUiThread( + () -> frameMetricsAggregator.getValue().add(activity), "FrameMetricsAggregator.add"); + snapshotFrameCountsAtStart(activity); + } } private void snapshotFrameCountsAtStart(final @NotNull Activity activity) { @@ -92,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; @@ -125,45 +140,46 @@ private void snapshotFrameCountsAtStart(final @NotNull Activity activity) { } @SuppressWarnings("NullAway") - public synchronized void setMetrics( - final @NotNull Activity activity, final @NotNull SentryId transactionId) { - if (!isFrameMetricsAggregatorAvailable()) { - return; - } + public void setMetrics(final @NotNull Activity activity, final @NotNull SentryId transactionId) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!isFrameMetricsAggregatorAvailable()) { + return; + } - // NOTE: removing an activity does not reset the frame counts, only reset() does - // throws IllegalArgumentException when attempting to remove - // OnFrameMetricsAvailableListener - // that was never added. - // there's no contains method. - // throws NullPointerException when attempting to remove - // OnFrameMetricsAvailableListener and - // there was no - // Observers, See - // https://android.googlesource.com/platform/frameworks/base/+/140ff5ea8e2d99edc3fbe63a43239e459334c76b - runSafelyOnUiThread(() -> frameMetricsAggregator.remove(activity), null); - - final @Nullable FrameCounts frameCounts = diffFrameCountsAtEnd(activity); - - if (frameCounts == null - || (frameCounts.totalFrames == 0 - && frameCounts.slowFrames == 0 - && frameCounts.frozenFrames == 0)) { - return; - } + // NOTE: removing an activity does not reset the frame counts, only reset() does + // throws IllegalArgumentException when attempting to remove + // OnFrameMetricsAvailableListener + // that was never added. + // there's no contains method. + // throws NullPointerException when attempting to remove + // OnFrameMetricsAvailableListener and + // there was no + // Observers, See + // https://android.googlesource.com/platform/frameworks/base/+/140ff5ea8e2d99edc3fbe63a43239e459334c76b + runSafelyOnUiThread(() -> frameMetricsAggregator.getValue().remove(activity), null); + + final @Nullable FrameCounts frameCounts = diffFrameCountsAtEnd(activity); + + if (frameCounts == null + || (frameCounts.totalFrames == 0 + && frameCounts.slowFrames == 0 + && frameCounts.frozenFrames == 0)) { + return; + } - final MeasurementValue tfValues = - new MeasurementValue(frameCounts.totalFrames, MeasurementUnit.NONE); - final MeasurementValue sfValues = - new MeasurementValue(frameCounts.slowFrames, MeasurementUnit.NONE); - final MeasurementValue ffValues = - new MeasurementValue(frameCounts.frozenFrames, MeasurementUnit.NONE); - final Map measurements = new HashMap<>(); - measurements.put(MeasurementValue.KEY_FRAMES_TOTAL, tfValues); - measurements.put(MeasurementValue.KEY_FRAMES_SLOW, sfValues); - measurements.put(MeasurementValue.KEY_FRAMES_FROZEN, ffValues); - - activityMeasurements.put(transactionId, measurements); + final MeasurementValue tfValues = + new MeasurementValue(frameCounts.totalFrames, MeasurementUnit.NONE); + final MeasurementValue sfValues = + new MeasurementValue(frameCounts.slowFrames, MeasurementUnit.NONE); + final MeasurementValue ffValues = + new MeasurementValue(frameCounts.frozenFrames, MeasurementUnit.NONE); + final Map measurements = new HashMap<>(); + measurements.put(MeasurementValue.KEY_FRAMES_TOTAL, tfValues); + measurements.put(MeasurementValue.KEY_FRAMES_SLOW, sfValues); + measurements.put(MeasurementValue.KEY_FRAMES_FROZEN, ffValues); + + activityMeasurements.put(transactionId, measurements); + } } private @Nullable FrameCounts diffFrameCountsAtEnd(final @NotNull Activity activity) { @@ -185,30 +201,34 @@ public synchronized void setMetrics( } @Nullable - public synchronized Map takeMetrics( - final @NotNull SentryId transactionId) { - if (!isFrameMetricsAggregatorAvailable()) { - return null; - } + public Map takeMetrics(final @NotNull SentryId transactionId) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!isFrameMetricsAggregatorAvailable()) { + return null; + } - final Map stringMeasurementValueMap = - activityMeasurements.get(transactionId); - activityMeasurements.remove(transactionId); - return stringMeasurementValueMap; + final Map stringMeasurementValueMap = + activityMeasurements.get(transactionId); + activityMeasurements.remove(transactionId); + return stringMeasurementValueMap; + } } @SuppressWarnings("NullAway") - public synchronized void stop() { - if (isFrameMetricsAggregatorAvailable()) { - runSafelyOnUiThread(() -> frameMetricsAggregator.stop(), "FrameMetricsAggregator.stop"); - frameMetricsAggregator.reset(); + public void stop() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (isFrameMetricsAggregatorAvailable()) { + runSafelyOnUiThread( + () -> frameMetricsAggregator.getValue().stop(), "FrameMetricsAggregator.stop"); + frameMetricsAggregator.getValue().reset(); + } + activityMeasurements.clear(); } - activityMeasurements.clear(); } private void runSafelyOnUiThread(final Runnable runnable, final String tag) { try { - if (AndroidMainThreadChecker.getInstance().isMainThread()) { + if (AndroidThreadChecker.getInstance().isMainThread()) { runnable.run(); } else { handler.post( 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 decc43f5be4..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 @@ -1,41 +1,50 @@ package io.sentry.android.core; import static io.sentry.MeasurementUnit.Duration.MILLISECOND; -import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; -import android.annotation.SuppressLint; import android.app.Activity; import android.app.Application; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.view.View; -import androidx.annotation.NonNull; -import io.sentry.Breadcrumb; +import io.sentry.Baggage; +import io.sentry.BaggageHeader; import io.sentry.FullyDisplayedReporter; -import io.sentry.Hint; -import io.sentry.IHub; +import io.sentry.IScope; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.ISpan; import io.sentry.ITransaction; import io.sentry.Instrumenter; import io.sentry.Integration; import io.sentry.NoOpTransaction; -import io.sentry.Scope; +import io.sentry.PropagationContext; import io.sentry.SentryDate; import io.sentry.SentryLevel; +import io.sentry.SentryNanotimeDate; import io.sentry.SentryOptions; +import io.sentry.SpanOptions; import io.sentry.SpanStatus; +import io.sentry.TracesSamplingDecision; import io.sentry.TransactionContext; import io.sentry.TransactionOptions; +import io.sentry.android.core.internal.util.ClassUtil; import io.sentry.android.core.internal.util.FirstDrawDoneListener; +import io.sentry.android.core.performance.ActivityLifecycleSpanHelper; +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; import io.sentry.util.TracingUtils; import java.io.Closeable; import java.io.IOException; import java.lang.ref.WeakReference; +import java.util.Collections; import java.util.Map; import java.util.WeakHashMap; import java.util.concurrent.Future; @@ -50,15 +59,26 @@ 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 long TTFD_TIMEOUT_MILLIS = 30000; + 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; - private @Nullable IHub hub; + private @Nullable IScopes scopes; private @Nullable SentryAndroidOptions options; private boolean performanceEnabled = false; @@ -68,14 +88,15 @@ public final class ActivityLifecycleIntegration private boolean isAllActivityCallbacksAvailable; private boolean firstActivityCreated = false; - private final boolean foregroundImportance; 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 @NotNull SentryDate lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime(); - private final @NotNull Handler mainHandler = new Handler(Looper.getMainLooper()); + private final @NotNull WeakHashMap activitySpanHelpers = + new WeakHashMap<>(); + 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 @@ -84,6 +105,10 @@ public final class ActivityLifecycleIntegration new WeakHashMap<>(); private final @NotNull ActivityFramesTracker activityFramesTracker; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private boolean fullyDisplayedCalled = false; + private final @NotNull AutoClosableReentrantLock fullyDisplayedLock = + new AutoClosableReentrantLock(); public ActivityLifecycleIntegration( final @NotNull Application application, @@ -98,35 +123,32 @@ public ActivityLifecycleIntegration( if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.Q) { isAllActivityCallbacksAvailable = true; } - - // we only track app start for processes that will show an Activity (full launch). - // Here we check the process importance which will tell us that. - foregroundImportance = ContextUtils.isForegroundImportance(this.application); } @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { + 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.hub = Objects.requireNonNull(hub, "Hub is required"); - - this.options - .getLogger() - .log( - SentryLevel.DEBUG, - "ActivityLifecycleIntegration enabled: %s", - this.options.isEnableActivityLifecycleBreadcrumbs()); + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); performanceEnabled = isPerformanceEnabled(this.options); fullyDisplayedReporter = this.options.getFullyDisplayedReporter(); 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(); + addIntegrationToSdkVersion("ActivityLifecycle"); } private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options) { @@ -136,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."); @@ -144,22 +169,6 @@ public void close() throws IOException { activityFramesTracker.stop(); } - private void addBreadcrumb(final @NotNull Activity activity, final @NotNull String state) { - if (options != null && hub != null && options.isEnableActivityLifecycleBreadcrumbs()) { - final Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setType("navigation"); - breadcrumb.setData("state", state); - breadcrumb.setData("screen", getActivityName(activity)); - breadcrumb.setCategory("ui.lifecycle"); - breadcrumb.setLevel(SentryLevel.INFO); - - final Hint hint = new Hint(); - hint.set(ANDROID_ACTIVITY, activity); - - hub.addBreadcrumb(breadcrumb, hint); - } - } - private @NotNull String getActivityName(final @NotNull Activity activity) { return activity.getClass().getSimpleName(); } @@ -176,21 +185,43 @@ private void stopPreviousTransactions() { private void startTracing(final @NotNull Activity activity) { WeakReference weakActivity = new WeakReference<>(activity); - if (hub != null && !isRunningTransactionOrTrace(activity)) { + if (scopes != null && !isRunningTransactionOrTrace(activity)) { if (!performanceEnabled) { activitiesWithOngoingTransactions.put(activity, NoOpTransaction.getInstance()); - TracingUtils.startNewTrace(hub); - } else if (performanceEnabled) { + if (options.isEnableAutoTraceIdGeneration()) { + TracingUtils.startNewTrace(scopes); + } + } else { // as we allow a single transaction running on the bound Scope, we finish the previous ones stopPreviousTransactions(); final String activityName = getActivityName(activity); - final SentryDate appStartTime = - foregroundImportance ? AppStartState.getInstance().getAppStartTime() : null; - final Boolean coldStart = AppStartState.getInstance().isColdStart(); + final @Nullable SentryDate appStartTime; + final @Nullable Boolean coldStart; + final @NotNull TimeSpan appStartTimeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); + + // we only track app start for processes that will show an Activity (full launch). + // Here we check the process importance which will tell us that. + final boolean foregroundImportance = ContextUtils.isForegroundImportance(); + if (foregroundImportance && appStartTimeSpan.hasStarted()) { + appStartTime = appStartTimeSpan.getStartTimestamp(); + coldStart = + AppStartMetrics.getInstance().getAppStartType() == AppStartMetrics.AppStartType.COLD; + } else { + appStartTime = null; + coldStart = null; + } final TransactionOptions transactionOptions = new TransactionOptions(); + + // Set deadline timeout based on configured option + final long deadlineTimeoutMillis = options.getDeadlineTimeout(); + // No deadline when zero or negative value is set + transactionOptions.setDeadlineTimeout( + deadlineTimeoutMillis <= 0 ? null : deadlineTimeoutMillis); + if (options.isEnableActivityLifecycleTracingAutoFinish()) { transactionOptions.setIdleTimeout(options.getIdleTimeout()); transactionOptions.setTrimEnd(true); @@ -216,45 +247,147 @@ private void startTracing(final @NotNull Activity activity) { // This will be the start timestamp of the transaction, as well as the ttid/ttfd spans final @NotNull SentryDate ttidStartTime; + final @Nullable TracesSamplingDecision appStartSamplingDecision; if (!(firstActivityCreated || appStartTime == null || coldStart == null)) { // The first activity ttid/ttfd spans should start at the app start time ttidStartTime = appStartTime; + // The app start transaction inherits the sampling decision from the app start profiling, + // then clears it + appStartSamplingDecision = AppStartMetrics.getInstance().getAppStartSamplingDecision(); + AppStartMetrics.getInstance().setAppStartSamplingDecision(null); } else { // The ttid/ttfd spans should start when the previous activity called its onPause method ttidStartTime = lastPausedTime; + appStartSamplingDecision = null; } transactionOptions.setStartTimestamp(ttidStartTime); + transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); + setSpanOrigin(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); + } + } - // we can only bind to the scope if there's no running transaction - ITransaction transaction = - hub.startTransaction( - new TransactionContext(activityName, TransactionNameSource.COMPONENT, UI_LOAD_OP), - transactionOptions); + // 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; + } - // 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); - - // in case there's already an end time (e.g. due to deferred SDK init) - // we can finish the app-start span - finishAppStartSpan(); + 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); + + if (isAppStart) { + if (!createStandaloneAppStart && !options.isEnableStandaloneAppStartTracing()) { + appStartSpan = + transaction.startChild( + getAppStartOp(coldStart), + getAppStartDesc(coldStart), + appStartTime, + Instrumenter.SENTRY, + spanOptions); + + finishAppStartSpan(); + } } final @NotNull ISpan ttidSpan = transaction.startChild( - TTID_OP, getTtidDesc(activityName), ttidStartTime, Instrumenter.SENTRY); + TTID_OP, + getTtidDesc(activityName), + ttidStartTime, + Instrumenter.SENTRY, + spanOptions); ttidSpanMap.put(activity, ttidSpan); if (timeToFullDisplaySpanEnabled && fullyDisplayedReporter != null && options != null) { final @NotNull ISpan ttfdSpan = transaction.startChild( - TTFD_OP, getTtfdDesc(activityName), ttidStartTime, Instrumenter.SENTRY); + TTFD_OP, + getTtfdDesc(activityName), + ttidStartTime, + Instrumenter.SENTRY, + spanOptions); try { ttfdSpanMap.put(activity, ttfdSpan); ttfdAutoCloseFuture = @@ -273,7 +406,7 @@ private void startTracing(final @NotNull Activity activity) { } // lets bind to the scope so other integrations can pick it up - hub.configureScope( + scopes.configureScope( scope -> { applyScope(scope, transaction); }); @@ -283,8 +416,67 @@ private void startTracing(final @NotNull Activity activity) { } } + 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 Scope scope, final @NotNull ITransaction transaction) { + void applyScope(final @NotNull IScope scope, final @NotNull ITransaction transaction) { scope.withTransaction( scopeTransaction -> { // we'd not like to overwrite existent transactions bound to the Scope @@ -303,7 +495,7 @@ void applyScope(final @NotNull Scope scope, final @NotNull ITransaction transact } @VisibleForTesting - void clearScope(final @NotNull Scope scope, final @NotNull ITransaction transaction) { + void clearScope(final @NotNull IScope scope, final @NotNull ITransaction transaction) { scope.withTransaction( scopeTransaction -> { if (scopeTransaction == transaction) { @@ -345,10 +537,10 @@ private void finishTransaction( status = SpanStatus.OK; } transaction.finish(status); - if (hub != null) { + if (scopes != null) { // make sure to remove the transaction from scope, as it may contain running children, // therefore `finish` method will not remove it from scope - hub.configureScope( + scopes.configureScope( scope -> { clearScope(scope, transaction); }); @@ -357,139 +549,213 @@ private void finishTransaction( } @Override - public synchronized void onActivityCreated( + public void onActivityPreCreated( final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { - setColdStart(savedInstanceState); - addBreadcrumb(activity, "created"); - startTracing(activity); - final @Nullable ISpan ttfdSpan = ttfdSpanMap.get(activity); + final ActivityLifecycleSpanHelper helper = + new ActivityLifecycleSpanHelper(activity.getClass().getName()); + activitySpanHelpers.put(activity, helper); + // The very first activity start timestamp cannot be set to the class instantiation time, as it + // may happen before an activity is started (service, broadcast receiver, etc). So we set it + // here. + if (firstActivityCreated) { + return; + } + lastPausedTime = + scopes != null + ? scopes.getOptions().getDateProvider().now() + : AndroidDateUtils.getCurrentSentryDateTime(); + helper.setOnCreateStartTimestamp(lastPausedTime); + } - firstActivityCreated = true; + @Override + public void onActivityCreated( + final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { + if (!isAllActivityCallbacksAvailable) { + onActivityPreCreated(activity, savedInstanceState); + } + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (scopes != null && options != null && options.isEnableScreenTracking()) { + final @Nullable String activityClassName = ClassUtil.getClassName(activity); + scopes.configureScope(scope -> scope.setScreen(activityClassName)); + } + startTracing(activity); + final @Nullable ISpan ttidSpan = ttidSpanMap.get(activity); + final @Nullable ISpan ttfdSpan = ttfdSpanMap.get(activity); + + firstActivityCreated = true; + + if (performanceEnabled + && ttidSpan != null + && ttfdSpan != null + && fullyDisplayedReporter != null) { + fullyDisplayedReporter.registerFullyDrawnListener( + () -> onFullFrameDrawn(ttidSpan, ttfdSpan)); + } + } + } - if (fullyDisplayedReporter != null) { - fullyDisplayedReporter.registerFullyDrawnListener(() -> onFullFrameDrawn(ttfdSpan)); + @Override + public void onActivityPostCreated( + final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { + final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); + if (helper != null) { + helper.createAndStopOnCreateSpan(getAppStartParent(activity)); } } @Override - public synchronized void onActivityStarted(final @NotNull Activity activity) { - if (performanceEnabled) { - // The docs on the screen rendering performance tracing - // (https://firebase.google.com/docs/perf-mon/screen-traces?platform=android#definition), - // state that the tracing starts for every Activity class when the app calls - // .onActivityStarted. - // Adding an Activity in onActivityCreated leads to Window.FEATURE_NO_TITLE not - // working. Moving this to onActivityStarted fixes the problem. - activityFramesTracker.addActivity(activity); + public void onActivityPreStarted(final @NotNull Activity activity) { + final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); + if (helper != null) { + helper.setOnStartStartTimestamp( + options != null + ? options.getDateProvider().now() + : AndroidDateUtils.getCurrentSentryDateTime()); } - addBreadcrumb(activity, "started"); } - @SuppressLint("NewApi") @Override - public synchronized void onActivityResumed(final @NotNull Activity activity) { - if (performanceEnabled) { - // app start span - @Nullable final SentryDate appStartStartTime = AppStartState.getInstance().getAppStartTime(); - @Nullable final SentryDate appStartEndTime = AppStartState.getInstance().getAppStartEndTime(); - // in case the SentryPerformanceProvider is disabled it does not set the app start times, - // and we need to set the end time manually here, - // the start time gets set manually in SentryAndroid.init() - if (appStartStartTime != null && appStartEndTime == null) { - AppStartState.getInstance().setAppStartEnd(); + public void onActivityStarted(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!isAllActivityCallbacksAvailable) { + onActivityPostCreated(activity, null); + onActivityPreStarted(activity); + } + if (performanceEnabled) { + // The docs on the screen rendering performance tracing + // (https://firebase.google.com/docs/perf-mon/screen-traces?platform=android#definition), + // state that the tracing starts for every Activity class when the app calls + // .onActivityStarted. + // Adding an Activity in onActivityCreated leads to Window.FEATURE_NO_TITLE not + // working. Moving this to onActivityStarted fixes the problem. + activityFramesTracker.addActivity(activity); } - finishAppStartSpan(); + } + } - final @Nullable ISpan ttidSpan = ttidSpanMap.get(activity); - final @Nullable ISpan ttfdSpan = ttfdSpanMap.get(activity); - final View rootView = activity.findViewById(android.R.id.content); - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN - && rootView != null) { - FirstDrawDoneListener.registerForNextDraw( - rootView, () -> onFirstFrameDrawn(ttfdSpan, ttidSpan), buildInfoProvider); - } else { - // Posting a task to the main thread's handler will make it executed after it finished - // its current job. That is, right after the activity draws the layout. - mainHandler.post(() -> onFirstFrameDrawn(ttfdSpan, ttidSpan)); + @Override + public void onActivityPostStarted(final @NotNull Activity activity) { + final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); + if (helper != null) { + helper.createAndStopOnStartSpan(getAppStartParent(activity)); + // Needed to handle hybrid SDKs + helper.saveSpanToAppStartMetrics(); + } + finishAppStartSpan(); + } + + @Override + public void onActivityResumed(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!isAllActivityCallbacksAvailable) { + onActivityPostStarted(activity); + } + if (performanceEnabled) { + + final @Nullable ISpan ttidSpan = ttidSpanMap.get(activity); + final @Nullable ISpan ttfdSpan = ttfdSpanMap.get(activity); + if (activity.getWindow() != null) { + FirstDrawDoneListener.registerForNextDraw( + activity, () -> onFirstFrameDrawn(ttfdSpan, ttidSpan), buildInfoProvider); + } else { + // Posting a task to the main thread's handler will make it executed after it finished + // its current job. That is, right after the activity draws the layout. + new Handler(Looper.getMainLooper()).post(() -> onFirstFrameDrawn(ttfdSpan, ttidSpan)); + } } } - addBreadcrumb(activity, "resumed"); } @Override - public void onActivityPostResumed(@NonNull Activity activity) { + public void onActivityPostResumed(@NotNull Activity activity) { // empty override, required to avoid a api-level breaking super.onActivityPostResumed() calls } @Override - public void onActivityPrePaused(@NonNull Activity activity) { + public void onActivityPrePaused(@NotNull Activity activity) { // only executed if API >= 29 otherwise it happens on onActivityPaused - if (isAllActivityCallbacksAvailable) { - if (hub == null) { - lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime(); - } else { - lastPausedTime = hub.getOptions().getDateProvider().now(); - } - } + // as the SDK may gets (re-)initialized mid activity lifecycle, ensure we set the flag here as + // well + // this ensures any newly launched activity will not use the app start timestamp as txn start + firstActivityCreated = true; + lastPausedTime = + scopes != null + ? scopes.getOptions().getDateProvider().now() + : AndroidDateUtils.getCurrentSentryDateTime(); } @Override - public synchronized void onActivityPaused(final @NotNull Activity activity) { - // only executed if API < 29 otherwise it happens on onActivityPrePaused - if (!isAllActivityCallbacksAvailable) { - if (hub == null) { - lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime(); - } else { - lastPausedTime = hub.getOptions().getDateProvider().now(); + public void onActivityPaused(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + // only executed if API < 29 otherwise it happens on onActivityPrePaused + if (!isAllActivityCallbacksAvailable) { + onActivityPrePaused(activity); } } - addBreadcrumb(activity, "paused"); } @Override - public synchronized void onActivityStopped(final @NotNull Activity activity) { - addBreadcrumb(activity, "stopped"); + public void onActivityStopped(final @NotNull Activity activity) { + // no-op (acquire lock if this no longer is no-op) } @Override - public synchronized void onActivitySaveInstanceState( + public void onActivitySaveInstanceState( final @NotNull Activity activity, final @NotNull Bundle outState) { - addBreadcrumb(activity, "saveInstanceState"); + // no-op (acquire lock if this no longer is no-op) } @Override - public synchronized void onActivityDestroyed(final @NotNull Activity activity) { - if (performanceEnabled || options.isEnableActivityLifecycleBreadcrumbs()) { - addBreadcrumb(activity, "destroyed"); + public void onActivityDestroyed(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final ActivityLifecycleSpanHelper helper = activitySpanHelpers.remove(activity); + if (helper != null) { + helper.clear(); + } + if (performanceEnabled) { - // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid - // memory leak - finishSpan(appStartSpan, SpanStatus.CANCELLED); + // 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); - final ISpan ttfdSpan = ttfdSpanMap.get(activity); - finishSpan(ttidSpan, SpanStatus.DEADLINE_EXCEEDED); + // we finish the ttidSpan as cancelled in case it isn't completed yet + final ISpan ttidSpan = ttidSpanMap.get(activity); + final ISpan ttfdSpan = ttfdSpanMap.get(activity); + finishSpan(ttidSpan, SpanStatus.DEADLINE_EXCEEDED); - // we finish the ttfdSpan as deadline_exceeded in case it isn't completed yet - finishExceededTtfdSpan(ttfdSpan, ttidSpan); - cancelTtfdAutoClose(); + // we finish the ttfdSpan as deadline_exceeded in case it isn't completed yet + finishExceededTtfdSpan(ttfdSpan, ttidSpan); + cancelTtfdAutoClose(); - // in case people opt-out enableActivityLifecycleTracingAutoFinish and forgot to finish it, - // we make sure to finish it when the activity gets destroyed. - stopTracing(activity, true); + // in case people opt-out enableActivityLifecycleTracingAutoFinish and forgot to finish it, + // we make sure to finish it when the activity gets destroyed. + stopTracing(activity, true); - // set it to null in case its been just finished as cancelled - appStartSpan = null; - ttidSpanMap.remove(activity); - ttfdSpanMap.remove(activity); + // set it to null in case its been just finished as cancelled + appStartSpan = null; + appStartTransaction = null; + ttidSpanMap.remove(activity); + ttfdSpanMap.remove(activity); + } + + // clear it up, so we don't start again for the same activity if the activity is in the + // activity stack still. + // if the activity is opened again and not in memory, transactions will be created normally. + activitiesWithOngoingTransactions.remove(activity); + + if (activitiesWithOngoingTransactions.isEmpty() && !activity.isChangingConfigurations()) { + clear(); + } } + } - // clear it up, so we don't start again for the same activity if the activity is in the - // activity - // stack still. - // if the activity is opened again and not in memory, transactions will be created normally. - activitiesWithOngoingTransactions.remove(activity); + private void clear() { + firstActivityCreated = false; + lastPausedTime = new SentryNanotimeDate(0, 0); + activitySpanHelpers.clear(); } private void finishSpan(final @Nullable ISpan span) { @@ -529,37 +795,85 @@ private void cancelTtfdAutoClose() { } private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable ISpan ttidSpan) { - if (options != null && ttidSpan != null) { - final SentryDate endDate = options.getDateProvider().now(); - final long durationNanos = endDate.diff(ttidSpan.getStartDate()); - final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); - ttidSpan.setMeasurement( - MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY, durationMillis, MILLISECOND); - - if (ttfdSpan != null && ttfdSpan.isFinished()) { - ttfdSpan.updateEndDate(endDate); - // If the ttfd span was finished before the first frame we adjust the measurement, too + // app start span + 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()) { + stopTimeSpanAtDate(appStartTimeSpan, firstFrameEndDate); + } + if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { + stopTimeSpanAtDate(sdkInitTimeSpan, firstFrameEndDate); + } + 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 && firstFrameEndDate != null) { + final long durationNanos = firstFrameEndDate.diff(ttidSpan.getStartDate()); + final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); ttidSpan.setMeasurement( - MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); + MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY, durationMillis, MILLISECOND); + // If Sentry.reportFullyDisplayed was called before the first frame is drawn, we finish + // the ttfd now + if (ttfdSpan != null && fullyDisplayedCalled) { + fullyDisplayedCalled = false; + ttidSpan.setMeasurement( + MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); + ttfdSpan.setMeasurement( + MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); + finishSpan(ttfdSpan, firstFrameEndDate); + } + + finishSpan(ttidSpan, firstFrameEndDate); + } else { + finishSpan(ttidSpan); + if (fullyDisplayedCalled) { + finishSpan(ttfdSpan); + } } - finishSpan(ttidSpan, endDate); - } else { - finishSpan(ttidSpan); } } - private void onFullFrameDrawn(final @Nullable ISpan ttfdSpan) { - if (options != null && ttfdSpan != null) { - final SentryDate endDate = options.getDateProvider().now(); - final long durationNanos = endDate.diff(ttfdSpan.getStartDate()); - final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); - ttfdSpan.setMeasurement( - MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); - finishSpan(ttfdSpan, endDate); + 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 { - finishSpan(ttfdSpan); + 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 + // with first frame drawn + try (final @NotNull ISentryLifecycleToken ignored = fullyDisplayedLock.acquire()) { + // If the TTID span didn't finish, it means the first frame was not drawn yet, which means + // Sentry.reportFullyDisplayed was called too early. We set a flag, so that whenever the TTID + // will finish, we will finish the TTFD span as well. + if (!ttidSpan.isFinished()) { + fullyDisplayedCalled = true; + return; + } + if (options != null) { + final SentryDate endDate = options.getDateProvider().now(); + final long durationNanos = endDate.diff(ttfdSpan.getStartDate()); + final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); + ttfdSpan.setMeasurement( + MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); + finishSpan(ttfdSpan, endDate); + } else { + finishSpan(ttfdSpan); + } + } } private void finishExceededTtfdSpan( @@ -581,6 +895,17 @@ WeakHashMap getActivitiesWithOngoingTransactions() { return activitiesWithOngoingTransactions; } + @TestOnly + @NotNull + WeakHashMap getActivitySpanHelpers() { + return activitySpanHelpers; + } + + @TestOnly + void setFirstActivityCreated(boolean firstActivityCreated) { + this.firstActivityCreated = firstActivityCreated; + } + @TestOnly @NotNull ActivityFramesTracker getActivityFramesTracker() { @@ -605,14 +930,6 @@ WeakHashMap getTtfdSpanMap() { return ttfdSpanMap; } - private void setColdStart(final @Nullable Bundle savedInstanceState) { - if (!firstActivityCreated) { - // if Activity has savedInstanceState then its a warm start - // https://developer.android.com/topic/performance/vitals/launch-time#warm - AppStartState.getInstance().setColdStart(savedInstanceState == null); - } - } - private @NotNull String getTtidDesc(final @NotNull String activityName) { return activityName + " initial display"; } @@ -636,6 +953,16 @@ private void setColdStart(final @Nullable Bundle savedInstanceState) { } } + 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; @@ -645,9 +972,166 @@ private void setColdStart(final @Nullable Bundle savedInstanceState) { } private void finishAppStartSpan() { - final @Nullable SentryDate appStartEndTime = AppStartState.getInstance().getAppStartEndTime(); + finishAppStartSpan(null); + } + + private void finishAppStartSpan(final @Nullable SentryDate endDate) { + final @Nullable SentryDate appStartEndTime = + 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 new file mode 100644 index 00000000000..a1c0c097cb9 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java @@ -0,0 +1,414 @@ +package io.sentry.android.core; + +import static io.sentry.DataCategory.All; +import static io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED; +import static java.util.concurrent.TimeUnit.SECONDS; + +import android.os.Build; +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.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; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +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 { + private static final long MAX_CHUNK_DURATION_MILLIS = 60000; + + private final @NotNull ILogger logger; + private final @Nullable String profilingTracesDirPath; + private final int profilingTracesHz; + private final @NotNull LazyEvaluator.Evaluator executorServiceSupplier; + private final @NotNull BuildInfoProvider buildInfoProvider; + private boolean isInitialized = false; + private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; + private @Nullable AndroidProfiler profiler = null; + private boolean isRunning = false; + private @Nullable IScopes scopes; + private @Nullable Future stopFuture; + private @Nullable CompositePerformanceCollector performanceCollector; + private final @NotNull List payloadBuilders = new ArrayList<>(); + 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 SentryNanotimeDate(); + private volatile boolean shouldSample = true; + private boolean shouldStop = false; + private boolean isSampled = false; + private int rootSpanCounter = 0; + + private final AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private final AutoClosableReentrantLock payloadLock = new AutoClosableReentrantLock(); + + public AndroidContinuousProfiler( + final @NotNull BuildInfoProvider buildInfoProvider, + final @NotNull SentryFrameMetricsCollector frameMetricsCollector, + final @NotNull ILogger logger, + final @Nullable String profilingTracesDirPath, + final int profilingTracesHz, + final @NotNull LazyEvaluator.Evaluator executorServiceSupplier) { + this.logger = logger; + this.frameMetricsCollector = frameMetricsCollector; + this.buildInfoProvider = buildInfoProvider; + this.profilingTracesDirPath = profilingTracesDirPath; + this.profilingTracesHz = profilingTracesHz; + this.executorServiceSupplier = executorServiceSupplier; + } + + private void init() { + // We initialize it only once + if (isInitialized) { + return; + } + isInitialized = true; + if (profilingTracesDirPath == null) { + logger.log( + SentryLevel.WARNING, + "Disabling profiling because no profiling traces dir path is defined in options."); + return; + } + if (profilingTracesHz <= 0) { + logger.log( + SentryLevel.WARNING, + "Disabling profiling because trace rate is set to %d", + profilingTracesHz); + return; + } + + profiler = + new AndroidProfiler( + profilingTracesDirPath, + (int) SECONDS.toMicros(1) / profilingTracesHz, + frameMetricsCollector, + null, + logger); + } + + @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: + // rootSpanCounter should never be negative, unless the user changed profile lifecycle + // while + // the profiler is running or close() is called. This is just a safety check. + if (rootSpanCounter < 0) { + rootSpanCounter = 0; + } + rootSpanCounter++; + break; + case MANUAL: + // We check if the profiler is already running and log a message only in manual mode, + // since + // in trace mode we can have multiple concurrent traces + if (isRunning()) { + logger.log(SentryLevel.DEBUG, "Profiler is already running."); + return; + } + break; + } + if (!isRunning()) { + logger.log(SentryLevel.DEBUG, "Started Profiler."); + start(); + } + } + } + + private void initScopes() { + if ((scopes == null || scopes == NoOpScopes.getInstance()) + && Sentry.getCurrentScopes() != NoOpScopes.getInstance()) { + this.scopes = Sentry.getCurrentScopes(); + this.performanceCollector = + Sentry.getCurrentScopes().getOptions().getCompositePerformanceCollector(); + final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter(); + if (rateLimiter != null) { + rateLimiter.addRateLimitObserver(this); + } + } + } + + private void start() { + initScopes(); + + // 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; + + // Let's initialize trace folder and profiling interval + init(); + // init() didn't create profiler, should never happen + if (profiler == null) { + return; + } + + if (scopes != null) { + 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."); + // Let's stop and reset profiler id, as the profile is now broken anyway + stop(false); + return; + } + + // 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 + stop(false); + return; + } + startProfileChunkTimestamp = scopes.getOptions().getDateProvider().now(); + } else { + startProfileChunkTimestamp = new SentryNanotimeDate(); + } + final AndroidProfiler.ProfileStartData startData = profiler.start(); + // check if profiling started + if (startData == null) { + return; + } + + isRunning = true; + + if (profilerId.equals(SentryId.EMPTY_ID)) { + profilerId = new SentryId(); + } + + if (chunkId.equals(SentryId.EMPTY_ID)) { + chunkId = new SentryId(); + } + + if (performanceCollector != null) { + performanceCollector.start(chunkId.toString()); + } + + try { + stopFuture = + executorServiceSupplier.evaluate().schedule(() -> stop(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; + } + } + + @Override + public void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + switch (profileLifecycle) { + case TRACE: + rootSpanCounter--; + // If there are active spans, and profile lifecycle is trace, we don't stop the profiler + if (rootSpanCounter > 0) { + return; + } + // rootSpanCounter should never be negative, unless the user changed profile lifecycle + // while the profiler is running or close() is called. This is just a safety check. + if (rootSpanCounter < 0) { + rootSpanCounter = 0; + } + shouldStop = true; + break; + case MANUAL: + shouldStop = true; + break; + } + } + } + + private void stop(final boolean restartProfiler) { + initScopes(); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (stopFuture != null) { + stopFuture.cancel(true); + } + // check if profiler was created and it's running + if (profiler == null || !isRunning) { + // When the profiler is stopped due to an error (e.g. offline or rate limited), reset the + // ids + profilerId = SentryId.EMPTY_ID; + chunkId = SentryId.EMPTY_ID; + return; + } + + // onTransactionStart() is only available since Lollipop_MR1 + // and SystemClock.elapsedRealtimeNanos() since Jelly Bean + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP_MR1) { + return; + } + + List performanceCollectionData = null; + if (performanceCollector != null) { + performanceCollectionData = performanceCollector.stop(chunkId.toString()); + } + + final AndroidProfiler.ProfileEndData endData = + profiler.endAndCollect(false, performanceCollectionData); + + // check if profiler end successfully + if (endData == null) { + logger.log( + SentryLevel.ERROR, + "An error occurred while collecting a profile chunk, and it won't be sent."); + } else { + // The scopes can be null if the profiler is started before the SDK is initialized (app + // start profiling), meaning there's no scopes to send the chunks. In that case, we store + // the data in a list and send it when the next chunk is finished. + try (final @NotNull ISentryLifecycleToken ignored2 = payloadLock.acquire()) { + payloadBuilders.add( + new ProfileChunk.Builder( + profilerId, + chunkId, + endData.measurementsMap, + endData.traceFile, + startProfileChunkTimestamp, + ProfileChunk.PLATFORM_ANDROID)); + } + } + + isRunning = false; + // A chunk is finished. Next chunk will have a different id. + chunkId = SentryId.EMPTY_ID; + + if (scopes != null) { + sendChunks(scopes, scopes.getOptions()); + } + + if (restartProfiler && !shouldStop) { + logger.log(SentryLevel.DEBUG, "Profile chunk finished. Starting a new one."); + start(); + } else { + // When the profiler is stopped manually, we have to reset its id + profilerId = SentryId.EMPTY_ID; + logger.log(SentryLevel.DEBUG, "Profile chunk finished."); + } + } + } + + public void reevaluateSampling() { + shouldSample = true; + } + + @Override + public void close(final boolean isTerminating) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + rootSpanCounter = 0; + shouldStop = true; + if (isTerminating) { + stop(false); + isClosed.set(true); + } + } + } + + @Override + public @NotNull SentryId getProfilerId() { + return profilerId; + } + + @Override + public @NotNull SentryId getChunkId() { + return chunkId; + } + + private void sendChunks(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + try { + options + .getExecutorService() + .submit( + () -> { + // SDK is closed, we don't send the chunks + if (isClosed.get()) { + return; + } + final ArrayList payloads = new ArrayList<>(payloadBuilders.size()); + try (final @NotNull ISentryLifecycleToken ignored = payloadLock.acquire()) { + for (ProfileChunk.Builder builder : payloadBuilders) { + payloads.add(builder.build(options)); + } + payloadBuilders.clear(); + } + for (ProfileChunk payload : payloads) { + scopes.captureProfileChunk(payload); + } + }); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to send profile chunks.", e); + } + } + + @Override + public boolean isRunning() { + return isRunning; + } + + @VisibleForTesting + @Nullable + Future getStopFuture() { + return stopFuture; + } + + @VisibleForTesting + public int getRootSpanCounter() { + return rootSpanCounter; + } + + @Override + public void onRateLimitChanged(@NotNull RateLimiter rateLimiter) { + // We stop the profiler as soon as we are rate limited, to avoid the performance overhead + if (rateLimiter.isActiveForCategory(All) + || rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)) { + logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler."); + stop(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 + } +} 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 0992fec96d0..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,73 +1,48 @@ package io.sentry.android.core; -import android.annotation.SuppressLint; -import android.os.Build; +import android.os.Process; import android.os.SystemClock; import android.system.Os; import android.system.OsConstants; -import io.sentry.CpuCollectionData; -import io.sentry.ICollector; 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 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 ICollector { +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 final @NotNull BuildInfoProvider buildInfoProvider; private boolean isEnabled = false; - public AndroidCpuCollector( - final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider) { - this.logger = Objects.requireNonNull(logger, "Logger is required."); - this.buildInfoProvider = - Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required."); + public AndroidCpuCollector(final @NotNull ILogger logger) { + Objects.requireNonNull(logger, "Logger is required."); } - @SuppressLint("NewApi") @Override public void setup() { - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) { - isEnabled = false; - return; - } 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(); } - @SuppressLint("NewApi") @Override public void collect(final @NotNull PerformanceCollectionData performanceCollectionData) { - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP || !isEnabled) { + if (!isEnabled) { return; } final long nowNanos = SystemClock.elapsedRealtimeNanos(); @@ -81,43 +56,11 @@ public void collect(final @NotNull PerformanceCollectionData performanceCollecti // number from 0 to 100, so we are going to multiply it by 100 final double cpuUsagePercentage = cpuNanosDiff / (double) realTimeNanosDiff; - CpuCollectionData cpuData = - new CpuCollectionData( - System.currentTimeMillis(), (cpuUsagePercentage / (double) numCores) * 100.0); - - performanceCollectionData.addCpuData(cpuData); + performanceCollectionData.setCpuUsagePercentage( + (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 = stat.split("[\n\t\r ]"); - 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 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/AndroidFatalLogger.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidFatalLogger.java new file mode 100644 index 00000000000..76d6ba99784 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidFatalLogger.java @@ -0,0 +1,66 @@ +package io.sentry.android.core; + +import android.util.Log; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class AndroidFatalLogger implements ILogger { + + private final @NotNull String tag; + + public AndroidFatalLogger() { + this("Sentry"); + } + + public AndroidFatalLogger(final @NotNull String tag) { + this.tag = tag; + } + + @SuppressWarnings("AnnotateFormatMethod") + @Override + public void log( + final @NotNull SentryLevel level, + final @NotNull String message, + final @Nullable Object... args) { + if (args == null || args.length == 0) { + Log.println(toLogcatLevel(level), tag, message); + } else { + Log.println(toLogcatLevel(level), tag, String.format(message, args)); + } + } + + @SuppressWarnings("AnnotateFormatMethod") + @Override + public void log( + final @NotNull SentryLevel level, + final @Nullable Throwable throwable, + final @NotNull String message, + final @Nullable Object... args) { + if (args == null || args.length == 0) { + log(level, message, throwable); + } else { + log(level, String.format(message, args), throwable); + } + } + + @Override + public void log( + final @NotNull SentryLevel level, + final @NotNull String message, + final @Nullable Throwable throwable) { + Log.wtf(tag, message, throwable); + } + + @Override + public boolean isEnabled(@Nullable SentryLevel level) { + return true; + } + + private int toLogcatLevel(final @NotNull SentryLevel sentryLevel) { + return Log.ASSERT; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLogger.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLogger.java index b0f6b8ac928..ef943c16968 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLogger.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLogger.java @@ -26,7 +26,11 @@ public void log( final @NotNull SentryLevel level, final @NotNull String message, final @Nullable Object... args) { - Log.println(toLogcatLevel(level), tag, String.format(message, args)); + if (args == null || args.length == 0) { + Log.println(toLogcatLevel(level), tag, message); + } else { + Log.println(toLogcatLevel(level), tag, String.format(message, args)); + } } @SuppressWarnings("AnnotateFormatMethod") @@ -36,7 +40,11 @@ public void log( final @Nullable Throwable throwable, final @NotNull String message, final @Nullable Object... args) { - log(level, String.format(message, args), throwable); + if (args == null || args.length == 0) { + log(level, message, throwable); + } else { + log(level, String.format(message, args), throwable); + } } @Override 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/AndroidMemoryCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMemoryCollector.java index e43823b8d0d..6775d818b4e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMemoryCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMemoryCollector.java @@ -1,24 +1,22 @@ package io.sentry.android.core; import android.os.Debug; -import io.sentry.ICollector; -import io.sentry.MemoryCollectionData; +import io.sentry.IPerformanceSnapshotCollector; import io.sentry.PerformanceCollectionData; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @ApiStatus.Internal -public class AndroidMemoryCollector implements ICollector { +public class AndroidMemoryCollector implements IPerformanceSnapshotCollector { @Override public void setup() {} @Override public void collect(final @NotNull PerformanceCollectionData performanceCollectionData) { - long now = System.currentTimeMillis(); long usedMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); long usedNativeMemory = Debug.getNativeHeapSize() - Debug.getNativeHeapFreeSize(); - MemoryCollectionData memoryData = new MemoryCollectionData(now, usedMemory, usedNativeMemory); - performanceCollectionData.addMemoryData(memoryData); + performanceCollectionData.setUsedHeapMemory(usedMemory); + performanceCollectionData.setUsedNativeMemory(usedNativeMemory); } } 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 f3cf00f96c5..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,36 +2,67 @@ 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.DefaultTransactionPerformanceCollector; +import io.sentry.CompositePerformanceCollector; +import io.sentry.DeduplicateMultithreadedEventProcessor; +import io.sentry.DefaultCompositePerformanceCollector; +import io.sentry.DefaultVersionDetector; +import io.sentry.IContinuousProfiler; import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ITransactionProfiler; +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; +import io.sentry.ScopeType; import io.sentry.SendFireAndForgetEnvelopeSender; 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; import io.sentry.android.core.internal.modules.AssetsModulesLoader; -import io.sentry.android.core.internal.util.AndroidMainThreadChecker; +import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider; +import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; +import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.distribution.DistributionIntegration; import io.sentry.android.fragment.FragmentLifecycleIntegration; +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter; +import io.sentry.android.replay.ReplayIntegration; import io.sentry.android.timber.SentryTimberIntegration; import io.sentry.cache.PersistingOptionsObserver; import io.sentry.cache.PersistingScopeObserver; import io.sentry.compose.gestures.ComposeGestureTargetLocator; import io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter; +import io.sentry.internal.debugmeta.NoOpDebugMetaLoader; import io.sentry.internal.gestures.GestureTargetLocator; +import io.sentry.internal.modules.NoOpModulesLoader; import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; +import io.sentry.protocol.SentryId; +import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.NoOpEnvelopeCache; +import io.sentry.transport.NoOpTransportGate; import io.sentry.util.LazyEvaluator; import io.sentry.util.Objects; +import io.sentry.util.thread.NoOpThreadChecker; import java.io.File; import java.util.ArrayList; import java.util.List; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; /** @@ -41,6 +72,8 @@ @SuppressWarnings("Convert2MethodRef") // older AGP versions do not support method references final class AndroidOptionsInitializer { + static final long DEFAULT_FLUSH_TIMEOUT_MS = 4000; + static final String SENTRY_COMPOSE_GESTURE_INTEGRATION_CLASS_NAME = "io.sentry.compose.gestures.ComposeGestureTargetLocator"; @@ -80,67 +113,111 @@ static void loadDefaultAndMetadataOptions( final @NotNull BuildInfoProvider buildInfoProvider) { Objects.requireNonNull(context, "The context is required."); - // it returns null if ContextImpl, so let's check for nullability - if (context.getApplicationContext() != null) { - context = context.getApplicationContext(); - } + @NotNull final Context finalContext = ContextUtils.getApplicationContext(context); Objects.requireNonNull(options, "The options object is required."); Objects.requireNonNull(logger, "The ILogger object is required."); // Firstly set the logger, if `debug=true` configured, logging can start asap. options.setLogger(logger); + options.setFatalLogger(new AndroidFatalLogger()); + 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(finalContext, logger, buildInfoProvider)); - ManifestMetadataReader.applyMetadata(context, options, buildInfoProvider); - initializeCacheDirs(context, options); + 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 static void initializeIntegrationsAndProcessors( final @NotNull SentryAndroidOptions options, final @NotNull Context context, - final @NotNull LoadClass loadClass, - final @NotNull ActivityFramesTracker activityFramesTracker) { + final @NotNull io.sentry.util.LoadClass loadClass, + final @NotNull ActivityFramesTracker activityFramesTracker, + final boolean isReplayAvailable) { initializeIntegrationsAndProcessors( options, context, new BuildInfoProvider(new AndroidLogger()), loadClass, - activityFramesTracker); + activityFramesTracker, + isReplayAvailable); } static void initializeIntegrationsAndProcessors( final @NotNull SentryAndroidOptions options, final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider, - final @NotNull LoadClass loadClass, - final @NotNull ActivityFramesTracker activityFramesTracker) { + final @NotNull io.sentry.util.LoadClass loadClass, + final @NotNull ActivityFramesTracker activityFramesTracker, + final boolean isReplayAvailable) { if (options.getCacheDirPath() != null && options.getEnvelopeDiskCache() instanceof NoOpEnvelopeCache) { options.setEnvelopeDiskCache(new AndroidEnvelopeCache(options)); } + if (options.getConnectionStatusProvider() instanceof NoOpConnectionStatusProvider) { + options.setConnectionStatusProvider( + new AndroidConnectionStatusProvider( + context, options, buildInfoProvider, AndroidCurrentDateProvider.getInstance())); + } + + 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.setTransportGate(new AndroidTransportGate(context, options.getLogger())); - final SentryFrameMetricsCollector frameMetricsCollector = - new SentryFrameMetricsCollector(context, options, buildInfoProvider); - options.setTransactionProfiler( - new AndroidTransactionProfiler(context, options, buildInfoProvider, frameMetricsCollector)); - options.setModulesLoader(new AssetsModulesLoader(context, options.getLogger())); - options.setDebugMetaLoader(new AssetsDebugMetaLoader(context, options.getLogger())); - - final boolean isAndroidXScrollViewAvailable = - loadClass.isClassAvailable("androidx.core.view.ScrollingView", options); + 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)); + } + if (options.getDebugMetaLoader() instanceof NoOpDebugMetaLoader) { + options.setDebugMetaLoader(new AssetsDebugMetaLoader(context, options.getLogger())); + } + if (options.getVersionDetector() instanceof NoopVersionDetector) { + options.setVersionDetector(new DefaultVersionDetector(options)); + } + + final @NotNull LazyEvaluator isAndroidXScrollViewAvailable = + loadClass.isClassAvailableLazy("androidx.core.view.ScrollingView", options); final boolean isComposeUpstreamAvailable = loadClass.isClassAvailable(COMPOSE_CLASS_NAME, options); @@ -169,16 +246,156 @@ static void initializeIntegrationsAndProcessors( options.setViewHierarchyExporters(viewHierarchyExporters); } - options.setMainThreadChecker(AndroidMainThreadChecker.getInstance()); - if (options.getCollectors().isEmpty()) { - options.addCollector(new AndroidMemoryCollector()); - options.addCollector(new AndroidCpuCollector(options.getLogger(), buildInfoProvider)); + if (options.getThreadChecker() instanceof NoOpThreadChecker) { + options.setThreadChecker(AndroidThreadChecker.getInstance()); + } + if (options.getSocketTagger() instanceof NoOpSocketTagger) { + options.setSocketTagger(AndroidSocketTagger.getInstance()); } - options.setTransactionPerformanceCollector(new DefaultTransactionPerformanceCollector(options)); - if (options.getCacheDirPath() != null) { - options.addScopeObserver(new PersistingScopeObserver(options)); - options.addOptionsObserver(new PersistingOptionsObserver(options)); + if (options.getPerformanceCollectors().isEmpty()) { + options.addPerformanceCollector(new AndroidMemoryCollector()); + options.addPerformanceCollector(new AndroidCpuCollector(options.getLogger())); + + if (options.isEnablePerformanceV2()) { + options.addPerformanceCollector( + new SpanFrameMetricsCollector( + options, + Objects.requireNonNull( + options.getFrameMetricsCollector(), + "options.getFrameMetricsCollector is required"))); + } + } + if (options.getCompositePerformanceCollector() instanceof NoOpCompositePerformanceCollector) { + 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. + final @Nullable ITransactionProfiler appStartTransactionProfiler; + final @Nullable IContinuousProfiler appStartContinuousProfiler; + try (final @NotNull ISentryLifecycleToken ignored = AppStartMetrics.staticLock.acquire()) { + appStartTransactionProfiler = appStartMetrics.getAppStartProfiler(); + appStartContinuousProfiler = appStartMetrics.getAppStartContinuousProfiler(); + appStartMetrics.setAppStartProfiler(null); + appStartMetrics.setAppStartContinuousProfiler(null); + } + + setupProfiler( + options, + context, + buildInfoProvider, + appStartTransactionProfiler, + appStartContinuousProfiler, + options.getCompositePerformanceCollector()); + } + + /** 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, + final @NotNull BuildInfoProvider buildInfoProvider, + final @Nullable ITransactionProfiler appStartTransactionProfiler, + final @Nullable IContinuousProfiler appStartContinuousProfiler, + 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) { + appStartContinuousProfiler.close(true); + } + if (appStartTransactionProfiler != null) { + options.setTransactionProfiler(appStartTransactionProfiler); + } else { + options.setTransactionProfiler( + new AndroidTransactionProfiler( + context, + options, + buildInfoProvider, + Objects.requireNonNull( + options.getFrameMetricsCollector(), + "options.getFrameMetricsCollector is required"))); + } + } else { + options.setTransactionProfiler(NoOpTransactionProfiler.getInstance()); + // This is a safeguard, but it should never happen, as the app start profiler should be the + // transaction one. + if (appStartTransactionProfiler != null) { + appStartTransactionProfiler.close(); + } + if (appStartContinuousProfiler != null) { + options.setContinuousProfiler(appStartContinuousProfiler); + // If the profiler is running, we start the performance collector too, otherwise we'd miss + // measurements in app launch profiles + final @NotNull SentryId chunkId = appStartContinuousProfiler.getChunkId(); + if (appStartContinuousProfiler.isRunning() && !chunkId.equals(SentryId.EMPTY_ID)) { + performanceCollector.start(chunkId.toString()); + } + } else { + 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."); + } + } } } @@ -186,10 +403,12 @@ static void installDefaultIntegrations( final @NotNull Context context, final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider, - final @NotNull LoadClass loadClass, + final @NotNull io.sentry.util.LoadClass loadClass, final @NotNull ActivityFramesTracker activityFramesTracker, final boolean isFragmentAvailable, - final boolean isTimberAvailable) { + final boolean isTimberAvailable, + final boolean isReplayAvailable, + final boolean isDistributionAvailable) { // Integration MUST NOT cache option values in ctor, as they will be configured later by the // user @@ -206,12 +425,13 @@ static void installDefaultIntegrations( // Integrations are registered in the same order. NDK before adding Watch outbox, // because sentry-native move files around and we don't want to watch that. - final Class sentryNdkClass = - isNdkAvailable(buildInfoProvider) - ? loadClass.loadClass(SENTRY_NDK_CLASS_NAME, options.getLogger()) - : null; + 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()); @@ -227,15 +447,20 @@ static void installDefaultIntegrations( // AppLifecycleIntegration has to be installed before AnrIntegration, because AnrIntegration // relies on AppState set by it options.addIntegration(new AppLifecycleIntegration()); + // AnrIntegration must be installed before ReplayIntegration, as ReplayIntegration relies on + // 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( new ActivityLifecycleIntegration( (Application) context, buildInfoProvider, activityFramesTracker)); - options.addIntegration(new CurrentActivityIntegration((Application) context)); + 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)); } @@ -252,10 +477,21 @@ static void installDefaultIntegrations( } options.addIntegration(new AppComponentsBreadcrumbsIntegration(context)); options.addIntegration(new SystemEventsBreadcrumbsIntegration(context)); - options.addIntegration( - new NetworkBreadcrumbsIntegration(context, buildInfoProvider, options.getLogger())); - options.addIntegration(new TempSensorBreadcrumbsIntegration(context)); - options.addIntegration(new PhoneStateBreadcrumbsIntegration(context)); + options.addIntegration(new NetworkBreadcrumbsIntegration(context, buildInfoProvider)); + if (isReplayAvailable) { + final ReplayIntegration replay = + new ReplayIntegration(context, CurrentDateProvider.getInstance()); + options.addIntegration(replay); + options.setReplayController(replay); + } + if (isDistributionAvailable) { + final DistributionIntegration distribution = new DistributionIntegration((context)); + options.setDistributionController(distribution); + options.addIntegration(distribution); + } + options + .getFeedbackOptions() + .setFormHandler(new SentryAndroidOptions.AndroidUserFeedbackFormHandler()); } /** @@ -268,8 +504,8 @@ private static void readDefaultOptionValues( final @NotNull SentryAndroidOptions options, final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { - final PackageInfo packageInfo = - ContextUtils.getPackageInfo(context, options.getLogger(), buildInfoProvider); + final @Nullable PackageInfo packageInfo = + ContextUtils.getPackageInfo(context, buildInfoProvider); if (packageInfo != null) { // Sets App's release if not set by Manifest if (options.getRelease() == null) { @@ -308,18 +544,11 @@ private static void readDefaultOptionValues( } /** - * Sets the cache dirs like sentry, outbox and sessions + * Retrieve the Sentry cache dir. * * @param context the Application context - * @param options the SentryAndroidOptions */ - private static void initializeCacheDirs( - final @NotNull Context context, final @NotNull SentryAndroidOptions options) { - final File cacheDir = new File(context.getCacheDir(), "sentry"); - options.setCacheDirPath(cacheDir.getAbsolutePath()); - } - - private static boolean isNdkAvailable(final @NotNull BuildInfoProvider buildInfoProvider) { - return buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN; + static @NotNull File getCacheDir(final @NotNull Context context) { + return new File(context.getCacheDir(), "sentry"); } } 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 new file mode 100644 index 00000000000..3f569df5378 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java @@ -0,0 +1,365 @@ +package io.sentry.android.core; + +import android.annotation.SuppressLint; +import android.os.Debug; +import android.os.Process; +import android.os.SystemClock; +import io.sentry.DateUtils; +import io.sentry.ILogger; +import io.sentry.ISentryExecutorService; +import io.sentry.ISentryLifecycleToken; +import io.sentry.PerformanceCollectionData; +import io.sentry.SentryLevel; +import io.sentry.SentryNanotimeDate; +import io.sentry.SentryUUID; +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; +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; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public class AndroidProfiler { + public static class ProfileStartData { + public final long startNanos; + public final long startCpuMillis; + public final @NotNull Date startTimestamp; + + public ProfileStartData( + final long startNanos, final long startCpuMillis, final @NotNull Date startTimestamp) { + this.startNanos = startNanos; + this.startCpuMillis = startCpuMillis; + this.startTimestamp = startTimestamp; + } + } + + public static class ProfileEndData { + public final long endNanos; + public final long endCpuMillis; + public final @NotNull File traceFile; + public final @NotNull Map measurementsMap; + public final boolean didTimeout; + + public ProfileEndData( + final long endNanos, + final long endCpuMillis, + final boolean didTimeout, + final @NotNull File traceFile, + final @NotNull Map measurementsMap) { + this.endNanos = endNanos; + this.traceFile = traceFile; + this.endCpuMillis = endCpuMillis; + this.measurementsMap = measurementsMap; + this.didTimeout = didTimeout; + } + } + + /** + * This appears to correspond to the buffer size of the data part of the file, excluding the key + * part. Once the buffer is full, new records are ignored, but the resulting trace file will be + * valid. + * + *

30 second traces can require a buffer of a few MB. 8MB is the default buffer size for + * [Debug.startMethodTracingSampling], but 3 should be enough for most cases. We can adjust this + * in the future if we notice that traces are being truncated in some applications. + */ + private static final int BUFFER_SIZE_BYTES = 3_000_000; + + private static final int PROFILING_TIMEOUT_MILLIS = 30_000; + private long profileStartNanos = 0; + private final @NotNull File traceFilesDir; + private final int intervalUs; + private @Nullable Future scheduledFinish = null; + private @Nullable File traceFile = null; + private @Nullable String frameMetricsCollectorId; + private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; + private final @NotNull ArrayDeque screenFrameRateMeasurements = + new ArrayDeque<>(); + private final @NotNull ArrayDeque slowFrameRenderMeasurements = + new ArrayDeque<>(); + private final @NotNull ArrayDeque frozenFrameRenderMeasurements = + new ArrayDeque<>(); + private final @NotNull Map measurementsMap = new HashMap<>(); + private final @Nullable LazyEvaluator.Evaluator + timeoutExecutorServiceSupplier; + private final @NotNull ILogger logger; + 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 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.timeoutExecutorServiceSupplier = timeoutExecutorServiceSupplier; + this.frameMetricsCollector = + Objects.requireNonNull(frameMetricsCollector, "SentryFrameMetricsCollector is required"); + } + + @SuppressLint("NewApi") + public @Nullable ProfileStartData start() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + // intervalUs is 0 only if there was a problem in the init + if (intervalUs == 0) { + logger.log( + SentryLevel.WARNING, "Disabling profiling because intervaUs is set to %d", intervalUs); + return null; + } + + if (isRunning) { + logger.log(SentryLevel.WARNING, "Profiling has already started..."); + return null; + } + + // We create a file with a uuid name, so no need to check if it already exists + traceFile = new File(traceFilesDir, SentryUUID.generateSentryId() + ".trace"); + + measurementsMap.clear(); + screenFrameRateMeasurements.clear(); + slowFrameRenderMeasurements.clear(); + frozenFrameRenderMeasurements.clear(); + + frameMetricsCollectorId = + 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) { + // profileStartNanos is calculated through SystemClock.elapsedRealtimeNanos(), + // but frameEndNanos uses System.nanotime(), so we convert it to get the timestamp + // relative to profileStartNanos + final long timestampNanos = new SentryNanotimeDate().nanoTimestamp(); + final long frameTimestampRelativeNanos = + frameEndNanos + - System.nanoTime() + + SystemClock.elapsedRealtimeNanos() + - profileStartNanos; + + // We don't allow negative relative timestamps. + // So we add a check, even if this should never happen. + 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)); + } + } + }); + + // We stop profiling after a timeout to avoid huge profiles to be sent + try { + if (timeoutExecutorServiceSupplier != null) { + scheduledFinish = + timeoutExecutorServiceSupplier + .evaluate() + .schedule(() -> endAndCollect(true, null), PROFILING_TIMEOUT_MILLIS); + } + } catch (RejectedExecutionException e) { + logger.log( + SentryLevel.ERROR, + "Failed to call the executor. Profiling will not be automatically finished. Did you call Sentry.close()?", + e); + } + + profileStartNanos = SystemClock.elapsedRealtimeNanos(); + final @NotNull Date profileStartTimestamp = DateUtils.getCurrentDateTime(); + long profileStartCpuMillis = Process.getElapsedCpuTime(); + + // We don't make any check on the file existence or writeable state, because we don't want to + // make file IO in the main thread. + // We cannot offload the work to the executorService, as if that's very busy, profiles could + // start/stop with a lot of delay and even cause ANRs. + try { + // If there is any problem with the file this method will throw (but it will not throw in + // tests) + Debug.startMethodTracingSampling(traceFile.getPath(), BUFFER_SIZE_BYTES, intervalUs); + isRunning = true; + return new ProfileStartData( + profileStartNanos, profileStartCpuMillis, profileStartTimestamp); + } catch (Throwable e) { + endAndCollect(false, null); + logger.log(SentryLevel.ERROR, "Unable to start a profile: ", e); + isRunning = false; + return null; + } + } + } + + @SuppressLint("NewApi") + public @Nullable ProfileEndData endAndCollect( + final boolean isTimeout, + final @Nullable List performanceCollectionData) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!isRunning) { + logger.log(SentryLevel.WARNING, "Profiler not running"); + return null; + } + + try { + // If there is any problem with the file this method could throw, but the start is also + // wrapped, so this should never happen (except for tests, where this is the only method + // that throws) + Debug.stopMethodTracing(); + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Error while stopping profiling: ", e); + } finally { + isRunning = false; + } + frameMetricsCollector.stopCollection(frameMetricsCollectorId); + + long transactionEndNanos = SystemClock.elapsedRealtimeNanos(); + long transactionEndCpuMillis = Process.getElapsedCpuTime(); + + if (traceFile == null) { + logger.log(SentryLevel.ERROR, "Trace file does not exists"); + return null; + } + + if (!slowFrameRenderMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_SLOW_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, slowFrameRenderMeasurements)); + } + if (!frozenFrameRenderMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_FROZEN_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, frozenFrameRenderMeasurements)); + } + if (!screenFrameRateMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_SCREEN_FRAME_RATES, + new ProfileMeasurement(ProfileMeasurement.UNIT_HZ, screenFrameRateMeasurements)); + } + putPerformanceCollectionDataInMeasurements(performanceCollectionData); + + if (scheduledFinish != null) { + scheduledFinish.cancel(true); + scheduledFinish = null; + } + + return new ProfileEndData( + transactionEndNanos, transactionEndCpuMillis, isTimeout, traceFile, measurementsMap); + } + } + + public void close() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + // we cancel any scheduled work + if (scheduledFinish != null) { + scheduledFinish.cancel(true); + scheduledFinish = null; + } + + // stop profiling if running + if (isRunning) { + endAndCollect(true, null); + } + } + } + + @SuppressLint("NewApi") + private void putPerformanceCollectionDataInMeasurements( + final @Nullable List performanceCollectionData) { + + // This difference is required, since the PerformanceCollectionData timestamps are expressed in + // terms of System.currentTimeMillis() and measurements timestamps require the nanoseconds since + // the beginning, expressed with SystemClock.elapsedRealtimeNanos() + long timestampDiff = + SystemClock.elapsedRealtimeNanos() + - profileStartNanos + - TimeUnit.MILLISECONDS.toNanos(System.currentTimeMillis()); + if (performanceCollectionData != null) { + final @NotNull ArrayDeque memoryUsageMeasurements = + new ArrayDeque<>(performanceCollectionData.size()); + final @NotNull ArrayDeque nativeMemoryUsageMeasurements = + new ArrayDeque<>(performanceCollectionData.size()); + final @NotNull ArrayDeque cpuUsageMeasurements = + new ArrayDeque<>(performanceCollectionData.size()); + + synchronized (performanceCollectionData) { + for (final @NotNull PerformanceCollectionData data : performanceCollectionData) { + final long nanoTimestamp = data.getNanoTimestamp(); + final long relativeStartNs = nanoTimestamp + timestampDiff; + + if (data.hasCpuUsagePercentage()) { + cpuUsageMeasurements.add( + new ProfileMeasurementValue( + relativeStartNs, data.getCpuUsagePercentage(), nanoTimestamp)); + } + if (data.hasUsedHeapMemory()) { + memoryUsageMeasurements.add( + new ProfileMeasurementValue( + relativeStartNs, data.getUsedHeapMemory(), nanoTimestamp)); + } + if (data.hasUsedNativeMemory()) { + nativeMemoryUsageMeasurements.add( + new ProfileMeasurementValue( + relativeStartNs, data.getUsedNativeMemory(), nanoTimestamp)); + } + } + } + + if (!cpuUsageMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_CPU_USAGE, + new ProfileMeasurement(ProfileMeasurement.UNIT_PERCENT, cpuUsageMeasurements)); + } + if (!memoryUsageMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_MEMORY_FOOTPRINT, + new ProfileMeasurement(ProfileMeasurement.UNIT_BYTES, memoryUsageMeasurements)); + } + if (!nativeMemoryUsageMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT, + new ProfileMeasurement(ProfileMeasurement.UNIT_BYTES, nativeMemoryUsageMeasurements)); + } + } + } + + boolean isRunning() { + return isRunning; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidSocketTagger.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidSocketTagger.java new file mode 100644 index 00000000000..7c4afd309f2 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidSocketTagger.java @@ -0,0 +1,30 @@ +package io.sentry.android.core; + +import android.net.TrafficStats; +import io.sentry.ISocketTagger; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class AndroidSocketTagger implements ISocketTagger { + + // just a random number to tag outgoing traffic from the Sentry SDK + private static final int SENTRY_TAG = 0xF001; + + private static final AndroidSocketTagger instance = new AndroidSocketTagger(); + + private AndroidSocketTagger() {} + + public static AndroidSocketTagger getInstance() { + return instance; + } + + @Override + public void tagSockets() { + TrafficStats.setThreadStatsTag(SENTRY_TAG); + } + + @Override + public void untagSockets() { + TrafficStats.clearThreadStatsTag(); + } +} 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 30e46baeb1a..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 @@ -1,84 +1,57 @@ package io.sentry.android.core; -import static android.content.Context.ACTIVITY_SERVICE; import static java.util.concurrent.TimeUnit.SECONDS; import android.annotation.SuppressLint; -import android.app.ActivityManager; import android.content.Context; import android.os.Build; -import android.os.Debug; -import android.os.Process; -import android.os.SystemClock; -import io.sentry.CpuCollectionData; -import io.sentry.HubAdapter; -import io.sentry.IHub; +import io.sentry.DateUtils; +import io.sentry.ILogger; +import io.sentry.ISentryExecutorService; +import io.sentry.ISentryLifecycleToken; import io.sentry.ITransaction; import io.sentry.ITransactionProfiler; -import io.sentry.MemoryCollectionData; import io.sentry.PerformanceCollectionData; import io.sentry.ProfilingTraceData; import io.sentry.ProfilingTransactionData; +import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.android.core.internal.util.CpuInfoUtils; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; -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; import java.util.ArrayList; -import java.util.HashMap; +import java.util.Date; import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.Future; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.TimeUnit; +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 { - - /** - * This appears to correspond to the buffer size of the data part of the file, excluding the key - * part. Once the buffer is full, new records are ignored, but the resulting trace file will be - * valid. - * - *

30 second traces can require a buffer of a few MB. 8MB is the default buffer size for - * [Debug.startMethodTracingSampling], but 3 should be enough for most cases. We can adjust this - * in the future if we notice that traces are being truncated in some applications. - */ - private static final int BUFFER_SIZE_BYTES = 3_000_000; - - private static final int PROFILING_TIMEOUT_MILLIS = 30_000; - - private int intervalUs; - private @Nullable File traceFile = null; - private @Nullable File traceFilesDir = null; - private @Nullable Future scheduledFinish = null; - private volatile @Nullable ProfilingTraceData timedOutProfilingData = null; private final @NotNull Context context; - private final @NotNull SentryAndroidOptions options; - private final @NotNull IHub hub; + private final @NotNull ILogger logger; + private final @Nullable String profilingTracesDirPath; + private final boolean isProfilingEnabled; + private final int profilingTracesHz; + private final @NotNull LazyEvaluator.Evaluator executorServiceSupplier; private final @NotNull BuildInfoProvider buildInfoProvider; - private long transactionStartNanos = 0; - private long profileStartCpuMillis = 0; private boolean isInitialized = false; - private int transactionsCounter = 0; - private @Nullable String frameMetricsCollectorId; + private final @NotNull AtomicBoolean isRunning = new AtomicBoolean(false); private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; - private @Nullable ProfilingTransactionData currentProfilingTransactionData; - private final @NotNull ArrayDeque screenFrameRateMeasurements = - new ArrayDeque<>(); - private final @NotNull ArrayDeque slowFrameRenderMeasurements = - new ArrayDeque<>(); - private final @NotNull ArrayDeque frozenFrameRenderMeasurements = - new ArrayDeque<>(); - private final @NotNull Map measurementsMap = new HashMap<>(); + private volatile @Nullable ProfilingTransactionData currentProfilingTransactionData; - private @Nullable ITransaction currentTransaction = null; + /** + * 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; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); public AndroidTransactionProfiler( final @NotNull Context context, @@ -87,25 +60,59 @@ public AndroidTransactionProfiler( final @NotNull SentryFrameMetricsCollector frameMetricsCollector) { this( context, - sentryAndroidOptions, buildInfoProvider, frameMetricsCollector, - HubAdapter.getInstance()); + sentryAndroidOptions.getLogger(), + sentryAndroidOptions.getProfilingTracesDirPath(), + sentryAndroidOptions.isProfilingEnabled(), + sentryAndroidOptions.getProfilingTracesHz(), + () -> sentryAndroidOptions.getExecutorService()); + } + + 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 ISentryExecutorService executorService) { + this( + context, + buildInfoProvider, + frameMetricsCollector, + logger, + profilingTracesDirPath, + isProfilingEnabled, + profilingTracesHz, + () -> executorService); } public AndroidTransactionProfiler( final @NotNull Context context, - final @NotNull SentryAndroidOptions sentryAndroidOptions, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull SentryFrameMetricsCollector frameMetricsCollector, - final @NotNull IHub hub) { - this.context = Objects.requireNonNull(context, "The application context is required"); - this.options = Objects.requireNonNull(sentryAndroidOptions, "SentryAndroidOptions is required"); - this.hub = Objects.requireNonNull(hub, "Hub is required"); + 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"); + this.logger = Objects.requireNonNull(logger, "ILogger is required"); this.frameMetricsCollector = Objects.requireNonNull(frameMetricsCollector, "SentryFrameMetricsCollector is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "The BuildInfoProvider is required."); + this.profilingTracesDirPath = profilingTracesDirPath; + this.isProfilingEnabled = isProfilingEnabled; + this.profilingTracesHz = profilingTracesHz; + this.executorServiceSupplier = + Objects.requireNonNull( + executorServiceSupplier, "A supplier for ISentryExecutorService is required."); + this.profileStartTimestamp = DateUtils.getCurrentDateTime(); } private void init() { @@ -114,317 +121,184 @@ private void init() { return; } isInitialized = true; - final String tracesFilesDirPath = options.getProfilingTracesDirPath(); - if (!options.isProfilingEnabled()) { - options.getLogger().log(SentryLevel.INFO, "Profiling is disabled in options."); + + if (!isProfilingEnabled) { + logger.log(SentryLevel.INFO, "Profiling is disabled in options."); return; } - if (tracesFilesDirPath == null) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "Disabling profiling because no profiling traces dir path is defined in options."); + if (profilingTracesDirPath == null) { + logger.log( + SentryLevel.WARNING, + "Disabling profiling because no profiling traces dir path is defined in options."); return; } - final int intervalHz = options.getProfilingTracesHz(); - if (intervalHz <= 0) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "Disabling profiling because trace rate is set to %d", - intervalHz); + if (profilingTracesHz <= 0) { + logger.log( + SentryLevel.WARNING, + "Disabling profiling because trace rate is set to %d", + profilingTracesHz); return; } - intervalUs = (int) SECONDS.toMicros(1) / intervalHz; - traceFilesDir = new File(tracesFilesDirPath); + + profiler = + new AndroidProfiler( + profilingTracesDirPath, + (int) SECONDS.toMicros(1) / profilingTracesHz, + frameMetricsCollector, + executorServiceSupplier, + logger); } @Override - public synchronized void onTransactionStart(final @NotNull ITransaction transaction) { - // Debug.startMethodTracingSampling() is only available since Lollipop - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) return; - - // Let's initialize trace folder and profiling interval - init(); - - // traceFilesDir is null or intervalUs is 0 only if there was a problem in the init, but - // we already logged that - if (traceFilesDir == null || intervalUs == 0) { - return; - } + public void start() { + // 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; - transactionsCounter++; // When the first transaction is starting, we can start profiling - if (transactionsCounter == 1) { - if (onFirstTransactionStarted(transaction)) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Transaction %s (%s) started and being profiled.", - transaction.getName(), - transaction.getSpanContext().getTraceId().toString()); + if (!isRunning.getAndSet(true)) { + // Let's initialize trace folder and profiling interval + init(); + + if (onFirstStart()) { + logger.log(SentryLevel.DEBUG, "Profiler started."); + } else { + // 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); + } } - } else { - transactionsCounter--; - options - .getLogger() - .log( - SentryLevel.WARNING, - "A transaction is already being profiled. Transaction %s (%s) will be ignored.", - transaction.getName(), - transaction.getSpanContext().getTraceId().toString()); } } @SuppressLint("NewApi") - private boolean onFirstTransactionStarted(final @NotNull ITransaction transaction) { - // We create a file with a uuid name, so no need to check if it already exists - traceFile = new File(traceFilesDir, UUID.randomUUID() + ".trace"); - - measurementsMap.clear(); - screenFrameRateMeasurements.clear(); - slowFrameRenderMeasurements.clear(); - frozenFrameRenderMeasurements.clear(); - - frameMetricsCollectorId = - frameMetricsCollector.startCollection( - new SentryFrameMetricsCollector.FrameMetricsCollectorListener() { - final long nanosInSecond = TimeUnit.SECONDS.toNanos(1); - final long frozenFrameThresholdNanos = TimeUnit.MILLISECONDS.toNanos(700); - float lastRefreshRate = 0; - - @Override - public void onFrameMetricCollected( - final long frameEndNanos, final long durationNanos, float refreshRate) { - // transactionStartNanos is calculated through SystemClock.elapsedRealtimeNanos(), - // but frameEndNanos uses System.nanotime(), so we convert it to get the timestamp - // relative to transactionStartNanos - final long frameTimestampRelativeNanos = - frameEndNanos - - System.nanoTime() - + SystemClock.elapsedRealtimeNanos() - - transactionStartNanos; - - // We don't allow negative relative timestamps. - // So we add a check, even if this should never happen. - if (frameTimestampRelativeNanos < 0) { - return; - } - // Most frames take just a few nanoseconds longer than the optimal calculated - // duration. - // Therefore we subtract one, because otherwise almost all frames would be slow. - boolean isSlow = durationNanos > nanosInSecond / (refreshRate - 1); - float newRefreshRate = (int) (refreshRate * 100) / 100F; - if (durationNanos > frozenFrameThresholdNanos) { - frozenFrameRenderMeasurements.addLast( - new ProfileMeasurementValue(frameTimestampRelativeNanos, durationNanos)); - } else if (isSlow) { - slowFrameRenderMeasurements.addLast( - new ProfileMeasurementValue(frameTimestampRelativeNanos, durationNanos)); - } - if (newRefreshRate != lastRefreshRate) { - lastRefreshRate = newRefreshRate; - screenFrameRateMeasurements.addLast( - new ProfileMeasurementValue(frameTimestampRelativeNanos, newRefreshRate)); - } - } - }); - - currentTransaction = transaction; - - // We stop profiling after a timeout to avoid huge profiles to be sent - try { - scheduledFinish = - options - .getExecutorService() - .schedule( - () -> timedOutProfilingData = onTransactionFinish(transaction, true, null), - PROFILING_TIMEOUT_MILLIS); - } catch (RejectedExecutionException e) { - options - .getLogger() - .log( - SentryLevel.ERROR, - "Failed to call the executor. Profiling will not be automatically finished. Did you call Sentry.close()?", - e); + private boolean onFirstStart() { + // init() didn't create profiler, should never happen + if (profiler == null) { + return false; } - transactionStartNanos = SystemClock.elapsedRealtimeNanos(); - profileStartCpuMillis = Process.getElapsedCpuTime(); - - currentProfilingTransactionData = - new ProfilingTransactionData(transaction, transactionStartNanos, profileStartCpuMillis); - - // We don't make any check on the file existence or writeable state, because we don't want to - // make file IO in the main thread. - // We cannot offload the work to the executorService, as if that's very busy, profiles could - // start/stop with a lot of delay and even cause ANRs. - try { - // If there is any problem with the file this method will throw (but it will not throw in - // tests) - Debug.startMethodTracingSampling(traceFile.getPath(), BUFFER_SIZE_BYTES, intervalUs); - return true; - } catch (Throwable e) { - onTransactionFinish(transaction, null); - options.getLogger().log(SentryLevel.ERROR, "Unable to start a profile: ", e); + final AndroidProfiler.ProfileStartData startData = profiler.start(); + // check if profiling started + if (startData == null) { return false; } + profileStartNanos = startData.startNanos; + profileStartCpuMillis = startData.startCpuMillis; + profileStartTimestamp = startData.startTimestamp; + return true; } @Override - public @Nullable synchronized ProfilingTraceData onTransactionFinish( - final @NotNull ITransaction transaction, - final @Nullable List performanceCollectionData) { + public void bindTransaction(final @NotNull ITransaction transaction) { + // 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); + } + } + } + } - return onTransactionFinish(transaction, false, performanceCollectionData); + @Override + public @Nullable ProfilingTraceData onTransactionFinish( + final @NotNull ITransaction transaction, + final @Nullable List performanceCollectionData, + final @NotNull SentryOptions options) { + return onTransactionFinish( + transaction.getName(), + transaction.getEventId().toString(), + transaction.getSpanContext().getTraceId().toString(), + false, + performanceCollectionData, + options); } @SuppressLint("NewApi") - private @Nullable synchronized ProfilingTraceData onTransactionFinish( - final @NotNull ITransaction transaction, + private @Nullable ProfilingTraceData onTransactionFinish( + final @NotNull String transactionName, + final @NotNull String transactionId, + final @NotNull String traceId, final boolean isTimeout, - final @Nullable List performanceCollectionData) { + final @Nullable List performanceCollectionData, + final @NotNull SentryOptions options) { - // onTransactionStart() is only available since Lollipop + // onTransactionStart() is only available since Lollipop_MR1 // and SystemClock.elapsedRealtimeNanos() since Jelly Bean - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) return null; - - final ProfilingTraceData profilingData = timedOutProfilingData; - - // Transaction finished, but it's not in the current profile - if (currentProfilingTransactionData == null - || !currentProfilingTransactionData.getId().equals(transaction.getEventId().toString())) { - // We check if we cached a profiling data due to a timeout with this profile in it - // If so, we return it back, otherwise we would simply lose it - if (profilingData != null) { - if (profilingData.getTransactionId().equals(transaction.getEventId().toString())) { - timedOutProfilingData = null; - return profilingData; - } else { - // Another transaction is finishing before the timed out one - options - .getLogger() - .log( - SentryLevel.INFO, - "A timed out profiling data exists, but the finishing transaction %s (%s) is not part of it", - transaction.getName(), - transaction.getSpanContext().getTraceId().toString()); - return null; - } - } - // A transaction is finishing, but it's not profiled. We can skip it - options - .getLogger() - .log( - SentryLevel.INFO, - "Transaction %s (%s) finished, but was not currently being profiled. Skipping", - transaction.getName(), - transaction.getSpanContext().getTraceId().toString()); - return null; - } + // and SUPPORTED_ABIS since KITKAT + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP_MR1) return null; - if (transactionsCounter > 0) { - transactionsCounter--; + // check if profiler was created + if (profiler == null) { + return null; } - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Transaction %s (%s) finished.", - transaction.getName(), - transaction.getSpanContext().getTraceId().toString()); - - if (transactionsCounter != 0 && !isTimeout) { - // We notify the data referring to this transaction that it finished - if (currentProfilingTransactionData != null) { - currentProfilingTransactionData.notifyFinish( - SystemClock.elapsedRealtimeNanos(), - transactionStartNanos, - Process.getElapsedCpuTime(), - profileStartCpuMillis); + final ProfilingTransactionData txData; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + txData = currentProfilingTransactionData; + + // Transaction finished, but it's not in the current profile + if (txData == null || !txData.getId().equals(transactionId)) { + // A transaction is finishing, but it's not profiled. We can skip it + logger.log( + SentryLevel.INFO, + "Transaction %s (%s) finished, but was not currently being profiled. Skipping", + transactionName, + traceId); + return null; } - return null; + currentProfilingTransactionData = null; } - try { - // If there is any problem with the file this method could throw, but the start is also - // wrapped, so this should never happen (except for tests, where this is the only method that - // throws) - Debug.stopMethodTracing(); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error while stopping profiling: ", e); - } - frameMetricsCollector.stopCollection(frameMetricsCollectorId); + logger.log(SentryLevel.DEBUG, "Transaction %s (%s) finished.", transactionName, traceId); - long transactionEndNanos = SystemClock.elapsedRealtimeNanos(); - long transactionEndCpuMillis = Process.getElapsedCpuTime(); - long transactionDurationNanos = transactionEndNanos - transactionStartNanos; + final AndroidProfiler.ProfileEndData endData = + profiler.endAndCollect(false, performanceCollectionData); - 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; - currentTransaction = null; - - if (scheduledFinish != null) { - scheduledFinish.cancel(true); - scheduledFinish = null; - } + isRunning.set(false); - if (traceFile == null) { - options.getLogger().log(SentryLevel.ERROR, "Trace file does not exists"); + // check if profiler end successfully + if (endData == null) { return null; } - String totalMem = "0"; - ActivityManager.MemoryInfo memInfo = getMemInfo(); - if (memInfo != null) { - totalMem = Long.toString(memInfo.totalMem); - } - String[] abis = Build.SUPPORTED_ABIS; - - // 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( - transactionEndNanos, - transactionStartNanos, - transactionEndCpuMillis, - profileStartCpuMillis); - } + long transactionDurationNanos = endData.endNanos - profileStartNanos; - if (!slowFrameRenderMeasurements.isEmpty()) { - measurementsMap.put( - ProfileMeasurement.ID_SLOW_FRAME_RENDERS, - new ProfileMeasurement(ProfileMeasurement.UNIT_NANOSECONDS, slowFrameRenderMeasurements)); - } - if (!frozenFrameRenderMeasurements.isEmpty()) { - measurementsMap.put( - ProfileMeasurement.ID_FROZEN_FRAME_RENDERS, - new ProfileMeasurement( - ProfileMeasurement.UNIT_NANOSECONDS, frozenFrameRenderMeasurements)); - } - if (!screenFrameRateMeasurements.isEmpty()) { - measurementsMap.put( - ProfileMeasurement.ID_SCREEN_FRAME_RATES, - new ProfileMeasurement(ProfileMeasurement.UNIT_HZ, screenFrameRateMeasurements)); + final @NotNull List transactionList = new ArrayList<>(1); + transactionList.add(txData); + txData.notifyFinish( + endData.endNanos, profileStartNanos, endData.endCpuMillis, profileStartCpuMillis); + + String totalMem = "0"; + final @Nullable Long memory = + (options instanceof SentryAndroidOptions) + ? DeviceInfoUtil.getInstance(context, (SentryAndroidOptions) options).getTotalMemory() + : null; + if (memory != null) { + totalMem = Long.toString(memory); } - putPerformanceCollectionDataInMeasurements(performanceCollectionData); + 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( - traceFile, + endData.traceFile, + profileStartTimestamp, transactionList, - transaction, + transactionName, + transactionId, + traceId, Long.toString(transactionDurationNanos), buildInfoProvider.getSdkInfoVersion(), abis != null && abis.length > 0 ? abis[0] : "", @@ -437,113 +311,37 @@ public void onFrameMetricCollected( options.getProguardUuid(), options.getRelease(), options.getEnvironment(), - isTimeout + (endData.didTimeout || isTimeout) ? ProfilingTraceData.TRUNCATION_REASON_TIMEOUT : ProfilingTraceData.TRUNCATION_REASON_NORMAL, - measurementsMap); + endData.measurementsMap); } - @SuppressLint("NewApi") - private void putPerformanceCollectionDataInMeasurements( - final @Nullable List performanceCollectionData) { - - // onTransactionStart() is only available since Lollipop - // and SystemClock.elapsedRealtimeNanos() since Jelly Bean - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) { - return; - } - - // This difference is required, since the PerformanceCollectionData timestamps are expressed in - // terms of System.currentTimeMillis() and measurements timestamps require the nanoseconds since - // the beginning, expressed with SystemClock.elapsedRealtimeNanos() - long timestampDiff = - SystemClock.elapsedRealtimeNanos() - - transactionStartNanos - - TimeUnit.MILLISECONDS.toNanos(System.currentTimeMillis()); - if (performanceCollectionData != null) { - final @NotNull ArrayDeque memoryUsageMeasurements = - new ArrayDeque<>(performanceCollectionData.size()); - final @NotNull ArrayDeque nativeMemoryUsageMeasurements = - new ArrayDeque<>(performanceCollectionData.size()); - final @NotNull ArrayDeque cpuUsageMeasurements = - new ArrayDeque<>(performanceCollectionData.size()); - for (PerformanceCollectionData performanceData : performanceCollectionData) { - CpuCollectionData cpuData = performanceData.getCpuData(); - MemoryCollectionData memoryData = performanceData.getMemoryData(); - if (cpuData != null) { - cpuUsageMeasurements.add( - new ProfileMeasurementValue( - TimeUnit.MILLISECONDS.toNanos(cpuData.getTimestampMillis()) + timestampDiff, - cpuData.getCpuUsagePercentage())); - } - if (memoryData != null && memoryData.getUsedHeapMemory() > -1) { - memoryUsageMeasurements.add( - new ProfileMeasurementValue( - TimeUnit.MILLISECONDS.toNanos(memoryData.getTimestampMillis()) + timestampDiff, - memoryData.getUsedHeapMemory())); - } - if (memoryData != null && memoryData.getUsedNativeMemory() > -1) { - nativeMemoryUsageMeasurements.add( - new ProfileMeasurementValue( - TimeUnit.MILLISECONDS.toNanos(memoryData.getTimestampMillis()) + timestampDiff, - memoryData.getUsedNativeMemory())); - } - } - if (!cpuUsageMeasurements.isEmpty()) { - measurementsMap.put( - ProfileMeasurement.ID_CPU_USAGE, - new ProfileMeasurement(ProfileMeasurement.UNIT_PERCENT, cpuUsageMeasurements)); - } - if (!memoryUsageMeasurements.isEmpty()) { - measurementsMap.put( - ProfileMeasurement.ID_MEMORY_FOOTPRINT, - new ProfileMeasurement(ProfileMeasurement.UNIT_BYTES, memoryUsageMeasurements)); - } - if (!nativeMemoryUsageMeasurements.isEmpty()) { - measurementsMap.put( - ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT, - new ProfileMeasurement(ProfileMeasurement.UNIT_BYTES, nativeMemoryUsageMeasurements)); - } - } + @Override + public boolean isRunning() { + return isRunning.get(); } @Override public void close() { - // we cancel any scheduled work - if (scheduledFinish != null) { - scheduledFinish.cancel(true); - scheduledFinish = null; - } + final @Nullable ProfilingTransactionData txData = currentProfilingTransactionData; // we stop profiling - if (currentTransaction != null) { - onTransactionFinish(currentTransaction, true, null); + if (txData != null) { + onTransactionFinish( + txData.getName(), + txData.getId(), + txData.getTraceId(), + true, + null, + ScopesAdapter.getInstance().getOptions()); } - } + // 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); - /** - * Get MemoryInfo object representing the memory state of the application. - * - * @return MemoryInfo object representing the memory state of the application - */ - private @Nullable ActivityManager.MemoryInfo getMemInfo() { - try { - ActivityManager actManager = (ActivityManager) context.getSystemService(ACTIVITY_SERVICE); - ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo(); - if (actManager != null) { - actManager.getMemoryInfo(memInfo); - return memInfo; - } - options.getLogger().log(SentryLevel.INFO, "Error getting MemoryInfo."); - return null; - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting MemoryInfo.", e); - return null; + // we have to first stop profiling otherwise we would lost the last profile + if (profiler != null) { + profiler.close(); } } - - @TestOnly - @Nullable - ITransaction getCurrentTransaction() { - return currentTransaction; - } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransportGate.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransportGate.java index fd9215970a3..fa138a802f2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransportGate.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransportGate.java @@ -1,29 +1,26 @@ package io.sentry.android.core; -import android.content.Context; -import io.sentry.ILogger; -import io.sentry.android.core.internal.util.ConnectivityChecker; +import io.sentry.IConnectionStatusProvider; +import io.sentry.SentryOptions; import io.sentry.transport.ITransportGate; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; final class AndroidTransportGate implements ITransportGate { - private final Context context; - private final @NotNull ILogger logger; + private final @NotNull SentryOptions options; - AndroidTransportGate(final @NotNull Context context, final @NotNull ILogger logger) { - this.context = context; - this.logger = logger; + AndroidTransportGate(final @NotNull SentryOptions options) { + this.options = options; } @Override public boolean isConnected() { - return isConnected(ConnectivityChecker.getConnectionStatus(context, logger)); + return isConnected(options.getConnectionStatusProvider().getConnectionStatus()); } @TestOnly - boolean isConnected(final @NotNull ConnectivityChecker.Status status) { + boolean isConnected(final @NotNull IConnectionStatusProvider.ConnectionStatus status) { // let's assume its connected if there's no permission or something as we can't really know // whether is online or not. switch (status) { 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 4c6ac6373af..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 @@ -1,9 +1,12 @@ package io.sentry.android.core; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + import android.annotation.SuppressLint; import android.content.Context; import io.sentry.Hint; -import io.sentry.IHub; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.SentryEvent; import io.sentry.SentryLevel; @@ -12,6 +15,7 @@ import io.sentry.hints.AbnormalExit; import io.sentry.hints.TransactionEnd; import io.sentry.protocol.Mechanism; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.HintUtils; import io.sentry.util.Objects; import java.io.Closeable; @@ -27,9 +31,11 @@ public final class AnrIntegration implements Integration, Closeable { private final @NotNull Context context; + private boolean isClosed = false; + private final @NotNull AutoClosableReentrantLock startLock = new AutoClosableReentrantLock(); public AnrIntegration(final @NotNull Context context) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); } /** @@ -41,48 +47,70 @@ public AnrIntegration(final @NotNull Context context) { private @Nullable SentryOptions options; - private static final @NotNull Object watchDogLock = new Object(); + protected static final @NotNull AutoClosableReentrantLock watchDogLock = + new AutoClosableReentrantLock(); @Override - public final void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { + public final void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { this.options = Objects.requireNonNull(options, "SentryOptions is required"); - register(hub, (SentryAndroidOptions) options); + register(scopes, (SentryAndroidOptions) options); } - private void register(final @NotNull IHub hub, final @NotNull SentryAndroidOptions options) { + private void register( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { options .getLogger() .log(SentryLevel.DEBUG, "AnrIntegration enabled: %s", options.isAnrEnabled()); if (options.isAnrEnabled()) { - synchronized (watchDogLock) { - if (anrWatchDog == null) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "ANR timeout in milliseconds: %d", - options.getAnrTimeoutIntervalMillis()); - - anrWatchDog = - new ANRWatchDog( - options.getAnrTimeoutIntervalMillis(), - options.isAnrReportInDebug(), - error -> reportANR(hub, options, error), - options.getLogger(), - context); - anrWatchDog.start(); - - options.getLogger().log(SentryLevel.DEBUG, "AnrIntegration installed."); - addIntegrationToSdkVersion(); - } + addIntegrationToSdkVersion("Anr"); + try { + options + .getExecutorService() + .submit( + () -> { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { + if (!isClosed) { + startAnrWatchdog(scopes, options); + } + } + }); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Failed to start AnrIntegration on executor thread.", e); + } + } + } + + private void startAnrWatchdog( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + try (final @NotNull ISentryLifecycleToken ignored = watchDogLock.acquire()) { + if (anrWatchDog == null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "ANR timeout in milliseconds: %d", + options.getAnrTimeoutIntervalMillis()); + + anrWatchDog = + new ANRWatchDog( + options.getAnrTimeoutIntervalMillis(), + options.isAnrReportInDebug(), + error -> reportANR(scopes, options, error), + options.getLogger(), + context); + anrWatchDog.start(); + + options.getLogger().log(SentryLevel.DEBUG, "AnrIntegration installed."); } } } @TestOnly void reportANR( - final @NotNull IHub hub, + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options, final @NotNull ApplicationNotResponding error) { options.getLogger().log(SentryLevel.INFO, "ANR triggered with message: %s", error.getMessage()); @@ -98,7 +126,7 @@ void reportANR( final AnrHint anrHint = new AnrHint(isAppInBackground); final Hint hint = HintUtils.createWithTypeCheckHint(anrHint); - hub.captureEvent(event, hint); + scopes.captureEvent(event, hint); } private @NotNull Throwable buildAnrThrowable( @@ -111,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 @@ -126,7 +161,10 @@ ANRWatchDog getANRWatchDog() { @Override public void close() throws IOException { - synchronized (watchDogLock) { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { + isClosed = true; + } + try (final @NotNull ISentryLifecycleToken ignored = watchDogLock.acquire()) { if (anrWatchDog != null) { anrWatchDog.interrupt(); anrWatchDog = null; @@ -161,5 +199,10 @@ public String mechanism() { public boolean ignoreCurrentThread() { return true; } + + @Override + public @Nullable Long timestamp() { + return null; + } } } 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 6a4b7edfa1c..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ /dev/null @@ -1,627 +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.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.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 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.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.User; -import io.sentry.util.HintUtils; -import java.util.ArrayList; -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; - - public AnrV2EventProcessor( - final @NotNull Context context, - final @NotNull SentryAndroidOptions options, - final @NotNull BuildInfoProvider buildInfoProvider) { - this.context = context; - this.options = options; - this.buildInfoProvider = buildInfoProvider; - - final SentryStackTraceFactory sentryStackTraceFactory = - new SentryStackTraceFactory(this.options); - - sentryExceptionFactory = new SentryExceptionFactory(sentryStackTraceFactory); - } - - @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); - - backfillOptions(event, unwrappedHint); - - setStaticValues(event); - - return event; - } - - // region scope persisted values - private void backfillScope(final @NotNull SentryEvent event) { - setRequest(event); - setUser(event); - setScopeTags(event); - setBreadcrumbs(event); - setExtras(event); - setContexts(event); - setTransaction(event); - setFingerprints(event); - setLevel(event); - setTrace(event); - } - - private void setTrace(final @NotNull SentryEvent event) { - final SpanContext spanContext = - PersistingScopeObserver.read(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 = - PersistingScopeObserver.read(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) PersistingScopeObserver.read(options, FINGERPRINT_FILENAME, List.class); - if (event.getFingerprints() == null) { - event.setFingerprints(fingerprint); - } - } - - private void setTransaction(final @NotNull SentryEvent event) { - final String transaction = - PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String.class); - if (event.getTransaction() == null) { - event.setTransaction(transaction); - } - } - - private void setContexts(final @NotNull SentryBaseEvent event) { - final Contexts persistedContexts = - PersistingScopeObserver.read(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) PersistingScopeObserver.read(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) - PersistingScopeObserver.read( - options, BREADCRUMBS_FILENAME, List.class, new Breadcrumb.Deserializer()); - if (breadcrumbs == null) { - return; - } - if (event.getBreadcrumbs() == null) { - event.setBreadcrumbs(new ArrayList<>(breadcrumbs)); - } else { - event.getBreadcrumbs().addAll(breadcrumbs); - } - } - - @SuppressWarnings("unchecked") - private void setScopeTags(final @NotNull SentryBaseEvent event) { - final Map tags = - (Map) - PersistingScopeObserver.read(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 = PersistingScopeObserver.read(options, USER_FILENAME, User.class); - event.setUser(user); - } - } - - private void setRequest(final @NotNull SentryBaseEvent event) { - if (event.getRequest() == null) { - final Request request = - PersistingScopeObserver.read(options, REQUEST_FILENAME, Request.class); - event.setRequest(request); - } - } - - // 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, options.getLogger())); - // 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, options.getLogger(), 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); - } - } - - 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 - - // 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) { - if (options.isSendDefaultPii()) { - if (event.getUser() == null) { - final User user = new User(); - user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); - event.setUser(user); - } else if (event.getUser().getIpAddress() == null) { - event.getUser().setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); - } - } - - // userId should be set even if event is Cached as the userId is static and won't change anyway. - final User user = event.getUser(); - if (user == null) { - event.setUser(getDefaultUser()); - } else if (user.getId() == null) { - user.setId(getDeviceId()); - } - } - - /** - * Sets the default user which contains only the userId. - * - * @return the User object - */ - private @NotNull User getDefaultUser() { - User user = new User(); - user.setId(getDeviceId()); - - return user; - } - - 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 Map sideLoadedInfo = - ContextUtils.getSideLoadedInfo(context, options.getLogger(), buildInfoProvider); - - if (sideLoadedInfo != null) { - for (final Map.Entry entry : sideLoadedInfo.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(); - if (options.isSendDefaultPii()) { - device.setName(ContextUtils.getDeviceName(context, buildInfoProvider)); - } - 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(buildInfoProvider)); - - 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; - } - - @SuppressLint("NewApi") - private @NotNull Long getMemorySize(final @NotNull ActivityManager.MemoryInfo memInfo) { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN) { - return memInfo.totalMem; - } - // using Runtime as a fallback - return java.lang.Runtime.getRuntime().totalMemory(); // JVM in bytes too - } - - private void mergeOS(final @NotNull SentryBaseEvent event) { - final OperatingSystem currentOS = event.getContexts().getOperatingSystem(); - final OperatingSystem androidOS = 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); - } - } - - private @NotNull OperatingSystem getOperatingSystem() { - OperatingSystem os = new OperatingSystem(); - os.setName("Android"); - os.setVersion(Build.VERSION.RELEASE); - os.setBuild(Build.DISPLAY); - - try { - os.setKernelVersion(ContextUtils.getKernelVersion(options.getLogger())); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting OperatingSystem.", e); - } - - return os; - } - // 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 f3d257d225b..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 @@ -1,5 +1,7 @@ package io.sentry.android.core; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + import android.annotation.SuppressLint; import android.app.ActivityManager; import android.app.ApplicationExitInfo; @@ -7,8 +9,8 @@ import io.sentry.Attachment; import io.sentry.DateUtils; import io.sentry.Hint; -import io.sentry.IHub; import io.sentry.ILogger; +import io.sentry.IScopes; import io.sentry.Integration; import io.sentry.SentryEvent; import io.sentry.SentryLevel; @@ -16,11 +18,13 @@ 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; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryThread; @@ -30,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; @@ -46,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; @@ -61,13 +58,13 @@ public AnrV2Integration(final @NotNull Context context) { AnrV2Integration( final @NotNull Context context, final @NotNull ICurrentDateProvider dateProvider) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); this.dateProvider = dateProvider; } @SuppressLint("NewApi") // we do the check in the AnrIntegrationFactory @Override - public void register(@NotNull IHub hub, @NotNull SentryOptions options) { + public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, @@ -88,12 +85,14 @@ public void register(@NotNull IHub hub, @NotNull SentryOptions options) { try { options .getExecutorService() - .submit(new AnrProcessor(context, hub, 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(); + addIntegrationToSdkVersion("AnrV2"); } } @@ -104,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 IHub hub; private final @NotNull SentryAndroidOptions options; - private final long threshold; - - AnrProcessor( - final @NotNull Context context, - final @NotNull IHub hub, - final @NotNull SentryAndroidOptions options, - final @NotNull ICurrentDateProvider dateProvider) { - this.context = context; - this.hub = hub; + + 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 = @@ -243,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( @@ -265,6 +169,14 @@ private void reportAsSentryEvent( event.setMessage(sentryMessage); } else if (result.type == ParseResult.Type.DUMP) { event.setThreads(result.threads); + if (result.debugImages != null) { + final DebugMeta debugMeta = new DebugMeta(); + 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)); @@ -275,72 +187,47 @@ private void reportAsSentryEvent( } } - final @NotNull SentryId sentryId = hub.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( final @NotNull ApplicationExitInfo exitInfo, final boolean isBackground) { - InputStream trace; - try { - trace = exitInfo.getTraceInputStream(); + final byte[] dump; + + try (final InputStream trace = exitInfo.getTraceInputStream()) { if (trace == null) { return new ParseResult(ParseResult.Type.NO_DUMP); } + 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); } - byte[] dump = null; - try { - dump = getDumpBytes(trace); - } catch (Throwable e) { - options - .getLogger() - .log(SentryLevel.WARNING, "Failed to convert ANR thread dump to byte array", e); - } - try (final BufferedReader reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(dump)))) { final Lines lines = Lines.readLines(reader); final ThreadDumpParser threadDumpParser = new ThreadDumpParser(options, isBackground); - final List threads = threadDumpParser.parse(lines); + threadDumpParser.parse(lines); + + 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 our regex matching is garbage and this is still error - return new ParseResult(ParseResult.Type.ERROR, dump); + // if the list is empty this means the system failed to capture a proper thread dump of + // the android threads, and only contains kernel-level threads and statuses, those ANRs + // are not actionable and neither they are reported by Google Play Console, so we just + // fall back to not reporting them + return new ParseResult(ParseResult.Type.NO_DUMP); } - return new ParseResult(ParseResult.Type.DUMP, dump, threads); + 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 { - 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 @@ -365,6 +252,12 @@ public AnrV2Hint( this.isBackgroundAnr = isBackgroundAnr; } + @Override + public boolean ignoreCurrentThread() { + return false; + } + + @NotNull @Override public Long timestamp() { return timestamp; @@ -379,6 +272,14 @@ public boolean shouldEnrich() { public String mechanism() { return isBackgroundAnr ? "anr_background" : "anr_foreground"; } + + @Override + public boolean isFlushable(@Nullable SentryId eventId) { + return true; + } + + @Override + public void setFlushable(@NotNull SentryId eventId) {} } static final class ParseResult { @@ -390,26 +291,38 @@ 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) { this.type = 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 @NotNull Type type, + final byte[] dump, + final @Nullable List threads, + 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 c9a552a5b76..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 @@ -1,16 +1,19 @@ package io.sentry.android.core; import static io.sentry.TypeCheckHint.ANDROID_CONFIGURATION; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import android.content.ComponentCallbacks2; import android.content.Context; import android.content.res.Configuration; import io.sentry.Breadcrumb; import io.sentry.Hint; -import io.sentry.IHub; +import io.sentry.IScopes; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; +import io.sentry.android.core.internal.util.Debouncer; import io.sentry.android.core.internal.util.DeviceOrientations; import io.sentry.protocol.Device; import io.sentry.util.Objects; @@ -23,17 +26,25 @@ public final class AppComponentsBreadcrumbsIntegration implements Integration, Closeable, ComponentCallbacks2 { + private static final long DEBOUNCE_WAIT_TIME_MS = 60 * 1000; + // pre-allocate hint to avoid creating it every time for the low memory case + private static final @NotNull Hint EMPTY_HINT = new Hint(); + private final @NotNull Context context; - private @Nullable IHub hub; + private @Nullable IScopes scopes; private @Nullable SentryAndroidOptions options; + private final @NotNull Debouncer trimMemoryDebouncer = + new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS, 0); + public AppComponentsBreadcrumbsIntegration(final @NotNull Context context) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); } @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - this.hub = Objects.requireNonNull(hub, "Hub is required"); + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, @@ -53,7 +64,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio options .getLogger() .log(SentryLevel.DEBUG, "AppComponentsBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion(); + addIntegrationToSdkVersion("AppComponentsBreadcrumbs"); } catch (Throwable e) { this.options.setEnableAppComponentBreadcrumbs(false); options.getLogger().log(SentryLevel.INFO, e, "ComponentCallbacks2 is not available."); @@ -83,7 +94,56 @@ public void close() throws IOException { @SuppressWarnings("deprecation") @Override public void onConfigurationChanged(@NotNull Configuration newConfig) { - if (hub != null) { + final long now = System.currentTimeMillis(); + executeInBackground(() -> captureConfigurationChangedBreadcrumb(now, newConfig)); + } + + @SuppressWarnings("deprecation") + @Override + public void onLowMemory() { + // we do this in onTrimMemory below already, this is legacy API (14 or below) + } + + @Override + public void onTrimMemory(final int level) { + if (level < TRIM_MEMORY_BACKGROUND) { + // only add breadcrumb if TRIM_MEMORY_BACKGROUND, TRIM_MEMORY_MODERATE or + // TRIM_MEMORY_COMPLETE. + // Release as much memory as the process can. + + // TRIM_MEMORY_UI_HIDDEN, TRIM_MEMORY_RUNNING_MODERATE, TRIM_MEMORY_RUNNING_LOW and + // TRIM_MEMORY_RUNNING_CRITICAL. + // Release any memory that your app doesn't need to run. + // So they are still not so critical at the point of killing the process. + // https://developer.android.com/topic/performance/memory + return; + } + + if (trimMemoryDebouncer.checkForDebounce()) { + // if we received trim_memory within 1 minute time, ignore this call + return; + } + + final long now = System.currentTimeMillis(); + executeInBackground(() -> captureLowMemoryBreadcrumb(now, level)); + } + + private void captureLowMemoryBreadcrumb(final long timeMs, final int level) { + if (scopes != null) { + final Breadcrumb breadcrumb = new Breadcrumb(timeMs); + breadcrumb.setType("system"); + breadcrumb.setCategory("device.event"); + breadcrumb.setMessage("Low memory"); + breadcrumb.setData("action", "LOW_MEMORY"); + breadcrumb.setData("level", level); + breadcrumb.setLevel(SentryLevel.WARNING); + scopes.addBreadcrumb(breadcrumb, EMPTY_HINT); + } + } + + private void captureConfigurationChangedBreadcrumb( + final long timeMs, final @NotNull Configuration newConfig) { + if (scopes != null) { final Device.DeviceOrientation deviceOrientation = DeviceOrientations.getOrientation(context.getResources().getConfiguration().orientation); @@ -94,7 +154,7 @@ public void onConfigurationChanged(@NotNull Configuration newConfig) { orientation = "undefined"; } - final Breadcrumb breadcrumb = new Breadcrumb(); + final Breadcrumb breadcrumb = new Breadcrumb(timeMs); breadcrumb.setType("navigation"); breadcrumb.setCategory("device.orientation"); breadcrumb.setData("position", orientation); @@ -103,46 +163,19 @@ public void onConfigurationChanged(@NotNull Configuration newConfig) { final Hint hint = new Hint(); hint.set(ANDROID_CONFIGURATION, newConfig); - hub.addBreadcrumb(breadcrumb, hint); + scopes.addBreadcrumb(breadcrumb, hint); } } - @Override - public void onLowMemory() { - createLowMemoryBreadcrumb(null); - } - - @Override - public void onTrimMemory(final int level) { - createLowMemoryBreadcrumb(level); - } - - private void createLowMemoryBreadcrumb(final @Nullable Integer level) { - if (hub != null) { - final Breadcrumb breadcrumb = new Breadcrumb(); - if (level != null) { - // only add breadcrumb if TRIM_MEMORY_BACKGROUND, TRIM_MEMORY_MODERATE or - // TRIM_MEMORY_COMPLETE. - // Release as much memory as the process can. - - // TRIM_MEMORY_UI_HIDDEN, TRIM_MEMORY_RUNNING_MODERATE, TRIM_MEMORY_RUNNING_LOW and - // TRIM_MEMORY_RUNNING_CRITICAL. - // Release any memory that your app doesn't need to run. - // So they are still not so critical at the point of killing the process. - // https://developer.android.com/topic/performance/memory - - if (level < TRIM_MEMORY_BACKGROUND) { - return; - } - breadcrumb.setData("level", level); + private void executeInBackground(final @NotNull Runnable runnable) { + if (options != null) { + try { + options.getExecutorService().submit(runnable); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.ERROR, t, "Failed to submit app components breadcrumb task"); } - - breadcrumb.setType("system"); - breadcrumb.setCategory("device.event"); - breadcrumb.setMessage("Low memory"); - breadcrumb.setData("action", "LOW_MEMORY"); - breadcrumb.setLevel(SentryLevel.WARNING); - hub.addBreadcrumb(breadcrumb); } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java index a6fc0c4721a..9fd90b23099 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java @@ -1,11 +1,13 @@ package io.sentry.android.core; -import androidx.lifecycle.ProcessLifecycleOwner; -import io.sentry.IHub; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; -import io.sentry.android.core.internal.util.AndroidMainThreadChecker; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; @@ -15,23 +17,14 @@ public final class AppLifecycleIntegration implements Integration, Closeable { + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); @TestOnly @Nullable volatile LifecycleWatcher watcher; private @Nullable SentryAndroidOptions options; - private final @NotNull MainLooperHandler handler; - - public AppLifecycleIntegration() { - this(new MainLooperHandler()); - } - - AppLifecycleIntegration(final @NotNull MainLooperHandler handler) { - this.handler = handler; - } - @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + Objects.requireNonNull(scopes, "Scopes are required"); this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, @@ -53,84 +46,47 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio if (this.options.isEnableAutoSessionTracking() || this.options.isEnableAppLifecycleBreadcrumbs()) { - try { - Class.forName("androidx.lifecycle.DefaultLifecycleObserver"); - Class.forName("androidx.lifecycle.ProcessLifecycleOwner"); - if (AndroidMainThreadChecker.getInstance().isMainThread()) { - addObserver(hub); - } else { - // some versions of the androidx lifecycle-process require this to be executed on the main - // thread. - handler.post(() -> addObserver(hub)); + try (final ISentryLifecycleToken ignored = lock.acquire()) { + if (watcher != null) { + return; } - } catch (ClassNotFoundException e) { - options - .getLogger() - .log( - SentryLevel.INFO, - "androidx.lifecycle is not available, AppLifecycleIntegration won't be installed", - e); - } catch (IllegalStateException e) { - options - .getLogger() - .log(SentryLevel.ERROR, "AppLifecycleIntegration could not be installed", e); - } - } - } - private void addObserver(final @NotNull IHub hub) { - // this should never happen, check added to avoid warnings from NullAway - if (this.options == null) { - return; - } + watcher = + new LifecycleWatcher( + scopes, + this.options.getSessionTrackingIntervalMillis(), + this.options.isEnableAutoSessionTracking(), + this.options.isEnableAppLifecycleBreadcrumbs()); - watcher = - new LifecycleWatcher( - hub, - this.options.getSessionTrackingIntervalMillis(), - this.options.isEnableAutoSessionTracking(), - this.options.isEnableAppLifecycleBreadcrumbs()); + AppState.getInstance().addAppStateListener(watcher); + } - try { - ProcessLifecycleOwner.get().getLifecycle().addObserver(watcher); options.getLogger().log(SentryLevel.DEBUG, "AppLifecycleIntegration installed."); - addIntegrationToSdkVersion(); - } catch (Throwable e) { - // This is to handle a potential 'AbstractMethodError' gracefully. The error is triggered in - // connection with conflicting dependencies of the androidx.lifecycle. - // //See the issue here: https://github.com/getsentry/sentry-java/pull/2228 - watcher = null; - options - .getLogger() - .log( - SentryLevel.ERROR, - "AppLifecycleIntegration failed to get Lifecycle and could not be installed.", - e); + addIntegrationToSdkVersion("AppLifecycle"); } } private void removeObserver() { - final @Nullable LifecycleWatcher watcherRef = watcher; + final @Nullable LifecycleWatcher watcherRef; + try (final ISentryLifecycleToken ignored = lock.acquire()) { + watcherRef = watcher; + watcher = null; + } + if (watcherRef != null) { - ProcessLifecycleOwner.get().getLifecycle().removeObserver(watcherRef); + AppState.getInstance().removeAppStateListener(watcherRef); if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "AppLifecycleIntegration removed."); } } - watcher = null; } @Override public void close() throws IOException { - if (watcher == null) { - return; - } - if (AndroidMainThreadChecker.getInstance().isMainThread()) { - removeObserver(); - } else { - // some versions of the androidx lifecycle-process require this to be executed on the main - // thread. - handler.post(() -> removeObserver()); - } + removeObserver(); + // TODO: probably should move it to Scopes.close(), but that'd require a new interface and + // different implementations for Java and Android. This is probably fine like this too, because + // integrations are closed in the same place + AppState.getInstance().unregisterLifecycleObserver(); } } 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/AppStartState.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartState.java deleted file mode 100644 index de690aa6683..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppStartState.java +++ /dev/null @@ -1,129 +0,0 @@ -package io.sentry.android.core; - -import android.os.SystemClock; -import io.sentry.DateUtils; -import io.sentry.SentryDate; -import io.sentry.SentryLongDate; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; - -/** AppStartState holds the state of the App Start metric and appStartTime */ -@ApiStatus.Internal -public final class AppStartState { - - private static @NotNull AppStartState instance = new AppStartState(); - - /** We filter out App starts more than 60s */ - private static final int MAX_APP_START_MILLIS = 60000; - - private @Nullable Long appStartMillis; - - private @Nullable Long appStartEndMillis; - - /** The type of App start coldStart=true -> Cold start, coldStart=false -> Warm start */ - private @Nullable Boolean coldStart = null; - - /** appStart as a Date used in the App's Context */ - private @Nullable SentryDate appStartTime; - - private AppStartState() {} - - public static @NotNull AppStartState getInstance() { - return instance; - } - - @TestOnly - void resetInstance() { - instance = new AppStartState(); - } - - synchronized void setAppStartEnd() { - setAppStartEnd(SystemClock.uptimeMillis()); - } - - @TestOnly - void setAppStartEnd(final long appStartEndMillis) { - this.appStartEndMillis = appStartEndMillis; - } - - @Nullable - public synchronized Long getAppStartInterval() { - if (appStartMillis == null || appStartEndMillis == null || coldStart == null) { - return null; - } - final long appStart = appStartEndMillis - appStartMillis; - - // We filter out app start more than 60s. - // This could be due to many different reasons. - // If you do the manual init and init the SDK too late and it does not compute the app start end - // in the very first Activity. - // If the process starts but the App isn't in the foreground. - // If the system forked the zygote earlier to accelerate the app start. - // And some unknown reasons that could not be reproduced. - // We've seen app starts with hours, days and even months. - if (appStart >= MAX_APP_START_MILLIS) { - return null; - } - - return appStart; - } - - public @Nullable Boolean isColdStart() { - return coldStart; - } - - synchronized void setColdStart(final boolean coldStart) { - if (this.coldStart != null) { - return; - } - this.coldStart = coldStart; - } - - @Nullable - public SentryDate getAppStartTime() { - return appStartTime; - } - - @Nullable - public SentryDate getAppStartEndTime() { - @Nullable final SentryDate start = getAppStartTime(); - if (start != null) { - @Nullable final Long durationMillis = getAppStartInterval(); - if (durationMillis != null) { - final long startNanos = start.nanoTimestamp(); - final long endNanos = startNanos + DateUtils.millisToNanos(durationMillis); - return new SentryLongDate(endNanos); - } - } - return null; - } - - @Nullable - public Long getAppStartMillis() { - return appStartMillis; - } - - synchronized void setAppStartTime( - final long appStartMillis, final @NotNull SentryDate appStartTime) { - // method is synchronized because the SDK may by init. on a background thread. - if (this.appStartTime != null && this.appStartMillis != null) { - return; - } - this.appStartTime = appStartTime; - this.appStartMillis = appStartMillis; - } - - @TestOnly - public synchronized void setAppStartMillis(final long appStartMillis) { - this.appStartMillis = appStartMillis; - } - - @TestOnly - public synchronized void reset() { - appStartTime = null; - appStartMillis = null; - appStartEndMillis = null; - } -} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java index d47372c0c84..74522f7aaac 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java @@ -1,5 +1,20 @@ package io.sentry.android.core; +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.ProcessLifecycleOwner; +import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; +import io.sentry.NoOpLogger; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.android.core.internal.util.AndroidThreadChecker; +import io.sentry.util.AutoClosableReentrantLock; +import java.io.Closeable; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -7,8 +22,11 @@ /** AppState holds the state of the App, e.g. whether the app is in background/foreground, etc. */ @ApiStatus.Internal -public final class AppState { +public final class AppState implements Closeable { private static @NotNull AppState instance = new AppState(); + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private volatile LifecycleObserver lifecycleObserver; + private MainLooperHandler handler = new MainLooperHandler(); private AppState() {} @@ -16,18 +34,188 @@ private AppState() {} return instance; } - private @Nullable Boolean inBackground = null; + private volatile @Nullable Boolean inBackground = null; @TestOnly - void resetInstance() { + void setHandler(final @NotNull MainLooperHandler handler) { + this.handler = handler; + } + + @ApiStatus.Internal + @TestOnly + public void resetInstance() { instance = new AppState(); } + @ApiStatus.Internal + @TestOnly + public LifecycleObserver getLifecycleObserver() { + return lifecycleObserver; + } + public @Nullable Boolean isInBackground() { return inBackground; } - synchronized void setInBackground(final boolean inBackground) { + void setInBackground(final boolean inBackground) { this.inBackground = inBackground; } + + public void addAppStateListener(final @NotNull AppStateListener listener) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + ensureLifecycleObserver(NoOpLogger.getInstance()); + + if (lifecycleObserver != null) { + lifecycleObserver.listeners.add(listener); + } + } + } + + public void removeAppStateListener(final @NotNull AppStateListener listener) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (lifecycleObserver != null) { + lifecycleObserver.listeners.remove(listener); + } + } + } + + @ApiStatus.Internal + public void registerLifecycleObserver(final @Nullable SentryOptions options) { + if (lifecycleObserver != null) { + return; + } + + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + ensureLifecycleObserver(options != null ? options.getLogger() : NoOpLogger.getInstance()); + } + } + + private void ensureLifecycleObserver(final @NotNull ILogger logger) { + if (lifecycleObserver != null) { + return; + } + try { + Class.forName("androidx.lifecycle.ProcessLifecycleOwner"); + // create it right away, so it's available in addAppStateListener in case it's posted to main + // thread + lifecycleObserver = new LifecycleObserver(); + + if (AndroidThreadChecker.getInstance().isMainThread()) { + addObserverInternal(logger); + } else { + // some versions of the androidx lifecycle-process require this to be executed on the main + // thread. + handler.post(() -> addObserverInternal(logger)); + } + } catch (ClassNotFoundException e) { + logger.log( + SentryLevel.WARNING, + "androidx.lifecycle is not available, some features might not be properly working," + + "e.g. Session Tracking, Network and System Events breadcrumbs, etc."); + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "AppState could not register lifecycle observer", e); + } + } + + private void addObserverInternal(final @NotNull ILogger logger) { + final @Nullable LifecycleObserver observerRef = lifecycleObserver; + try { + // might already be unregistered/removed so we have to check for nullability + if (observerRef != null) { + ProcessLifecycleOwner.get().getLifecycle().addObserver(observerRef); + } + } catch (Throwable e) { + // This is to handle a potential 'AbstractMethodError' gracefully. The error is triggered in + // connection with conflicting dependencies of the androidx.lifecycle. + // //See the issue here: https://github.com/getsentry/sentry-java/pull/2228 + lifecycleObserver = null; + logger.log( + SentryLevel.ERROR, + "AppState failed to get Lifecycle and could not install lifecycle observer.", + e); + } + } + + @ApiStatus.Internal + public void unregisterLifecycleObserver() { + if (lifecycleObserver == null) { + return; + } + + final @Nullable LifecycleObserver ref; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + ref = lifecycleObserver; + lifecycleObserver.listeners.clear(); + lifecycleObserver = null; + } + + if (AndroidThreadChecker.getInstance().isMainThread()) { + removeObserverInternal(ref); + } else { + // some versions of the androidx lifecycle-process require this to be executed on the main + // thread. + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef + handler.post(() -> removeObserverInternal(ref)); + } + } + + private void removeObserverInternal(final @Nullable LifecycleObserver ref) { + if (ref != null) { + ProcessLifecycleOwner.get().getLifecycle().removeObserver(ref); + } + } + + @Override + public void close() throws IOException { + unregisterLifecycleObserver(); + } + + @ApiStatus.Internal + public final class LifecycleObserver implements DefaultLifecycleObserver { + final List listeners = + new CopyOnWriteArrayList() { + @Override + public boolean add(AppStateListener appStateListener) { + final boolean addResult = super.add(appStateListener); + // notify the listeners immediately to let them "catch up" with the current state + // (mimics the behavior of androidx.lifecycle) + if (Boolean.FALSE.equals(inBackground)) { + appStateListener.onForeground(); + } else if (Boolean.TRUE.equals(inBackground)) { + appStateListener.onBackground(); + } + return addResult; + } + }; + + @ApiStatus.Internal + @TestOnly + public List getListeners() { + return listeners; + } + + @Override + public void onStart(@NonNull LifecycleOwner owner) { + setInBackground(false); + for (AppStateListener listener : listeners) { + listener.onForeground(); + } + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + setInBackground(true); + for (AppStateListener listener : listeners) { + listener.onBackground(); + } + } + } + + // If necessary, we can adjust this and add other callbacks in the future + public interface AppStateListener { + void onForeground(); + + void onBackground(); + } } 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/BuildInfoProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/BuildInfoProvider.java index b998414a9e2..5b0786ffb7f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/BuildInfoProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/BuildInfoProvider.java @@ -17,6 +17,7 @@ public final class BuildInfoProvider { public BuildInfoProvider(final @NotNull ILogger logger) { this.logger = Objects.requireNonNull(logger, "The ILogger object is required."); } + /** * Returns the Build.VERSION.SDK_INT * diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index d43dde9471c..60ae00f2ead 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -2,33 +2,174 @@ import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; import static android.content.Context.ACTIVITY_SERVICE; +import static android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED; import android.annotation.SuppressLint; import android.app.ActivityManager; +import android.content.BroadcastReceiver; +import android.content.ComponentName; import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.os.Build; -import android.os.Process; -import android.provider.Settings; +import android.os.Handler; import android.util.DisplayMetrics; import io.sentry.ILogger; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.android.core.util.AndroidLazyEvaluator; +import io.sentry.protocol.App; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; import java.io.IOException; +import java.util.Arrays; 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; +import org.jetbrains.annotations.TestOnly; -final class ContextUtils { +@ApiStatus.Internal +public final class ContextUtils { + + static class SideLoadedInfo { + private final boolean isSideLoaded; + private final @Nullable String installerStore; + + public SideLoadedInfo(final boolean isSideLoaded, final @Nullable String installerStore) { + this.isSideLoaded = isSideLoaded; + this.installerStore = installerStore; + } + + public boolean isSideLoaded() { + return isSideLoaded; + } + + public @Nullable String getInstallerStore() { + return installerStore; + } + + public @NotNull Map asTags() { + final Map data = new HashMap<>(); + data.put("isSideLoaded", String.valueOf(isSideLoaded)); + if (installerStore != null) { + data.put("installerStore", installerStore); + } + return data; + } + } + + static class SplitApksInfo { + // https://github.com/google/bundletool/blob/master/src/main/java/com/android/tools/build/bundletool/model/AndroidManifest.java#L257-L263 + static final String SPLITS_REQUIRED = "com.android.vending.splits.required"; + + private final boolean isSplitApks; + private final String[] splitNames; + + public SplitApksInfo(final boolean isSplitApks, final String[] splitNames) { + this.isSplitApks = isSplitApks; + this.splitNames = splitNames; + } + + public boolean isSplitApks() { + return isSplitApks; + } + + public @Nullable String[] getSplitNames() { + return splitNames; + } + } private ContextUtils() {} + // to avoid doing a bunch of Binder calls we use LazyEvaluator to cache the values that are static + // during the app process running + + /** + * Since this packageInfo uses flags 0 we can assume it's static and cache it as the package name + * or version code cannot change during runtime, only after app update (which will spin up a new + * process). + */ + @SuppressLint("NewApi") + private static final @NotNull AndroidLazyEvaluator staticPackageInfo33 = + new AndroidLazyEvaluator<>( + context -> { + try { + return context + .getPackageManager() + .getPackageInfo(context.getPackageName(), PackageManager.PackageInfoFlags.of(0)); + } catch (Throwable e) { + return null; + } + }); + + private static final @NotNull AndroidLazyEvaluator staticPackageInfo = + new AndroidLazyEvaluator<>( + context -> { + try { + return context.getPackageManager().getPackageInfo(context.getPackageName(), 0); + } catch (Throwable e) { + return null; + } + }); + + private static final @NotNull AndroidLazyEvaluator applicationName = + new AndroidLazyEvaluator<>( + context -> { + try { + final ApplicationInfo applicationInfo = context.getApplicationInfo(); + final int stringId = applicationInfo.labelRes; + if (stringId == 0) { + if (applicationInfo.nonLocalizedLabel != null) { + return applicationInfo.nonLocalizedLabel.toString(); + } + return context.getPackageManager().getApplicationLabel(applicationInfo).toString(); + } else { + return context.getString(stringId); + } + } catch (Throwable e) { + return null; + } + }); + + /** + * Since this applicationInfo uses the same flag (METADATA) we can assume it's static and cache it + * as the manifest metadata cannot change during runtime, only after app update (which will spin + * up a new process). + */ + @SuppressLint("NewApi") + private static final @NotNull AndroidLazyEvaluator staticAppInfo33 = + new AndroidLazyEvaluator<>( + context -> { + try { + return context + .getPackageManager() + .getApplicationInfo( + context.getPackageName(), + PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA)); + } catch (Throwable e) { + return null; + } + }); + + private static final @NotNull AndroidLazyEvaluator staticAppInfo = + new AndroidLazyEvaluator<>( + context -> { + try { + return context + .getPackageManager() + .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + } catch (Throwable e) { + return null; + } + }); + /** * Return the Application's PackageInfo if possible, or null. * @@ -36,10 +177,12 @@ private ContextUtils() {} */ @Nullable static PackageInfo getPackageInfo( - final @NotNull Context context, - final @NotNull ILogger logger, - final @NotNull BuildInfoProvider buildInfoProvider) { - return getPackageInfo(context, 0, logger, buildInfoProvider); + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { + return staticPackageInfo33.getValue(context); + } else { + return staticPackageInfo.getValue(context); + } } /** @@ -76,22 +219,14 @@ static PackageInfo getPackageInfo( * @return the Application's ApplicationInfo if possible, or throws */ @SuppressLint("NewApi") - @NotNull + @Nullable @SuppressWarnings("deprecation") static ApplicationInfo getApplicationInfo( - final @NotNull Context context, - final long flag, - final @NotNull BuildInfoProvider buildInfoProvider) - throws PackageManager.NameNotFoundException { + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { - return context - .getPackageManager() - .getApplicationInfo( - context.getPackageName(), PackageManager.ApplicationInfoFlags.of(flag)); + return staticAppInfo33.getValue(context); } else { - return context - .getPackageManager() - .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + return staticAppInfo.getValue(context); } } @@ -133,30 +268,45 @@ static String getVersionName(final @NotNull PackageInfo packageInfo) { * * @return true if IMPORTANCE_FOREGROUND and false otherwise */ - static boolean isForegroundImportance(final @NotNull Context context) { + @ApiStatus.Internal + public static boolean isForegroundImportance() { try { - final Object service = context.getSystemService(Context.ACTIVITY_SERVICE); - if (service instanceof ActivityManager) { - final ActivityManager activityManager = (ActivityManager) service; - final List runningAppProcesses = - activityManager.getRunningAppProcesses(); - - if (runningAppProcesses != null) { - final int myPid = Process.myPid(); - for (final ActivityManager.RunningAppProcessInfo processInfo : runningAppProcesses) { - if (processInfo.pid == myPid) { - if (processInfo.importance == IMPORTANCE_FOREGROUND) { - return true; - } - break; - } + final ActivityManager.RunningAppProcessInfo appProcessInfo = + new ActivityManager.RunningAppProcessInfo(); + ActivityManager.getMyMemoryState(appProcessInfo); + return appProcessInfo.importance == IMPORTANCE_FOREGROUND; + } catch (Throwable ignored) { + // should never happen + } + return false; + } + + /** + * Determines if the app is a packaged android library for running Compose Preview Mode + * + * @param context the context + * @return true, if the app is actually a library running as an app for Compose Preview Mode + */ + @ApiStatus.Internal + public static boolean appIsLibraryForComposePreview(final @NotNull Context context) { + // Jetpack Compose Preview (aka "Run Preview on Device") + // uses the androidTest flavor for android library modules, + // so let's fail-fast by checking this first + if (context.getPackageName().endsWith(".test")) { + try { + final @NotNull ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + final @NotNull List appTasks = activityManager.getAppTasks(); + for (final ActivityManager.AppTask task : appTasks) { + final @Nullable ComponentName component = task.getTaskInfo().baseIntent.getComponent(); + if (component != null + && component.getClassName().equals("androidx.compose.ui.tooling.PreviewActivity")) { + return true; } } + } catch (Throwable t) { + // ignored } - } catch (SecurityException ignored) { - // happens for isolated processes - } catch (Throwable ignored) { - // should never happen } return false; } @@ -187,14 +337,14 @@ static boolean isForegroundImportance(final @NotNull Context context) { return defaultVersion; } - @SuppressWarnings("deprecation") - static @Nullable Map getSideLoadedInfo( + @SuppressWarnings({"deprecation"}) + static @Nullable SideLoadedInfo retrieveSideLoadedInfo( final @NotNull Context context, final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider) { String packageName = null; try { - final PackageInfo packageInfo = getPackageInfo(context, logger, buildInfoProvider); + final PackageInfo packageInfo = getPackageInfo(context, buildInfoProvider); final PackageManager packageManager = context.getPackageManager(); if (packageInfo != null && packageManager != null) { @@ -202,20 +352,10 @@ static boolean isForegroundImportance(final @NotNull Context context) { // getInstallSourceInfo requires INSTALL_PACKAGES permission which is only given to system // apps. + // if it's installed via adb, system apps or untrusted sources + // could be amazon, google play etc - or null in case of sideload final String installerPackageName = packageManager.getInstallerPackageName(packageName); - - final Map sideLoadedInfo = new HashMap<>(); - - if (installerPackageName != null) { - sideLoadedInfo.put("isSideLoaded", "false"); - // could be amazon, google play etc - sideLoadedInfo.put("installerStore", installerPackageName); - } else { - // if it's installed via adb, system apps or untrusted sources - sideLoadedInfo.put("isSideLoaded", "true"); - } - - return sideLoadedInfo; + return new SideLoadedInfo(installerPackageName == null, installerPackageName); } } catch (IllegalArgumentException e) { // it'll never be thrown as we are querying its own App's package. @@ -225,29 +365,33 @@ static boolean isForegroundImportance(final @NotNull Context context) { return null; } + @SuppressWarnings({"deprecation"}) + static @Nullable SplitApksInfo retrieveSplitApksInfo( + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { + String[] splitNames = null; + final ApplicationInfo applicationInfo = getApplicationInfo(context, buildInfoProvider); + final PackageInfo packageInfo = getPackageInfo(context, buildInfoProvider); + + if (packageInfo != null) { + splitNames = packageInfo.splitNames; + boolean isSplitApks = false; + if (applicationInfo != null && applicationInfo.metaData != null) { + isSplitApks = applicationInfo.metaData.getBoolean(SplitApksInfo.SPLITS_REQUIRED); + } + + return new SplitApksInfo(isSplitApks, splitNames); + } + + return null; + } + /** * Get the human-facing Application name. * * @return Application name */ - static @Nullable String getApplicationName( - final @NotNull Context context, final @NotNull ILogger logger) { - try { - final ApplicationInfo applicationInfo = context.getApplicationInfo(); - final int stringId = applicationInfo.labelRes; - if (stringId == 0) { - if (applicationInfo.nonLocalizedLabel != null) { - return applicationInfo.nonLocalizedLabel.toString(); - } - return context.getPackageManager().getApplicationLabel(applicationInfo).toString(); - } else { - return context.getString(stringId); - } - } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Error getting application name.", e); - } - - return null; + static @Nullable String getApplicationName(final @NotNull Context context) { + return applicationName.getValue(context); } /** @@ -280,26 +424,8 @@ static boolean isForegroundImportance(final @NotNull Context context) { } } - @SuppressLint("NewApi") // we're wrapping into if-check with sdk version - static @Nullable String getDeviceName( - final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - return Settings.Global.getString(context.getContentResolver(), "device_name"); - } else { - return null; - } - } - - @SuppressWarnings("deprecation") - @SuppressLint("NewApi") // we're wrapping into if-check with sdk version - static @NotNull String[] getArchitectures(final @NotNull BuildInfoProvider buildInfoProvider) { - final String[] supportedAbis; - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.LOLLIPOP) { - supportedAbis = Build.SUPPORTED_ABIS; - } else { - supportedAbis = new String[] {Build.CPU_ABI, Build.CPU_ABI2}; - } - return supportedAbis; + static @NotNull String[] getArchitectures() { + return Build.SUPPORTED_ABIS; } /** @@ -324,4 +450,102 @@ static boolean isForegroundImportance(final @NotNull Context context) { return null; } } + + /** Register an exported BroadcastReceiver, independently from platform version. */ + static @Nullable Intent registerReceiver( + final @NotNull Context context, + final @NotNull SentryOptions options, + final @Nullable BroadcastReceiver receiver, + final @NotNull IntentFilter filter, + final @Nullable Handler handler) { + return registerReceiver( + context, new BuildInfoProvider(options.getLogger()), receiver, filter, handler); + } + + /** Register an exported BroadcastReceiver, independently from platform version. */ + @SuppressLint({"NewApi", "UnspecifiedRegisterReceiverFlag"}) + static @Nullable Intent registerReceiver( + final @NotNull Context context, + final @NotNull BuildInfoProvider buildInfoProvider, + final @Nullable BroadcastReceiver receiver, + final @NotNull IntentFilter filter, + final @Nullable Handler handler) { + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { + // From https://developer.android.com/guide/components/broadcasts#context-registered-receivers + // If this receiver is listening for broadcasts sent from the system or from other apps, even + // other apps that you own—use the RECEIVER_EXPORTED flag. If instead this receiver is + // listening only for broadcasts sent by your app, use the RECEIVER_NOT_EXPORTED flag. + return context.registerReceiver( + receiver, filter, null, handler, Context.RECEIVER_NOT_EXPORTED); + } else { + return context.registerReceiver(receiver, filter, null, handler); + } + } + + static void setAppPackageInfo( + final @NotNull PackageInfo packageInfo, + final @NotNull BuildInfoProvider buildInfoProvider, + final @Nullable DeviceInfoUtil deviceInfoUtil, + final @NotNull App app) { + app.setAppIdentifier(packageInfo.packageName); + app.setAppVersion(packageInfo.versionName); + app.setAppBuild(ContextUtils.getVersionCode(packageInfo, buildInfoProvider)); + + final Map permissions = new HashMap<>(); + final String[] requestedPermissions = packageInfo.requestedPermissions; + final int[] requestedPermissionsFlags = packageInfo.requestedPermissionsFlags; + + if (requestedPermissions != null + && requestedPermissions.length > 0 + && requestedPermissionsFlags != null + && requestedPermissionsFlags.length > 0) { + for (int i = 0; i < requestedPermissions.length; i++) { + String permission = requestedPermissions[i]; + permission = permission.substring(permission.lastIndexOf('.') + 1); + + final boolean granted = + (requestedPermissionsFlags[i] & REQUESTED_PERMISSION_GRANTED) + == REQUESTED_PERMISSION_GRANTED; + permissions.put(permission, granted ? "granted" : "not_granted"); + } + } + app.setPermissions(permissions); + + if (deviceInfoUtil != null) { + try { + final ContextUtils.SplitApksInfo splitApksInfo = deviceInfoUtil.getSplitApksInfo(); + if (splitApksInfo != null) { + app.setSplitApks(splitApksInfo.isSplitApks()); + if (splitApksInfo.getSplitNames() != null) { + app.setSplitNames(Arrays.asList(splitApksInfo.getSplitNames())); + } + } + } catch (Throwable e) { + } + } + } + + /** + * Get the app context + * + * @return the app context, or if not available, the provided context + */ + @NotNull + public static Context getApplicationContext(final @NotNull Context context) { + // it returns null if ContextImpl, so let's check for nullability + final @Nullable Context appContext = context.getApplicationContext(); + if (appContext != null) { + return appContext; + } + return context; + } + + @TestOnly + static void resetInstance() { + staticPackageInfo33.resetValue(); + staticPackageInfo.resetValue(); + applicationName.resetValue(); + staticAppInfo33.resetValue(); + staticAppInfo.resetValue(); + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityHolder.java b/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityHolder.java index 8da322b20bf..a7733821c05 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityHolder.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityHolder.java @@ -1,11 +1,10 @@ package io.sentry.android.core; import android.app.Activity; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import java.lang.ref.WeakReference; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; @ApiStatus.Internal public class CurrentActivityHolder { @@ -16,7 +15,7 @@ private CurrentActivityHolder() {} private @Nullable WeakReference currentActivity; - public static @NonNull CurrentActivityHolder getInstance() { + public static @NotNull CurrentActivityHolder getInstance() { return instance; } @@ -27,7 +26,7 @@ private CurrentActivityHolder() {} return null; } - public void setActivity(final @NonNull Activity activity) { + public void setActivity(final @NotNull Activity activity) { if (currentActivity != null && currentActivity.get() == activity) { return; } @@ -38,4 +37,11 @@ public void setActivity(final @NonNull Activity activity) { public void clearActivity() { currentActivity = null; } + + public void clearActivity(final @NotNull Activity activity) { + if (currentActivity != null && currentActivity.get() != activity) { + return; + } + currentActivity = null; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java deleted file mode 100644 index b4c5f1ed027..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java +++ /dev/null @@ -1,80 +0,0 @@ -package io.sentry.android.core; - -import android.app.Activity; -import android.app.Application; -import android.os.Bundle; -import androidx.annotation.NonNull; -import io.sentry.IHub; -import io.sentry.Integration; -import io.sentry.SentryOptions; -import io.sentry.util.Objects; -import java.io.Closeable; -import java.io.IOException; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@ApiStatus.Internal -public final class CurrentActivityIntegration - implements Integration, Closeable, Application.ActivityLifecycleCallbacks { - - private final @NotNull Application application; - - public CurrentActivityIntegration(final @NotNull Application application) { - this.application = Objects.requireNonNull(application, "Application is required"); - } - - @Override - public void register(@NotNull IHub hub, @NotNull SentryOptions options) { - application.registerActivityLifecycleCallbacks(this); - } - - @Override - public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { - setCurrentActivity(activity); - } - - @Override - public void onActivityStarted(@NonNull Activity activity) { - setCurrentActivity(activity); - } - - @Override - public void onActivityResumed(@NonNull Activity activity) { - setCurrentActivity(activity); - } - - @Override - public void onActivityPaused(@NonNull Activity activity) { - cleanCurrentActivity(activity); - } - - @Override - public void onActivityStopped(@NonNull Activity activity) { - cleanCurrentActivity(activity); - } - - @Override - public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {} - - @Override - public void onActivityDestroyed(@NonNull Activity activity) { - cleanCurrentActivity(activity); - } - - @Override - public void close() throws IOException { - application.unregisterActivityLifecycleCallbacks(this); - CurrentActivityHolder.getInstance().clearActivity(); - } - - private void cleanCurrentActivity(final @NotNull Activity activity) { - if (CurrentActivityHolder.getInstance().getActivity() == activity) { - CurrentActivityHolder.getInstance().clearActivity(); - } - } - - private void setCurrentActivity(final @NotNull Activity activity) { - CurrentActivityHolder.getInstance().setActivity(activity); - } -} 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 8d88c6674ae..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 @@ -1,126 +1,73 @@ package io.sentry.android.core; -import static android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED; -import static android.os.BatteryManager.EXTRA_TEMPERATURE; - -import android.annotation.SuppressLint; -import android.app.ActivityManager; import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; -import android.os.BatteryManager; import android.os.Build; -import android.os.Environment; -import android.os.LocaleList; -import android.os.StatFs; -import android.os.SystemClock; -import android.util.DisplayMetrics; -import io.sentry.DateUtils; -import io.sentry.EventProcessor; -import io.sentry.Hint; -import io.sentry.SentryBaseEvent; -import io.sentry.SentryEvent; -import io.sentry.SentryLevel; -import io.sentry.android.core.internal.util.AndroidMainThreadChecker; -import io.sentry.android.core.internal.util.ConnectivityChecker; -import io.sentry.android.core.internal.util.CpuInfoUtils; -import io.sentry.android.core.internal.util.DeviceOrientations; -import io.sentry.android.core.internal.util.RootChecker; +import io.sentry.*; +import io.sentry.android.core.internal.util.AndroidThreadChecker; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.App; -import io.sentry.protocol.Device; import io.sentry.protocol.OperatingSystem; +import io.sentry.protocol.SentryException; +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.util.HintUtils; +import io.sentry.util.LazyEvaluator; import io.sentry.util.Objects; -import java.io.File; -import java.util.Calendar; import java.util.Collections; -import java.util.Date; -import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.TimeZone; import java.util.concurrent.ExecutorService; 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; final class DefaultAndroidEventProcessor implements EventProcessor { - @TestOnly static final String ROOTED = "rooted"; - @TestOnly static final String KERNEL_VERSION = "kernelVersion"; - @TestOnly static final String EMULATOR = "emulator"; - @TestOnly static final String SIDE_LOADED = "sideLoaded"; - @TestOnly final Context context; - @TestOnly final Future> contextData; - private final @NotNull BuildInfoProvider buildInfoProvider; - private final @NotNull RootChecker rootChecker; private final @NotNull SentryAndroidOptions options; + @TestOnly final @Nullable Future deviceInfoUtil; + private final @NotNull LazyEvaluator deviceFamily = + new LazyEvaluator<>(() -> ContextUtils.getFamily(NoOpLogger.getInstance())); public DefaultAndroidEventProcessor( final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull SentryAndroidOptions options) { - this( - context, - buildInfoProvider, - new RootChecker(context, buildInfoProvider, options.getLogger()), - options); - } - - DefaultAndroidEventProcessor( - final @NotNull Context context, - final @NotNull BuildInfoProvider buildInfoProvider, - final @NotNull RootChecker rootChecker, - final @NotNull SentryAndroidOptions options) { - this.context = Objects.requireNonNull(context, "The application context is required."); + this.context = + Objects.requireNonNull( + ContextUtils.getApplicationContext(context), "The application context is required."); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "The BuildInfoProvider is required."); - this.rootChecker = Objects.requireNonNull(rootChecker, "The RootChecker is required."); this.options = Objects.requireNonNull(options, "The options object is required."); - ExecutorService executorService = Executors.newSingleThreadExecutor(); // don't ref. to method reference, theres a bug on it - //noinspection Convert2MethodRef - contextData = executorService.submit(() -> loadContextData()); - // reading CPU info performs disk I/O, but it's result is cached, let's pre-cache it - executorService.submit(() -> CpuInfoUtils.getInstance().readMaxFrequencies()); - - executorService.shutdown(); - } - - private @NotNull Map loadContextData() { - Map map = new HashMap<>(); - - if (options.isEnableRootCheck()) { - map.put(ROOTED, rootChecker.isDeviceRooted()); - } - - final String kernelVersion = ContextUtils.getKernelVersion(options.getLogger()); - if (kernelVersion != null) { - map.put(KERNEL_VERSION, kernelVersion); - } - - // its not IO, but it has been cached in the old version as well - map.put(EMULATOR, buildInfoProvider.isEmulator()); - - final Map sideLoadedInfo = - ContextUtils.getSideLoadedInfo(context, options.getLogger(), buildInfoProvider); - if (sideLoadedInfo != null) { - map.put(SIDE_LOADED, sideLoadedInfo); + // 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(new DeviceInfoCacheThreadFactory()); + try { + deviceInfoUtil = + executorService.submit(() -> DeviceInfoUtil.getInstance(this.context, options)); + } catch (RejectedExecutionException e) { + deviceInfoUtil = null; + options.getLogger().log(SentryLevel.WARNING, "Device info caching task rejected.", e); } - - return map; + this.deviceInfoUtil = deviceInfoUtil; + executorService.shutdown(); } @Override @@ -136,16 +83,72 @@ public DefaultAndroidEventProcessor( setCommons(event, true, applyScopeData); + fixExceptionOrder(event); + return event; } + @Override + public @Nullable SentryLogEvent process(@NotNull SentryLogEvent event) { + setDevice(event); + setOs(event); + 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 + * hooks like Application.onCreate is wrapped by Android framework with a RuntimeException. Thus, + * if the last exception is a RuntimeInit$MethodAndArgsCaller, reverse the order to get a better + * issue title. This is a quick fix, for more details see: #64074 #59679 #64088 + * + * @param event the event to process + */ + private static void fixExceptionOrder(final @NotNull SentryEvent event) { + boolean reverseExceptions = false; + + final @Nullable List exceptions = event.getExceptions(); + if (exceptions != null && exceptions.size() > 1) { + final @NotNull SentryException lastException = exceptions.get(exceptions.size() - 1); + if ("java.lang".equals(lastException.getModule())) { + final @Nullable SentryStackTrace stacktrace = lastException.getStacktrace(); + if (stacktrace != null) { + final @Nullable List frames = stacktrace.getFrames(); + if (frames != null) { + for (final @NotNull SentryStackFrame frame : frames) { + if ("com.android.internal.os.RuntimeInit$MethodAndArgsCaller" + .equals(frame.getModule())) { + reverseExceptions = true; + break; + } + } + } + } + } + } + + if (reverseExceptions) { + Collections.reverse(exceptions); + } + } + private void setCommons( final @NotNull SentryBaseEvent event, final boolean errorEvent, final boolean applyScopeData) { mergeUser(event); setDevice(event, errorEvent, applyScopeData); - mergeOS(event); setSideLoadedInfo(event); } @@ -165,12 +168,18 @@ private boolean shouldApplyScopeData( } private void mergeUser(final @NotNull SentryBaseEvent event) { - // userId should be set even if event is Cached as the userId is static and won't change anyway. - final User user = event.getUser(); + @Nullable User user = event.getUser(); if (user == null) { - event.setUser(getDefaultUser()); - } else if (user.getId() == null) { - user.setId(getDeviceId()); + 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(Installation.id(context)); + } + if (user.getIpAddress() == null && options.isSendDefaultPii()) { + user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } @@ -179,16 +188,35 @@ private void setDevice( final boolean errorEvent, final boolean applyScopeData) { if (event.getContexts().getDevice() == null) { - event.getContexts().setDevice(getDevice(errorEvent, applyScopeData)); + if (deviceInfoUtil != null) { + try { + event + .getContexts() + .setDevice(deviceInfoUtil.get().collectDeviceInformation(errorEvent, applyScopeData)); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info", e); + } + } else { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info"); + } + mergeOS(event); } } private void mergeOS(final @NotNull SentryBaseEvent event) { final OperatingSystem currentOS = event.getContexts().getOperatingSystem(); - final OperatingSystem androidOS = getOperatingSystem(); - // make Android OS the main OS using the 'os' key - event.getContexts().setOperatingSystem(androidOS); + if (deviceInfoUtil != null) { + try { + final OperatingSystem androidOS = deviceInfoUtil.get().getOperatingSystem(); + // make Android OS the main OS using the 'os' key + event.getContexts().setOperatingSystem(androidOS); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve os system", e); + } + } else { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info"); + } if (currentOS != null) { // add additional OS which was already part of the SentryEvent (eg Linux read from NDK) @@ -202,6 +230,62 @@ private void mergeOS(final @NotNull SentryBaseEvent event) { } } + private void setDevice(final @NotNull SentryLogEvent 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 SentryLogEvent 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); + } + } + + 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) { @@ -210,9 +294,7 @@ private void processNonCachedEvent( app = new App(); } setAppExtras(app, hint); - setPackageInfo(event, app); - event.getContexts().setApp(app); } @@ -221,7 +303,7 @@ private void setThreads(final @NotNull SentryEvent event, final @NotNull Hint hi final boolean isHybridSDK = HintUtils.isFromHybridSdk(hint); for (final SentryThread thread : event.getThreads()) { - final boolean isMainThread = AndroidMainThreadChecker.getInstance().isMainThread(thread); + final boolean isMainThread = AndroidThreadChecker.getInstance().isMainThread(thread); // TODO: Fix https://github.com/getsentry/team-mobile/issues/47 if (thread.isCurrent() == null) { @@ -244,7 +326,19 @@ private void setPackageInfo(final @NotNull SentryBaseEvent event, final @NotNull String versionCode = ContextUtils.getVersionCode(packageInfo, buildInfoProvider); setDist(event, versionCode); - setAppPackageInfo(app, packageInfo); + + @Nullable DeviceInfoUtil deviceInfoUtil = null; + if (this.deviceInfoUtil != null) { + try { + deviceInfoUtil = this.deviceInfoUtil.get(); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info", e); + } + } else { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info"); + } + + ContextUtils.setAppPackageInfo(packageInfo, buildInfoProvider, deviceInfoUtil, app); } } @@ -255,8 +349,12 @@ private void setDist(final @NotNull SentryBaseEvent event, final @NotNull String } private void setAppExtras(final @NotNull App app, final @NotNull Hint hint) { - app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); - app.setAppStartTime(DateUtils.toUtilDate(AppStartState.getInstance().getAppStartTime())); + app.setAppName(ContextUtils.getApplicationName(context)); + final @NotNull TimeSpan appStartTimeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); + if (appStartTimeSpan.hasStarted()) { + app.setAppStartTime(DateUtils.toUtilDate(appStartTimeSpan.getStartTimestamp())); + } // This should not be set by Hybrid SDKs since they have their own app's lifecycle if (!HintUtils.isFromHybridSdk(hint) && app.getInForeground() == null) { @@ -269,499 +367,32 @@ private void setAppExtras(final @NotNull App app, final @NotNull Hint hint) { } } - @SuppressWarnings({"ObsoleteSdkInt", "NewApi"}) - private @NotNull Long getMemorySize(final @NotNull ActivityManager.MemoryInfo memInfo) { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN) { - return memInfo.totalMem; - } - // using Runtime as a fallback - return java.lang.Runtime.getRuntime().totalMemory(); // JVM in bytes too - } - - // we can get some inspiration here - // https://github.com/flutter/plugins/blob/master/packages/device_info/android/src/main/java/io/flutter/plugins/deviceinfo/DeviceInfoPlugin.java - private @NotNull Device getDevice(final boolean errorEvent, final boolean applyScopeData) { - // TODO: missing usable memory - - Device device = new Device(); - if (options.isSendDefaultPii()) { - device.setName(ContextUtils.getDeviceName(context, buildInfoProvider)); - } - 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(buildInfoProvider)); - - // setting such values require IO hence we don't run for transactions - if (errorEvent) { - if (options.isCollectAdditionalContext()) { - setDeviceIO(device, applyScopeData); - } - } - - device.setOrientation(getOrientation()); - - try { - Object emulator = contextData.get().get(EMULATOR); - if (emulator != null) { - device.setSimulator((Boolean) emulator); - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting emulator.", e); - } - - 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); - } - - device.setBootTime(getBootTime()); - device.setTimezone(getTimeZone()); - - if (device.getId() == null) { - device.setId(getDeviceId()); - } - - final Locale locale = Locale.getDefault(); - if (device.getLanguage() == null) { - device.setLanguage(locale.getLanguage()); - } - if (device.getLocale() == null) { - device.setLocale(locale.toString()); // eg en_US - } - - final @NotNull List cpuFrequencies = CpuInfoUtils.getInstance().readMaxFrequencies(); - if (!cpuFrequencies.isEmpty()) { - device.setProcessorFrequency(Collections.max(cpuFrequencies).doubleValue()); - device.setProcessorCount(cpuFrequencies.size()); - } - - return device; - } - - private void setDeviceIO(final @NotNull Device device, final boolean applyScopeData) { - final Intent batteryIntent = getBatteryIntent(); - if (batteryIntent != null) { - device.setBatteryLevel(getBatteryLevel(batteryIntent)); - device.setCharging(isCharging(batteryIntent)); - device.setBatteryTemperature(getBatteryTemperature(batteryIntent)); - } - - Boolean connected; - switch (ConnectivityChecker.getConnectionStatus(context, options.getLogger())) { - case NOT_CONNECTED: - connected = false; - break; - case CONNECTED: - connected = true; - break; - default: - connected = null; - } - device.setOnline(connected); - - final ActivityManager.MemoryInfo memInfo = - ContextUtils.getMemInfo(context, options.getLogger()); - if (memInfo != null) { - // in bytes - device.setMemorySize(getMemorySize(memInfo)); - if (applyScopeData) { - device.setFreeMemory(memInfo.availMem); - device.setLowMemory(memInfo.lowMemory); - } - // there are runtime.totalMemory() and runtime.freeMemory(), but I kept the same for - // compatibility - } - - // 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 File internalStorageFile = context.getExternalFilesDir(null); - if (internalStorageFile != null) { - StatFs internalStorageStat = new StatFs(internalStorageFile.getPath()); - device.setStorageSize(getTotalInternalStorage(internalStorageStat)); - device.setFreeStorage(getUnusedInternalStorage(internalStorageStat)); - } - - final StatFs externalStorageStat = getExternalStorageStat(internalStorageFile); - if (externalStorageStat != null) { - device.setExternalStorageSize(getTotalExternalStorage(externalStorageStat)); - device.setExternalFreeStorage(getUnusedExternalStorage(externalStorageStat)); - } - - if (device.getConnectionType() == null) { - // wifi, ethernet or cellular, null if none - device.setConnectionType( - ConnectivityChecker.getConnectionType(context, options.getLogger(), buildInfoProvider)); - } - } - - @SuppressWarnings("NewApi") - private TimeZone getTimeZone() { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N) { - LocaleList locales = context.getResources().getConfiguration().getLocales(); - if (!locales.isEmpty()) { - Locale locale = locales.get(0); - return Calendar.getInstance(locale).getTimeZone(); - } - } - return Calendar.getInstance().getTimeZone(); - } - - @SuppressWarnings("JdkObsolete") - private @Nullable Date getBootTime() { - try { - // if user changes the clock, will give a wrong answer, consider ACTION_TIME_CHANGED. - // currentTimeMillis returns UTC already - return DateUtils.getDateTime(System.currentTimeMillis() - SystemClock.elapsedRealtime()); - } catch (IllegalArgumentException e) { - options.getLogger().log(SentryLevel.ERROR, e, "Error getting the device's boot time."); - } - return null; - } - - private @Nullable Intent getBatteryIntent() { - return context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); - } - - /** - * Get the device's current battery level (as a percentage of total). - * - * @return the device's current battery level (as a percentage of total), or null if unknown - */ - private @Nullable Float getBatteryLevel(final @NotNull Intent batteryIntent) { - try { - int level = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); - int scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); - - if (level == -1 || scale == -1) { - return null; - } - - float percentMultiplier = 100.0f; - - return ((float) level / (float) scale) * percentMultiplier; - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting device battery level.", e); - return null; - } - } - - /** - * Checks whether or not the device is currently plugged in and charging, or null if unknown. - * - * @return whether or not the device is currently plugged in and charging, or null if unknown - */ - private @Nullable Boolean isCharging(final @NotNull Intent batteryIntent) { - try { - int plugged = batteryIntent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1); - return plugged == BatteryManager.BATTERY_PLUGGED_AC - || plugged == BatteryManager.BATTERY_PLUGGED_USB; - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting device charging state.", e); - return null; - } - } - - private @Nullable Float getBatteryTemperature(final @NotNull Intent batteryIntent) { - try { - int temperature = batteryIntent.getIntExtra(EXTRA_TEMPERATURE, -1); - if (temperature != -1) { - return ((float) temperature) / 10; // celsius - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting battery temperature.", e); - } - return null; - } - - /** - * Get the device's current screen orientation. - * - * @return the device's current screen orientation, or null if unknown - */ - @SuppressWarnings("deprecation") - private @Nullable Device.DeviceOrientation getOrientation() { - Device.DeviceOrientation deviceOrientation = null; - try { - deviceOrientation = - DeviceOrientations.getOrientation(context.getResources().getConfiguration().orientation); - if (deviceOrientation == null) { - options - .getLogger() - .log( - SentryLevel.INFO, - "No device orientation available (ORIENTATION_SQUARE|ORIENTATION_UNDEFINED)"); - return null; - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting device orientation.", e); - } - return deviceOrientation; - } - - /** - * Get the total amount of internal storage, in bytes. - * - * @return the total amount of internal storage, in bytes - */ - private @Nullable Long getTotalInternalStorage(final @NotNull StatFs stat) { - try { - long blockSize = getBlockSizeLong(stat); - long totalBlocks = getBlockCountLong(stat); - return totalBlocks * blockSize; - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting total internal storage amount.", e); - return null; - } - } - - @SuppressWarnings({"ObsoleteSdkInt", "NewApi"}) - private long getBlockSizeLong(final @NotNull StatFs stat) { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN_MR2) { - return stat.getBlockSizeLong(); - } - return getBlockSizeDep(stat); - } - - @SuppressWarnings("deprecation") - private int getBlockSizeDep(final @NotNull StatFs stat) { - return stat.getBlockSize(); - } - - @SuppressWarnings({"ObsoleteSdkInt", "NewApi"}) - private long getBlockCountLong(final @NotNull StatFs stat) { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN_MR2) { - return stat.getBlockCountLong(); - } - return getBlockCountDep(stat); - } - - @SuppressWarnings("deprecation") - private int getBlockCountDep(final @NotNull StatFs stat) { - return stat.getBlockCount(); - } - - @SuppressWarnings({"ObsoleteSdkInt", "NewApi"}) - private long getAvailableBlocksLong(final @NotNull StatFs stat) { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN_MR2) { - return stat.getAvailableBlocksLong(); - } - return getAvailableBlocksDep(stat); - } - - @SuppressWarnings("deprecation") - private int getAvailableBlocksDep(final @NotNull StatFs stat) { - return stat.getAvailableBlocks(); - } - - /** - * Get the unused amount of internal storage, in bytes. - * - * @return the unused amount of internal storage, in bytes - */ - private @Nullable Long getUnusedInternalStorage(final @NotNull StatFs stat) { - try { - long blockSize = getBlockSizeLong(stat); - long availableBlocks = getAvailableBlocksLong(stat); - return availableBlocks * blockSize; - } catch (Throwable e) { - options - .getLogger() - .log(SentryLevel.ERROR, "Error getting unused internal storage amount.", e); - return null; - } - } - - private @Nullable StatFs getExternalStorageStat(final @Nullable File internalStorage) { - if (!isExternalStorageMounted()) { - File path = getExternalStorageDep(internalStorage); - if (path != null) { // && path.canRead()) { canRead() will read return false - return new StatFs(path.getPath()); - } - options.getLogger().log(SentryLevel.INFO, "Not possible to read external files directory"); - return null; - } - options.getLogger().log(SentryLevel.INFO, "External storage is not mounted or emulated."); - return null; - } - - @SuppressWarnings({"ObsoleteSdkInt", "NewApi"}) - private @Nullable File[] getExternalFilesDirs() { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.KITKAT) { - return context.getExternalFilesDirs(null); - } else { - File single = context.getExternalFilesDir(null); - if (single != null) { - return new File[] {single}; - } - } - return null; - } - - private @Nullable File getExternalStorageDep(final @Nullable File internalStorage) { - File[] externalFilesDirs = getExternalFilesDirs(); - - if (externalFilesDirs != null) { - // return the 1st file which is not the emulated internal storage - String internalStoragePath = - internalStorage != null ? internalStorage.getAbsolutePath() : null; - for (File file : externalFilesDirs) { - // externalFilesDirs may contain null values :( - if (file == null) { - continue; - } - - // return the 1st file if you cannot compare with the internal one - if (internalStoragePath == null || internalStoragePath.isEmpty()) { - return file; - } - // if we are looking to the same directory, let's check the next one or no external storage - if (file.getAbsolutePath().contains(internalStoragePath)) { - continue; - } - return file; - } - } else { - options.getLogger().log(SentryLevel.INFO, "Not possible to read getExternalFilesDirs"); - } - return null; - } - - /** - * Get the total amount of external storage, in bytes, or null if no external storage is mounted. - * - * @return the total amount of external storage, in bytes, or null if no external storage is - * mounted - */ - private @Nullable Long getTotalExternalStorage(final @NotNull StatFs stat) { - try { - long blockSize = getBlockSizeLong(stat); - long totalBlocks = getBlockCountLong(stat); - return totalBlocks * blockSize; - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting total external storage amount.", e); - return null; - } - } - - private boolean isExternalStorageMounted() { - final String storageState = Environment.getExternalStorageState(); - return (Environment.MEDIA_MOUNTED.equals(storageState) - || Environment.MEDIA_MOUNTED_READ_ONLY.equals(storageState)) - && !Environment.isExternalStorageEmulated(); - } - - /** - * Get the unused amount of external storage, in bytes, or null if no external storage is mounted. - * - * @return the unused amount of external storage, in bytes, or null if no external storage is - * mounted - */ - private @Nullable Long getUnusedExternalStorage(final @NotNull StatFs stat) { - try { - long blockSize = getBlockSizeLong(stat); - long availableBlocks = getAvailableBlocksLong(stat); - return availableBlocks * blockSize; - } catch (Throwable e) { - options - .getLogger() - .log(SentryLevel.ERROR, "Error getting unused external storage amount.", e); - return null; - } - } - - private @NotNull OperatingSystem getOperatingSystem() { - OperatingSystem os = new OperatingSystem(); - os.setName("Android"); - os.setVersion(Build.VERSION.RELEASE); - os.setBuild(Build.DISPLAY); - - try { - Object kernelVersion = contextData.get().get(KERNEL_VERSION); - if (kernelVersion != null) { - os.setKernelVersion((String) kernelVersion); - } - - Object rooted = contextData.get().get(ROOTED); - if (rooted != null) { - os.setRooted((Boolean) rooted); - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting OperatingSystem.", e); - } - - return os; - } - - @SuppressLint("NewApi") // we perform an if-check for that, but lint fails to recognize - private void setAppPackageInfo(final @NotNull App app, final @NotNull PackageInfo packageInfo) { - app.setAppIdentifier(packageInfo.packageName); - app.setAppVersion(packageInfo.versionName); - app.setAppBuild(ContextUtils.getVersionCode(packageInfo, buildInfoProvider)); - - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN) { - final Map permissions = new HashMap<>(); - final String[] requestedPermissions = packageInfo.requestedPermissions; - final int[] requestedPermissionsFlags = packageInfo.requestedPermissionsFlags; - - if (requestedPermissions != null - && requestedPermissions.length > 0 - && requestedPermissionsFlags != null - && requestedPermissionsFlags.length > 0) { - for (int i = 0; i < requestedPermissions.length; i++) { - String permission = requestedPermissions[i]; - permission = permission.substring(permission.lastIndexOf('.') + 1); - - final boolean granted = - (requestedPermissionsFlags[i] & REQUESTED_PERMISSION_GRANTED) - == REQUESTED_PERMISSION_GRANTED; - permissions.put(permission, granted ? "granted" : "not_granted"); - } - } - app.setPermissions(permissions); - } - } - /** * Sets the default user which contains only the userId. * * @return the User object */ - public @NotNull User getDefaultUser() { - User user = new User(); - user.setId(getDeviceId()); - + public @NotNull User getDefaultUser(final @NotNull Context context) { + final @NotNull User user = new User(); + user.setId(Installation.id(context)); return user; } - private @Nullable String getDeviceId() { - try { - return Installation.id(context); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting installationId.", e); - } - return null; - } - - @SuppressWarnings("unchecked") private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { - try { - final Object sideLoadedInfo = contextData.get().get(SIDE_LOADED); - - if (sideLoadedInfo instanceof Map) { - for (final Map.Entry entry : - ((Map) sideLoadedInfo).entrySet()) { - event.setTag(entry.getKey(), entry.getValue()); + if (deviceInfoUtil != null) { + try { + final ContextUtils.SideLoadedInfo sideLoadedInfo = deviceInfoUtil.get().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); } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting side loaded info.", e); + } else { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info"); } } @@ -778,4 +409,31 @@ private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { return transaction; } + + @Override + public @NotNull SentryReplayEvent process( + final @NotNull SentryReplayEvent event, final @NotNull Hint hint) { + final boolean applyScopeData = shouldApplyScopeData(event, hint); + if (applyScopeData) { + processNonCachedEvent(event, hint); + } + + setCommons(event, false, applyScopeData); + + return event; + } + + @Override + 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 new file mode 100644 index 00000000000..63b88c0e440 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -0,0 +1,501 @@ +package io.sentry.android.core; + +import static android.os.BatteryManager.EXTRA_TEMPERATURE; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.content.Context; +import android.content.Intent; +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; +import android.util.DisplayMetrics; +import io.sentry.DateUtils; +import io.sentry.ISentryLifecycleToken; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.android.core.internal.util.CpuInfoUtils; +import io.sentry.android.core.internal.util.DeviceOrientations; +import io.sentry.android.core.internal.util.RootChecker; +import io.sentry.protocol.Device; +import io.sentry.protocol.OperatingSystem; +import io.sentry.util.AutoClosableReentrantLock; +import java.io.File; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +@ApiStatus.Internal +public final class DeviceInfoUtil { + + @SuppressLint("StaticFieldLeak") + private static volatile DeviceInfoUtil instance; + + private static final @NotNull AutoClosableReentrantLock staticLock = + new AutoClosableReentrantLock(); + + private final @NotNull Context context; + private final @NotNull SentryAndroidOptions options; + private final @NotNull BuildInfoProvider buildInfoProvider; + private final @Nullable Boolean isEmulator; + private final @Nullable ContextUtils.SideLoadedInfo sideLoadedInfo; + private final @Nullable ContextUtils.SplitApksInfo splitApksInfo; + private final @NotNull OperatingSystem os; + + private final @Nullable Long totalMem; + + public DeviceInfoUtil( + final @NotNull Context context, final @NotNull SentryAndroidOptions options) { + this.context = context; + this.options = options; + this.buildInfoProvider = new BuildInfoProvider(options.getLogger()); + + // these are potentially expense IO operations + CpuInfoUtils.getInstance().readMaxFrequencies(); + os = retrieveOperatingSystemInformation(); + isEmulator = buildInfoProvider.isEmulator(); + sideLoadedInfo = + ContextUtils.retrieveSideLoadedInfo(context, options.getLogger(), buildInfoProvider); + splitApksInfo = ContextUtils.retrieveSplitApksInfo(context, buildInfoProvider); + final @Nullable ActivityManager.MemoryInfo memInfo = + ContextUtils.getMemInfo(context, options.getLogger()); + if (memInfo != null) { + totalMem = memInfo.totalMem; + } else { + totalMem = null; + } + } + + @NotNull + public static DeviceInfoUtil getInstance( + final @NotNull Context context, final @NotNull SentryAndroidOptions options) { + if (instance == null) { + try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { + if (instance == null) { + instance = new DeviceInfoUtil(ContextUtils.getApplicationContext(context), options); + } + } + } + return instance; + } + + @TestOnly + public static void resetInstance() { + instance = null; + } + + // we can get some inspiration here + // https://github.com/flutter/plugins/blob/master/packages/device_info/android/src/main/java/io/flutter/plugins/deviceinfo/DeviceInfoPlugin.java + @SuppressLint("NewApi") + @NotNull + public Device collectDeviceInformation( + final boolean collectDeviceIO, final boolean collectDynamicData) { + // TODO: missing usable memory + final @NotNull 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()); + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.S) { + device.setChipset(Build.SOC_MANUFACTURER + " " + Build.SOC_MODEL); + } + + device.setOrientation(getOrientation()); + if (isEmulator != null) { + device.setSimulator(isEmulator); + } + + final @Nullable 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); + } + + device.setBootTime(getBootTime()); + device.setTimezone(getTimeZone()); + + if (device.getId() == null) { + device.setId(getDeviceId()); + } + + final @NotNull Locale locale = Locale.getDefault(); + if (device.getLocale() == null) { + device.setLocale(locale.toString()); // eg en_US + } + + final @NotNull List cpuFrequencies = CpuInfoUtils.getInstance().readMaxFrequencies(); + if (!cpuFrequencies.isEmpty()) { + device.setProcessorFrequency(Collections.max(cpuFrequencies).doubleValue()); + device.setProcessorCount(cpuFrequencies.size()); + } + + device.setMemorySize(totalMem); + + // setting such values require IO hence we don't run for transactions + if (collectDeviceIO && options.isCollectAdditionalContext()) { + setDeviceIO(device, collectDynamicData, options.isCollectExternalStorageContext()); + } + + return device; + } + + @NotNull + public OperatingSystem getOperatingSystem() { + return os; + } + + @Nullable + public Long getTotalMemory() { + return totalMem; + } + + @NotNull + private OperatingSystem retrieveOperatingSystemInformation() { + + final OperatingSystem os = new OperatingSystem(); + os.setName("Android"); + os.setVersion(Build.VERSION.RELEASE); + os.setBuild(Build.DISPLAY); + + final @Nullable String kernelVersion = ContextUtils.getKernelVersion(options.getLogger()); + if (kernelVersion != null) { + os.setKernelVersion(kernelVersion); + } + + if (options.isEnableRootCheck()) { + final boolean rooted = + new RootChecker(context, buildInfoProvider, options.getLogger()).isDeviceRooted(); + os.setRooted(rooted); + } + return os; + } + + @Nullable + public ContextUtils.SideLoadedInfo getSideLoadedInfo() { + return sideLoadedInfo; + } + + @Nullable + public ContextUtils.SplitApksInfo getSplitApksInfo() { + return splitApksInfo; + } + + 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)); + device.setCharging(isCharging(batteryIntent, options)); + device.setBatteryTemperature(getBatteryTemperature(batteryIntent)); + } + + // TODO .getConnectionStatus() may be blocking, investigate if this can be done async + Boolean connected; + switch (options.getConnectionStatusProvider().getConnectionStatus()) { + case DISCONNECTED: + connected = false; + break; + case CONNECTED: + connected = true; + break; + default: + connected = null; + } + device.setOnline(connected); + + final @Nullable ActivityManager.MemoryInfo memInfo = + ContextUtils.getMemInfo(context, options.getLogger()); + if (memInfo != null && includeDynamicData) { + // in bytes + device.setFreeMemory(memInfo.availMem); + device.setLowMemory(memInfo.lowMemory); + } + + // 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 dataDir = Environment.getDataDirectory(); + if (dataDir != null) { + StatFs internalStorageStat = new StatFs(dataDir.getPath()); + device.setStorageSize(getTotalInternalStorage(internalStorageStat)); + device.setFreeStorage(getUnusedInternalStorage(internalStorageStat)); + } + + 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) { + // wifi, ethernet or cellular, null if none + device.setConnectionType(options.getConnectionStatusProvider().getConnectionType()); + } + } + + @SuppressWarnings("NewApi") + @NotNull + private TimeZone getTimeZone() { + // 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); + if (locale.getUnicodeLocaleType("tz") != null) { + return Calendar.getInstance(locale).getTimeZone(); + } + } + } + return TimeZone.getDefault(); + } + + @SuppressWarnings("JdkObsolete") + @Nullable + private Date getBootTime() { + try { + // if user changes the clock, will give a wrong answer, consider ACTION_TIME_CHANGED. + // currentTimeMillis returns UTC already + return DateUtils.getDateTime(System.currentTimeMillis() - SystemClock.elapsedRealtime()); + } catch (IllegalArgumentException e) { + options.getLogger().log(SentryLevel.ERROR, e, "Error getting the device's boot time."); + } + return null; + } + + @Nullable + private Intent getBatteryIntent() { + return ContextUtils.registerReceiver( + context, buildInfoProvider, null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED), null); + } + + /** + * Get the device's current battery level (as a percentage of total). + * + * @return the device's current battery level (as a percentage of total), or null if unknown + */ + @Nullable + public static Float getBatteryLevel( + final @NotNull Intent batteryIntent, final @NotNull SentryOptions options) { + try { + int level = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + int scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + + if (level == -1 || scale == -1) { + return null; + } + + float percentMultiplier = 100.0f; + + return ((float) level / (float) scale) * percentMultiplier; + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting device battery level.", e); + return null; + } + } + + /** + * Checks whether or not the device is currently plugged in and charging, or null if unknown. + * + * @return whether or not the device is currently plugged in and charging, or null if unknown + */ + @Nullable + public static Boolean isCharging( + final @NotNull Intent batteryIntent, final @NotNull SentryOptions options) { + try { + int plugged = batteryIntent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1); + return plugged == BatteryManager.BATTERY_PLUGGED_AC + || plugged == BatteryManager.BATTERY_PLUGGED_USB; + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting device charging state.", e); + return null; + } + } + + @Nullable + private Float getBatteryTemperature(final @NotNull Intent batteryIntent) { + try { + int temperature = batteryIntent.getIntExtra(EXTRA_TEMPERATURE, -1); + if (temperature != -1) { + return ((float) temperature) / 10; // celsius + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting battery temperature.", e); + } + return null; + } + + /** + * Get the device's current screen orientation. + * + * @return the device's current screen orientation, or null if unknown + */ + @Nullable + private Device.DeviceOrientation getOrientation() { + Device.DeviceOrientation deviceOrientation = null; + try { + deviceOrientation = + DeviceOrientations.getOrientation(context.getResources().getConfiguration().orientation); + if (deviceOrientation == null) { + options + .getLogger() + .log( + SentryLevel.INFO, + "No device orientation available (ORIENTATION_SQUARE|ORIENTATION_UNDEFINED)"); + return null; + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting device orientation.", e); + } + return deviceOrientation; + } + + /** + * Get the total amount of internal storage, in bytes. + * + * @return the total amount of internal storage, in bytes + */ + @Nullable + private Long getTotalInternalStorage(final @NotNull StatFs stat) { + try { + long blockSize = stat.getBlockSizeLong(); + long totalBlocks = stat.getBlockCountLong(); + return totalBlocks * blockSize; + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting total internal storage amount.", e); + return null; + } + } + + /** + * Get the unused amount of internal storage, in bytes. + * + * @return the unused amount of internal storage, in bytes + */ + @Nullable + private Long getUnusedInternalStorage(final @NotNull StatFs stat) { + try { + long blockSize = stat.getBlockSizeLong(); + long availableBlocks = stat.getAvailableBlocksLong(); + return availableBlocks * blockSize; + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "Error getting unused internal storage amount.", e); + return null; + } + } + + @Nullable + private StatFs getExternalStorageStat(final @Nullable File internalStorage) { + try { + File path = getExternalStorageDep(internalStorage); + if (path != null) { // && path.canRead()) { canRead() will read return false + return new StatFs(path.getPath()); + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.INFO, "Not possible to read external files directory"); + } + return null; + } + + @Nullable + private File getExternalStorageDep(final @Nullable File internalStorage) { + final @Nullable File[] externalFilesDirs = context.getExternalFilesDirs(null); + + if (externalFilesDirs != null) { + // return the 1st file which is not the emulated internal storage + String internalStoragePath = + internalStorage != null ? internalStorage.getAbsolutePath() : null; + for (File file : externalFilesDirs) { + // externalFilesDirs may contain null values :( + if (file == null) { + continue; + } + + // return the 1st file if you cannot compare with the internal one + if (internalStoragePath == null || internalStoragePath.isEmpty()) { + return file; + } + // if we are looking to the same directory, let's check the next one or no external storage + if (file.getAbsolutePath().contains(internalStoragePath)) { + continue; + } + return file; + } + } else { + options.getLogger().log(SentryLevel.INFO, "Not possible to read getExternalFilesDirs"); + } + return null; + } + + /** + * Get the total amount of external storage, in bytes, or null if no external storage is mounted. + * + * @return the total amount of external storage, in bytes, or null if no external storage is + * mounted + */ + @Nullable + private Long getTotalExternalStorage(final @NotNull StatFs stat) { + try { + final long blockSize = stat.getBlockSizeLong(); + final long totalBlocks = stat.getBlockCountLong(); + return totalBlocks * blockSize; + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting total external storage amount.", e); + return null; + } + } + + /** + * Get the unused amount of external storage, in bytes, or null if no external storage is mounted. + * + * @return the unused amount of external storage, in bytes, or null if no external storage is + * mounted + */ + @Nullable + private Long getUnusedExternalStorage(final @NotNull StatFs stat) { + try { + final long blockSize = stat.getBlockSizeLong(); + final long availableBlocks = stat.getAvailableBlocksLong(); + return availableBlocks * blockSize; + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "Error getting unused external storage amount.", e); + return null; + } + } + + @Nullable + private String getDeviceId() { + try { + return Installation.id(context); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting installationId.", e); + } + return null; + } +} 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 140b9449822..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 @@ -1,13 +1,19 @@ package io.sentry.android.core; -import io.sentry.IHub; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.OutboxSender; 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; @@ -16,14 +22,16 @@ public abstract class EnvelopeFileObserverIntegration implements Integration, Closeable { private @Nullable EnvelopeFileObserver observer; private @Nullable ILogger logger; + private boolean isClosed = false; + protected final @NotNull AutoClosableReentrantLock startLock = new AutoClosableReentrantLock(); public static @NotNull EnvelopeFileObserverIntegration getOutboxFileObserver() { return new OutboxEnvelopeFileObserverIntegration(); } @Override - public final void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); + public final void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + Objects.requireNonNull(scopes, "Scopes are required"); Objects.requireNonNull(options, "SentryOptions is required"); logger = options.getLogger(); @@ -37,30 +45,65 @@ public final void register(final @NotNull IHub hub, final @NotNull SentryOptions logger.log( SentryLevel.DEBUG, "Registering EnvelopeFileObserverIntegration for path: %s", path); - final OutboxSender outboxSender = - new OutboxSender( - hub, - options.getEnvelopeReader(), - options.getSerializer(), - logger, - options.getFlushTimeoutMillis()); - - observer = - new EnvelopeFileObserver(path, outboxSender, logger, options.getFlushTimeoutMillis()); try { - observer.startWatching(); - logger.log(SentryLevel.DEBUG, "EnvelopeFileObserverIntegration installed."); - } catch (Throwable e) { - // it could throw eg NoSuchFileException or NullPointerException options - .getLogger() - .log(SentryLevel.ERROR, "Failed to initialize EnvelopeFileObserverIntegration.", e); + .getExecutorService() + .submit( + () -> { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { + if (!isClosed) { + startOutboxSender(scopes, options, path); + } + } + }); + } catch (Throwable e) { + logger.log( + SentryLevel.DEBUG, + "Failed to start EnvelopeFileObserverIntegration on executor thread.", + e); } } } + 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, + options.getEnvelopeReader(), + options.getSerializer(), + options.getLogger(), + options.getFlushTimeoutMillis(), + options.getMaxQueueSize()); + + observer = + new EnvelopeFileObserver( + path, outboxSender, options.getLogger(), options.getFlushTimeoutMillis()); + try { + observer.startWatching(); + options.getLogger().log(SentryLevel.DEBUG, "EnvelopeFileObserverIntegration installed."); + addIntegrationToSdkVersion("EnvelopeFileObserver"); + } catch (Throwable e) { + // it could throw eg NoSuchFileException or NullPointerException + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to initialize EnvelopeFileObserverIntegration.", e); + } + } + @Override public void close() { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { + isClosed = true; + } if (observer != null) { observer.stopWatching(); 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/IDebugImagesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java index 902f7efc2b5..7b98147aab8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java @@ -2,6 +2,7 @@ import io.sentry.protocol.DebugImage; import java.util.List; +import java.util.Set; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; @@ -11,5 +12,8 @@ public interface IDebugImagesLoader { @Nullable List loadDebugImages(); + @Nullable + Set loadDebugImagesForAddresses(Set addresses); + void clearDebugImages(); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/Installation.java b/sentry-android-core/src/main/java/io/sentry/android/core/Installation.java index 007bb306cdd..ba08e71342a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/Installation.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/Installation.java @@ -1,13 +1,15 @@ package io.sentry.android.core; import android.content.Context; +import io.sentry.ISentryLifecycleToken; +import io.sentry.SentryUUID; +import io.sentry.util.AutoClosableReentrantLock; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.RandomAccessFile; import java.nio.charset.Charset; -import java.util.UUID; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -19,6 +21,9 @@ final class Installation { private static final Charset UTF_8 = Charset.forName("UTF-8"); + protected static final @NotNull AutoClosableReentrantLock staticLock = + new AutoClosableReentrantLock(); + private Installation() {} /** @@ -29,20 +34,22 @@ private Installation() {} * @return the generated installationId * @throws RuntimeException if not possible to read nor to write to the file. */ - public static synchronized String id(final @NotNull Context context) throws RuntimeException { - if (deviceId == null) { - final File installation = new File(context.getFilesDir(), INSTALLATION); - try { - if (!installation.exists()) { - deviceId = writeInstallationFile(installation); - return deviceId; + public static String id(final @NotNull Context context) throws RuntimeException { + try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { + if (deviceId == null) { + final File installation = new File(context.getFilesDir(), INSTALLATION); + try { + if (!installation.exists()) { + deviceId = writeInstallationFile(installation); + return deviceId; + } + deviceId = readInstallationFile(installation); + } catch (Throwable e) { + throw new RuntimeException(e); } - deviceId = readInstallationFile(installation); - } catch (Throwable e) { - throw new RuntimeException(e); } + return deviceId; } - return deviceId; } @TestOnly @@ -58,7 +65,7 @@ public static synchronized String id(final @NotNull Context context) throws Runt static @NotNull String writeInstallationFile(final @NotNull File installation) throws IOException { try (final OutputStream out = new FileOutputStream(installation)) { - final String id = UUID.randomUUID().toString(); + final String id = SentryUUID.generateSentryId(); out.write(id.getBytes(UTF_8)); out.flush(); return id; 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 new file mode 100644 index 00000000000..2779f803a69 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -0,0 +1,345 @@ +package io.sentry.android.core; + +import static io.sentry.Sentry.getCurrentScopes; +import static io.sentry.SentryLevel.INFO; +import static io.sentry.SentryLevel.WARNING; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import io.sentry.DateUtils; +import io.sentry.ILogger; +import io.sentry.IScope; +import io.sentry.IScopes; +import io.sentry.ISerializer; +import io.sentry.ObjectWriter; +import io.sentry.PropagationContext; +import io.sentry.ScopeType; +import io.sentry.ScopesAdapter; +import io.sentry.SentryEnvelope; +import io.sentry.SentryEnvelopeItem; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.Session; +import io.sentry.android.core.performance.ActivityLifecycleTimeSpan; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; +import io.sentry.cache.EnvelopeCache; +import io.sentry.protocol.App; +import io.sentry.protocol.Device; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.User; +import io.sentry.util.MapObjectWriter; +import io.sentry.util.TracingUtils; +import java.io.ByteArrayInputStream; +import java.io.File; +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.concurrent.atomic.AtomicReference; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Sentry SDK internal API methods meant for being used by the Sentry Hybrid SDKs. */ +@ApiStatus.Internal +public final class InternalSentrySdk { + + /** + * @return a copy of the current scopes's topmost scope, or null in case the scopes is disabled + */ + @Nullable + public static IScope getCurrentScope() { + final @NotNull AtomicReference scopeRef = new AtomicReference<>(); + ScopesAdapter.getInstance() + .configureScope( + ScopeType.COMBINED, + scope -> { + scopeRef.set(scope.clone()); + }); + return scopeRef.get(); + } + + /** + * Serializes the provided scope. Specific data may be back-filled (e.g. device context) if the + * scope itself does not provide it. + * + * @param context Android context + * @param options Sentry Options + * @param scope the scope + * @return a map containing all relevant scope data (user, contexts, tags, extras, fingerprint, + * level, breadcrumbs) + */ + @NotNull + public static Map serializeScope( + final @NotNull Context context, + final @NotNull SentryAndroidOptions options, + final @Nullable IScope scope) { + + final @NotNull Map data = new HashMap<>(); + if (scope == null) { + return data; + } + try { + final @NotNull ILogger logger = options.getLogger(); + final @NotNull ObjectWriter writer = new MapObjectWriter(data); + + final @NotNull DeviceInfoUtil deviceInfoUtil = DeviceInfoUtil.getInstance(context, options); + final @NotNull Device deviceInfo = deviceInfoUtil.collectDeviceInformation(true, true); + scope.getContexts().setDevice(deviceInfo); + scope.getContexts().setOperatingSystem(deviceInfoUtil.getOperatingSystem()); + + // user + @Nullable User user = scope.getUser(); + if (user == null) { + user = new User(); + scope.setUser(user); + } + if (user.getId() == null) { + try { + user.setId(Installation.id(context)); + } catch (RuntimeException e) { + logger.log(SentryLevel.ERROR, "Could not retrieve installation ID", e); + } + } + + // app context + @Nullable App app = scope.getContexts().getApp(); + if (app == null) { + app = new App(); + } + app.setAppName(ContextUtils.getApplicationName(context)); + + final @NotNull TimeSpan appStartTimeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); + if (appStartTimeSpan.hasStarted()) { + app.setAppStartTime(DateUtils.toUtilDate(appStartTimeSpan.getStartTimestamp())); + } + + final @NotNull BuildInfoProvider buildInfoProvider = + new BuildInfoProvider(options.getLogger()); + final @Nullable PackageInfo packageInfo = + ContextUtils.getPackageInfo( + context, PackageManager.GET_PERMISSIONS, options.getLogger(), buildInfoProvider); + if (packageInfo != null) { + ContextUtils.setAppPackageInfo(packageInfo, buildInfoProvider, deviceInfoUtil, app); + } + scope.getContexts().setApp(app); + + writer.name("user").value(logger, scope.getUser()); + writer.name("contexts").value(logger, scope.getContexts()); + writer.name("tags").value(logger, scope.getTags()); + writer.name("extras").value(logger, scope.getExtras()); + writer.name("fingerprint").value(logger, scope.getFingerprint()); + writer.name("level").value(logger, scope.getLevel()); + writer.name("breadcrumbs").value(logger, scope.getBreadcrumbs()); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Could not serialize scope.", e); + return new HashMap<>(); + } + + return data; + } + + /** + * Captures the provided envelope. Compared to {@link IScopes#captureEvent(SentryEvent)} this + * method
+ * - will not enrich events with additional data (e.g. scope)
+ * - will not execute beforeSend: it's up to the caller to take care of this
+ * - will not perform any sampling: it's up to the caller to take care of this
+ * - will enrich the envelope with a Session update if applicable
+ * + * @param envelopeData the serialized envelope data + * @return The Id (SentryId object) of the event, or null in case the envelope could not be + * captured + */ + @Nullable + public static SentryId captureEnvelope( + final @NotNull byte[] envelopeData, final boolean maybeStartNewSession) { + final @NotNull IScopes scopes = ScopesAdapter.getInstance(); + final @NotNull SentryOptions options = scopes.getOptions(); + + try (final InputStream envelopeInputStream = new ByteArrayInputStream(envelopeData)) { + final @NotNull ISerializer serializer = options.getSerializer(); + final @Nullable SentryEnvelope envelope = + options.getEnvelopeReader().read(envelopeInputStream); + if (envelope == null) { + return null; + } + + final @NotNull List envelopeItems = new ArrayList<>(); + + // determine session state based on events inside envelope + @Nullable Session.State status = null; + boolean crashedOrErrored = false; + for (SentryEnvelopeItem item : envelope.getItems()) { + envelopeItems.add(item); + + final SentryEvent event = item.getEvent(serializer); + if (event != null) { + if (event.isCrashed()) { + status = Session.State.Crashed; + } + if (event.isCrashed() || event.isErrored()) { + crashedOrErrored = true; + } + } + } + + // update session and add it to envelope if necessary + final @Nullable Session session = updateSession(scopes, options, status, crashedOrErrored); + if (session != null) { + final SentryEnvelopeItem sessionItem = SentryEnvelopeItem.fromSession(serializer, session); + envelopeItems.add(sessionItem); + deleteCurrentSessionFile( + options, + // should be sync if going to crash or already not a main thread + !maybeStartNewSession || !scopes.getOptions().getThreadChecker().isMainThread()); + if (maybeStartNewSession) { + scopes.startSession(); + } + } + + final SentryEnvelope repackagedEnvelope = + new SentryEnvelope(envelope.getHeader(), envelopeItems); + return scopes.captureEnvelope(repackagedEnvelope); + } catch (Throwable t) { + options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", t); + } + return null; + } + + public static Map getAppStartMeasurement() { + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + final @NotNull List> spans = new ArrayList<>(); + + addTimeSpanToSerializedSpans(metrics.createProcessInitSpan(), spans); + addTimeSpanToSerializedSpans(metrics.getApplicationOnCreateTimeSpan(), spans); + + for (final TimeSpan span : metrics.getContentProviderOnCreateTimeSpans()) { + addTimeSpanToSerializedSpans(span, spans); + } + + for (final ActivityLifecycleTimeSpan span : metrics.getActivityLifecycleTimeSpans()) { + addTimeSpanToSerializedSpans(span.getOnCreate(), spans); + addTimeSpanToSerializedSpans(span.getOnStart(), spans); + } + + final @NotNull Map result = new HashMap<>(); + result.put("spans", spans); + result.put("type", metrics.getAppStartType().toString().toLowerCase(Locale.ROOT)); + if (metrics.getAppStartTimeSpan().hasStarted()) { + result.put("app_start_timestamp_ms", metrics.getAppStartTimeSpan().getStartTimestampMs()); + } + + return result; + } + + private static void addTimeSpanToSerializedSpans(TimeSpan span, List> spans) { + if (span.hasNotStarted()) { + ScopesAdapter.getInstance() + .getOptions() + .getLogger() + .log(WARNING, "Can not convert not-started TimeSpan to Map for Hybrid SDKs."); + return; + } + + if (span.hasNotStopped()) { + ScopesAdapter.getInstance() + .getOptions() + .getLogger() + .log(WARNING, "Can not convert not-stopped TimeSpan to Map for Hybrid SDKs."); + return; + } + + final @NotNull Map spanMap = new HashMap<>(); + spanMap.put("description", span.getDescription()); + spanMap.put("start_timestamp_ms", span.getStartTimestampMs()); + spanMap.put("end_timestamp_ms", span.getProjectedStopTimestampMs()); + spans.add(spanMap); + } + + private static void deleteCurrentSessionFile( + final @NotNull SentryOptions options, boolean isSync) { + if (!isSync) { + try { + options + .getExecutorService() + .submit( + () -> { + deleteCurrentSessionFile(options); + }); + } catch (Throwable e) { + options + .getLogger() + .log(WARNING, "Submission of deletion of the current session file rejected.", e); + } + } else { + deleteCurrentSessionFile(options); + } + } + + private static void deleteCurrentSessionFile(final @NotNull SentryOptions options) { + final String cacheDirPath = options.getCacheDirPath(); + if (cacheDirPath == null) { + options.getLogger().log(INFO, "Cache dir is not set, not deleting the current session."); + return; + } + + final File sessionFile = EnvelopeCache.getCurrentSessionFile(cacheDirPath); + if (!sessionFile.delete()) { + options.getLogger().log(WARNING, "Failed to delete the current session file."); + } + } + + @Nullable + private static Session updateSession( + final @NotNull IScopes scopes, + final @NotNull SentryOptions options, + final @Nullable Session.State status, + final boolean crashedOrErrored) { + final @NotNull AtomicReference sessionRef = new AtomicReference<>(); + scopes.configureScope( + scope -> { + final @Nullable Session session = scope.getSession(); + if (session != null) { + final boolean updated = session.update(status, null, crashedOrErrored, null); + // if we have an uncaughtExceptionHint we can end the session. + if (updated) { + if (session.getStatus() == Session.State.Crashed) { + session.end(); + // Session needs to be removed from the scope, otherwise it will be send twice + // standalone and with the crash event + scope.clearSession(); + } + sessionRef.set(session); + } + } else { + options.getLogger().log(INFO, "Session is null on updateSession"); + } + }); + return sessionRef.get(); + } + + /** + * Allows a Hybrid SDK to set the trace on the native layer + * + * @param traceId the trace ID + * @param spanId the trace origin's span ID + * @param sampleRate the sample rate used by the origin of the trace + * @param sampleRand the random value used to sample with by the origin of the trace + */ + public static void setTrace( + final @NotNull String traceId, + final @NotNull String spanId, + final @Nullable Double sampleRate, + final @Nullable Double sampleRand) { + TracingUtils.setTrace( + getCurrentScopes(), + PropagationContext.fromExistingTrace(traceId, spanId, sampleRate, sampleRand)); + } +} 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 7b38bcd9c2f..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 @@ -1,43 +1,40 @@ package io.sentry.android.core; -import androidx.lifecycle.DefaultLifecycleObserver; -import androidx.lifecycle.LifecycleOwner; import io.sentry.Breadcrumb; -import io.sentry.IHub; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.Session; -import io.sentry.android.core.internal.util.BreadcrumbFactory; import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.ICurrentDateProvider; -import java.util.Timer; -import java.util.TimerTask; +import io.sentry.util.AutoClosableReentrantLock; +import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; -final class LifecycleWatcher implements DefaultLifecycleObserver { +final class LifecycleWatcher implements AppState.AppStateListener { private final AtomicLong lastUpdatedSession = new AtomicLong(0L); private final long sessionIntervalMillis; - private @Nullable TimerTask timerTask; - private final @Nullable Timer timer; - private final @NotNull Object timerLock = new Object(); - private final @NotNull IHub hub; + private @Nullable Future endSessionFuture; + private final @NotNull AutoClosableReentrantLock endSessionLock = new AutoClosableReentrantLock(); + private final @NotNull IScopes scopes; private final boolean enableSessionTracking; private final boolean enableAppLifecycleBreadcrumbs; private final @NotNull ICurrentDateProvider currentDateProvider; LifecycleWatcher( - final @NotNull IHub hub, + final @NotNull IScopes scopes, final long sessionIntervalMillis, final boolean enableSessionTracking, final boolean enableAppLifecycleBreadcrumbs) { this( - hub, + scopes, sessionIntervalMillis, enableSessionTracking, enableAppLifecycleBreadcrumbs, @@ -45,7 +42,7 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { } LifecycleWatcher( - final @NotNull IHub hub, + final @NotNull IScopes scopes, final long sessionIntervalMillis, final boolean enableSessionTracking, final boolean enableAppLifecycleBreadcrumbs, @@ -53,90 +50,91 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { this.sessionIntervalMillis = sessionIntervalMillis; this.enableSessionTracking = enableSessionTracking; this.enableAppLifecycleBreadcrumbs = enableAppLifecycleBreadcrumbs; - this.hub = hub; + this.scopes = scopes; this.currentDateProvider = currentDateProvider; - if (enableSessionTracking) { - timer = new Timer(true); - } else { - timer = null; - } } - // App goes to foreground @Override - public void onStart(final @NotNull LifecycleOwner owner) { + public void onForeground() { startSession(); addAppBreadcrumb("foreground"); - - // Consider using owner.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED); - // in the future. - AppState.getInstance().setInBackground(false); } private void startSession() { - if (enableSessionTracking) { - cancelTask(); + cancelTask(); - final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); + final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); - hub.configureScope( - scope -> { - if (lastUpdatedSession.get() == 0L) { - final @Nullable Session currentSession = scope.getSession(); - if (currentSession != null && currentSession.getStarted() != null) { - lastUpdatedSession.set(currentSession.getStarted().getTime()); - } + scopes.configureScope( + scope -> { + if (lastUpdatedSession.get() == 0L) { + final @Nullable Session currentSession = scope.getSession(); + if (currentSession != null && currentSession.getStarted() != null) { + lastUpdatedSession.set(currentSession.getStarted().getTime()); } - }); - - final long lastUpdatedSession = this.lastUpdatedSession.get(); - if (lastUpdatedSession == 0L - || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { - addSessionBreadcrumb("start"); - hub.startSession(); + } + }); + + final long lastUpdatedSession = this.lastUpdatedSession.get(); + if (lastUpdatedSession == 0L + || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { + if (enableSessionTracking) { + scopes.startSession(); } - this.lastUpdatedSession.set(currentTimeMillis); + scopes.getOptions().getReplayController().start(); } + scopes.getOptions().getReplayController().resume(); + this.lastUpdatedSession.set(currentTimeMillis); } // App went to background and triggered this callback after 700ms // as no new screen was shown @Override - public void onStop(final @NotNull LifecycleOwner owner) { - if (enableSessionTracking) { - final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); - this.lastUpdatedSession.set(currentTimeMillis); + public void onBackground() { + final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); + this.lastUpdatedSession.set(currentTimeMillis); - scheduleEndSession(); - } + scopes.getOptions().getReplayController().pause(); + scheduleEndSession(); - AppState.getInstance().setInBackground(true); addAppBreadcrumb("background"); } private void scheduleEndSession() { - synchronized (timerLock) { + try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) { cancelTask(); - if (timer != null) { - timerTask = - new TimerTask() { - @Override - public void run() { - addSessionBreadcrumb("end"); - hub.endSession(); - } - }; - - timer.schedule(timerTask, sessionIntervalMillis); + final @NotNull Runnable endSession = + () -> { + if (enableSessionTracking) { + scopes.endSession(); + } + scopes.getOptions().getReplayController().stop(); + scopes.getOptions().getContinuousProfiler().close(false); + }; + + 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() { - synchronized (timerLock) { - if (timerTask != null) { - timerTask.cancel(); - timerTask = null; + try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) { + if (endSessionFuture != null) { + endSessionFuture.cancel(false); + endSessionFuture = null; } } } @@ -148,24 +146,13 @@ private void addAppBreadcrumb(final @NotNull String state) { breadcrumb.setData("state", state); breadcrumb.setCategory("app.lifecycle"); breadcrumb.setLevel(SentryLevel.INFO); - hub.addBreadcrumb(breadcrumb); + scopes.addBreadcrumb(breadcrumb); } } - private void addSessionBreadcrumb(final @NotNull String state) { - final Breadcrumb breadcrumb = BreadcrumbFactory.forSession(state); - hub.addBreadcrumb(breadcrumb); - } - - @TestOnly - @Nullable - TimerTask getTimerTask() { - return timerTask; - } - @TestOnly @Nullable - Timer getTimer() { - return timer; + Future getEndSessionFuture() { + return endSessionFuture; } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LoadClass.java b/sentry-android-core/src/main/java/io/sentry/android/core/LoadClass.java index 6401945cab2..34b8d1d5f19 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LoadClass.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LoadClass.java @@ -1,13 +1,23 @@ package io.sentry.android.core; import io.sentry.ILogger; -import io.sentry.SentryLevel; import io.sentry.SentryOptions; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -/** An Adapter for making Class.forName testable */ -public final class LoadClass { +/** + * An Adapter for making Class.forName testable + * + * @deprecated please use {@link io.sentry.util.LoadClass} instead. + */ +@Deprecated +public final class LoadClass extends io.sentry.util.LoadClass { + + private final io.sentry.util.LoadClass delegate; + + public LoadClass() { + delegate = new io.sentry.util.LoadClass(); + } /** * Try to load a class via reflection @@ -17,30 +27,15 @@ public final class LoadClass { * @return a Class if it's available, or null */ public @Nullable Class loadClass(final @NotNull String clazz, final @Nullable ILogger logger) { - try { - return Class.forName(clazz); - } catch (ClassNotFoundException e) { - if (logger != null) { - logger.log(SentryLevel.DEBUG, "Class not available:" + clazz, e); - } - } catch (UnsatisfiedLinkError e) { - if (logger != null) { - logger.log(SentryLevel.ERROR, "Failed to load (UnsatisfiedLinkError) " + clazz, e); - } - } catch (Throwable e) { - if (logger != null) { - logger.log(SentryLevel.ERROR, "Failed to initialize " + clazz, e); - } - } - return null; + return delegate.loadClass(clazz, logger); } public boolean isClassAvailable(final @NotNull String clazz, final @Nullable ILogger logger) { - return loadClass(clazz, logger) != null; + return delegate.isClassAvailable(clazz, logger); } public boolean isClassAvailable( final @NotNull String clazz, final @Nullable SentryOptions options) { - return isClassAvailable(clazz, options != null ? options.getLogger() : null); + return delegate.isClassAvailable(clazz, options); } } 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 3758123f61b..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 @@ -2,13 +2,18 @@ import android.content.Context; import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; import android.os.Bundle; 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; @@ -26,20 +31,29 @@ final class ManifestMetadataReader { static final String SAMPLE_RATE = "io.sentry.sample-rate"; static final String ANR_ENABLE = "io.sentry.anr.enable"; 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"; - // TODO: remove on 6.x in favor of SESSION_AUTO_TRACKING_ENABLE - static final String SESSION_TRACKING_ENABLE = "io.sentry.session-tracking.enable"; - static final String AUTO_SESSION_TRACKING_ENABLE = "io.sentry.auto-session-tracking.enable"; static final String SESSION_TRACKING_TIMEOUT_INTERVAL_MILLIS = "io.sentry.session-tracking.timeout-interval-millis"; @@ -56,7 +70,6 @@ final class ManifestMetadataReader { static final String UNCAUGHT_EXCEPTION_HANDLER_ENABLE = "io.sentry.uncaught-exception-handler.enable"; - static final String TRACING_ENABLE = "io.sentry.traces.enable"; static final String TRACES_SAMPLE_RATE = "io.sentry.traces.sample-rate"; static final String TRACES_ACTIVITY_ENABLE = "io.sentry.traces.activity.enable"; static final String TRACES_ACTIVITY_AUTO_FINISH_ENABLE = @@ -65,14 +78,16 @@ final class ManifestMetadataReader { static final String TTFD_ENABLE = "io.sentry.traces.time-to-full-display.enable"; - static final String TRACES_PROFILING_ENABLE = "io.sentry.traces.profiling.enable"; static final String PROFILES_SAMPLE_RATE = "io.sentry.traces.profiling.sample-rate"; - @ApiStatus.Experimental static final String TRACE_SAMPLING = "io.sentry.traces.trace-sampling"; + static final String PROFILE_SESSION_SAMPLE_RATE = + "io.sentry.traces.profiling.session-sample-rate"; - // TODO: remove in favor of TRACE_PROPAGATION_TARGETS - @Deprecated static final String TRACING_ORIGINS = "io.sentry.traces.tracing-origins"; + static final String PROFILE_LIFECYCLE = "io.sentry.traces.profiling.lifecycle"; + static final String PROFILER_START_ON_APP_START = "io.sentry.traces.profiling.start-on-app-start"; + + @ApiStatus.Experimental static final String TRACE_SAMPLING = "io.sentry.traces.trace-sampling"; static final String TRACE_PROPAGATION_TARGETS = "io.sentry.traces.trace-propagation-targets"; static final String ATTACH_THREADS = "io.sentry.attach-threads"; @@ -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"; @@ -92,6 +108,97 @@ final class ManifestMetadataReader { static final String ENABLE_ROOT_CHECK = "io.sentry.enable-root-check"; + static final String ENABLE_SENTRY = "io.sentry.enabled"; + + static final String SEND_MODULES = "io.sentry.send-modules"; + + 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"; + + static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.session-replay.on-error-sample-rate"; + + static final String REPLAYS_MASK_ALL_TEXT = "io.sentry.session-replay.mask-all-text"; + + 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"; + + static final String MAX_BREADCRUMBS = "io.sentry.max-breadcrumbs"; + + static final String IGNORED_ERRORS = "io.sentry.ignored-errors"; + + static final String IN_APP_INCLUDES = "io.sentry.in-app-includes"; + + static final String IN_APP_EXCLUDES = "io.sentry.in-app-excludes"; + + 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"; + + static final String DEADLINE_TIMEOUT = "io.sentry.traces.deadline-timeout"; + + static final String FEEDBACK_NAME_REQUIRED = "io.sentry.feedback.is-name-required"; + + static final String FEEDBACK_SHOW_NAME = "io.sentry.feedback.show-name"; + + static final String FEEDBACK_EMAIL_REQUIRED = "io.sentry.feedback.is-email-required"; + + static final String FEEDBACK_SHOW_EMAIL = "io.sentry.feedback.show-email"; + + static final String FEEDBACK_USE_SENTRY_USER = "io.sentry.feedback.use-sentry-user"; + + 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() {} @@ -117,7 +224,7 @@ static void applyMetadata( options.setDebug(readBool(metadata, logger, DEBUG, options.isDebug())); if (options.isDebug()) { - final String level = + final @Nullable String level = readString( metadata, logger, @@ -129,18 +236,27 @@ static void applyMetadata( } options.setAnrEnabled(readBool(metadata, logger, ANR_ENABLE, options.isAnrEnabled())); - - // deprecated - final boolean enableSessionTracking = + options.setTombstoneEnabled( + readBool(metadata, logger, TOMBSTONE_ENABLE, options.isTombstoneEnabled())); + options.setAttachRawTombstone( + readBool(metadata, logger, TOMBSTONE_ATTACH_RAW, options.isAttachRawTombstone())); + options.setReportHistoricalTombstones( readBool( - metadata, logger, SESSION_TRACKING_ENABLE, options.isEnableAutoSessionTracking()); + metadata, + logger, + TOMBSTONE_REPORT_HISTORICAL, + options.isReportHistoricalTombstones())); // use enableAutoSessionTracking as fallback options.setEnableAutoSessionTracking( - readBool(metadata, logger, AUTO_SESSION_TRACKING_ENABLE, enableSessionTracking)); + readBool( + metadata, + logger, + AUTO_SESSION_TRACKING_ENABLE, + options.isEnableAutoSessionTracking())); if (options.getSampleRate() == null) { - final Double sampleRate = readDouble(metadata, logger, SAMPLE_RATE); + final double sampleRate = readDouble(metadata, logger, SAMPLE_RATE); if (sampleRate != -1) { options.setSampleRate(sampleRate); } @@ -156,14 +272,42 @@ static void applyMetadata( ANR_TIMEOUT_INTERVAL_MILLIS, options.getAnrTimeoutIntervalMillis())); - final String dsn = readString(metadata, logger, DSN, options.getDsn()); - if (dsn == null) { + 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()); + + if (!enabled || (dsn != null && dsn.isEmpty())) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Sentry enabled flag set to false or DSN is empty: disabling sentry-android"); + } else if (dsn == null) { options .getLogger() .log(SentryLevel.FATAL, "DSN is required. Use empty string to disable SDK."); - } else if (dsn.isEmpty()) { - options.getLogger().log(SentryLevel.DEBUG, "DSN is empty, disabling sentry-android"); } + + options.setEnabled(enabled); options.setDsn(dsn); options.setEnableNdk(readBool(metadata, logger, NDK_ENABLE, options.isEnableNdk())); @@ -171,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( @@ -182,6 +334,9 @@ static void applyMetadata( SESSION_TRACKING_TIMEOUT_INTERVAL_MILLIS, options.getSessionTrackingIntervalMillis())); + options.setMaxBreadcrumbs( + (int) readLong(metadata, logger, MAX_BREADCRUMBS, options.getMaxBreadcrumbs())); + options.setEnableActivityLifecycleBreadcrumbs( readBool( metadata, @@ -243,6 +398,13 @@ static void applyMetadata( options.setSendClientReports( readBool(metadata, logger, CLIENT_REPORTS_ENABLE, options.isSendClientReports())); + final boolean isAutoInitEnabled = readBool(metadata, logger, AUTO_INIT, true); + if (isAutoInitEnabled) { + options.setInitPriority(InitPriority.LOW); + } + + options.setForceInit(readBool(metadata, logger, FORCE_INIT, options.isForceInit())); + options.setCollectAdditionalContext( readBool( metadata, @@ -250,12 +412,15 @@ static void applyMetadata( COLLECT_ADDITIONAL_CONTEXT, options.isCollectAdditionalContext())); - if (options.getEnableTracing() == null) { - options.setEnableTracing(readBoolNullable(metadata, logger, TRACING_ENABLE, null)); - } + options.setCollectExternalStorageContext( + readBool( + metadata, + logger, + COLLECT_EXTERNAL_STORAGE_CONTEXT, + options.isCollectExternalStorageContext())); if (options.getTracesSampleRate() == null) { - final Double tracesSampleRate = readDouble(metadata, logger, TRACES_SAMPLE_RATE); + final double tracesSampleRate = readDouble(metadata, logger, TRACES_SAMPLE_RATE); if (tracesSampleRate != -1) { options.setTracesSampleRate(tracesSampleRate); } @@ -278,16 +443,39 @@ static void applyMetadata( TRACES_ACTIVITY_AUTO_FINISH_ENABLE, options.isEnableActivityLifecycleTracingAutoFinish())); - options.setProfilingEnabled( - readBool(metadata, logger, TRACES_PROFILING_ENABLE, options.isProfilingEnabled())); - if (options.getProfilesSampleRate() == null) { - final Double profilesSampleRate = readDouble(metadata, logger, PROFILES_SAMPLE_RATE); + final double profilesSampleRate = readDouble(metadata, logger, PROFILES_SAMPLE_RATE); if (profilesSampleRate != -1) { options.setProfilesSampleRate(profilesSampleRate); } } + if (options.getProfileSessionSampleRate() == null) { + final double profileSessionSampleRate = + readDouble(metadata, logger, PROFILE_SESSION_SAMPLE_RATE); + if (profileSessionSampleRate != -1) { + options.setProfileSessionSampleRate(profileSessionSampleRate); + } + } + + final @Nullable String profileLifecycle = + readString( + metadata, + logger, + PROFILE_LIFECYCLE, + options.getProfileLifecycle().name().toLowerCase(Locale.ROOT)); + if (profileLifecycle != null) { + options.setProfileLifecycle( + ProfileLifecycle.valueOf(profileLifecycle.toUpperCase(Locale.ROOT))); + } + + options.setStartProfilerOnAppStart( + readBool( + metadata, + logger, + PROFILER_START_ON_APP_START, + options.isStartProfilerOnAppStart())); + options.setEnableUserInteractionTracing( readBool(metadata, logger, TRACES_UI_ENABLE, options.isEnableUserInteractionTracing())); @@ -303,15 +491,7 @@ static void applyMetadata( List tracePropagationTargets = readList(metadata, logger, TRACE_PROPAGATION_TARGETS); - // TODO remove once TRACING_ORIGINS have been removed - if (!metadata.containsKey(TRACE_PROPAGATION_TARGETS) - && (tracePropagationTargets == null || tracePropagationTargets.isEmpty())) { - tracePropagationTargets = readList(metadata, logger, TRACING_ORIGINS); - } - - if ((metadata.containsKey(TRACE_PROPAGATION_TARGETS) - || metadata.containsKey(TRACING_ORIGINS)) - && tracePropagationTargets == null) { + if (metadata.containsKey(TRACE_PROPAGATION_TARGETS) && tracePropagationTargets == null) { options.setTracePropagationTargets(Collections.emptyList()); } else if (tracePropagationTargets != null) { options.setTracePropagationTargets(tracePropagationTargets); @@ -336,6 +516,7 @@ static void applyMetadata( // sdkInfo.addIntegration(); + @Nullable List integrationsFromGradlePlugin = readList(metadata, logger, SENTRY_GRADLE_PLUGIN_INTEGRATIONS); if (integrationsFromGradlePlugin != null) { @@ -346,8 +527,246 @@ static void applyMetadata( options.setEnableRootCheck( readBool(metadata, logger, ENABLE_ROOT_CHECK, options.isEnableRootCheck())); - } + options.setSendModules(readBool(metadata, logger, SEND_MODULES, options.isSendModules())); + + 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())); + + options.setEnableAutoTraceIdGeneration( + readBool( + metadata, + logger, + ENABLE_AUTO_TRACE_ID_GENERATION, + options.isEnableAutoTraceIdGeneration())); + + options.setDeadlineTimeout( + readLong(metadata, logger, DEADLINE_TIMEOUT, options.getDeadlineTimeout())); + + if (options.getSessionReplay().getSessionSampleRate() == null) { + final double sessionSampleRate = + readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); + if (sessionSampleRate != -1) { + options.getSessionReplay().setSessionSampleRate(sessionSampleRate); + } + } + + if (options.getSessionReplay().getOnErrorSampleRate() == null) { + final double onErrorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); + if (onErrorSampleRate != -1) { + options.getSessionReplay().setOnErrorSampleRate(onErrorSampleRate); + } + } + + options + .getSessionReplay() + .setMaskAllText(readBool(metadata, logger, REPLAYS_MASK_ALL_TEXT, true)); + + options + .getSessionReplay() + .setMaskAllImages(readBool(metadata, logger, REPLAYS_MASK_ALL_IMAGES, true)); + + 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); + if (includes != null && !includes.isEmpty()) { + for (final @NotNull String include : includes) { + options.addInAppInclude(include); + } + } + + final @Nullable List excludes = readList(metadata, logger, IN_APP_EXCLUDES); + if (excludes != null && !excludes.isEmpty()) { + for (final @NotNull String exclude : excludes) { + options.addInAppExclude(exclude); + } + } + + options + .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())); + feedbackOptions.setShowName( + readBool(metadata, logger, FEEDBACK_SHOW_NAME, feedbackOptions.isShowName())); + feedbackOptions.setEmailRequired( + readBool(metadata, logger, FEEDBACK_EMAIL_REQUIRED, feedbackOptions.isEmailRequired())); + feedbackOptions.setShowEmail( + readBool(metadata, logger, FEEDBACK_SHOW_EMAIL, feedbackOptions.isShowEmail())); + feedbackOptions.setUseSentryUser( + readBool( + 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() .log(SentryLevel.INFO, "Retrieving configuration from AndroidManifest.xml"); @@ -365,25 +784,10 @@ private static boolean readBool( final @NotNull String key, final boolean defaultValue) { final boolean value = metadata.getBoolean(key, defaultValue); - logger.log(SentryLevel.DEBUG, "%s read: %s", key, value); - return value; - } - - @SuppressWarnings("deprecation") - private static @Nullable Boolean readBoolNullable( - final @NotNull Bundle metadata, - final @NotNull ILogger logger, - final @NotNull String key, - final @Nullable Boolean defaultValue) { - if (metadata.getSerializable(key) != null) { - final boolean nonNullDefault = defaultValue == null ? false : true; - final boolean bool = metadata.getBoolean(key, nonNullDefault); - logger.log(SentryLevel.DEBUG, "%s read: %s", key, bool); - return bool; - } else { - logger.log(SentryLevel.DEBUG, "%s used default %s", key, defaultValue); - return defaultValue; + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); } + return value; } private static @Nullable String readString( @@ -392,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, "%s read: %s", key, value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } @@ -402,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, "%s read: %s", key, 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, "%s read: %s", key, value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } if (value != null) { return Arrays.asList(value.split(",", -1)); } else { @@ -417,11 +827,16 @@ private static boolean readBool( } } - private static @NotNull Double readDouble( + private static double readDouble( final @NotNull Bundle metadata, final @NotNull ILogger logger, final @NotNull String key) { // manifest meta-data only reads float - final Double value = ((Float) metadata.getFloat(key, -1)).doubleValue(); - logger.log(SentryLevel.DEBUG, "%s read: %s", key, value); + double value = ((Float) metadata.getFloat(key, -1)).doubleValue(); + if (value == -1) { + value = ((Integer) metadata.getInt(key, -1)).doubleValue(); + } + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } @@ -432,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, "%s read: %s", key, value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } @@ -452,7 +869,6 @@ static boolean isAutoInit(final @NotNull Context context, final @NotNull ILogger if (metadata != null) { autoInit = readBool(metadata, logger, AUTO_INIT, true); } - logger.log(SentryLevel.INFO, "Retrieving auto-init from AndroidManifest.xml"); } catch (Throwable e) { logger.log(SentryLevel.ERROR, "Failed to read auto-init from android manifest metadata.", e); } @@ -464,18 +880,14 @@ static boolean isAutoInit(final @NotNull Context context, final @NotNull ILogger * * @param context the application context * @return the Bundle attached to the PackageManager - * @throws PackageManager.NameNotFoundException if the package name is non-existent */ private static @Nullable Bundle getMetadata( final @NotNull Context context, final @NotNull ILogger logger, - final @Nullable BuildInfoProvider buildInfoProvider) - throws PackageManager.NameNotFoundException { + final @Nullable BuildInfoProvider buildInfoProvider) { final ApplicationInfo app = ContextUtils.getApplicationInfo( - context, - PackageManager.GET_META_DATA, - buildInfoProvider != null ? buildInfoProvider : new BuildInfoProvider(logger)); - return app.metaData; + context, buildInfoProvider != null ? buildInfoProvider : new BuildInfoProvider(logger)); + return app != null ? app.metaData : null; } } 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/NdkHandlerStrategy.java b/sentry-android-core/src/main/java/io/sentry/android/core/NdkHandlerStrategy.java new file mode 100644 index 00000000000..74b446658d3 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NdkHandlerStrategy.java @@ -0,0 +1,19 @@ +package io.sentry.android.core; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public enum NdkHandlerStrategy { + SENTRY_HANDLER_STRATEGY_DEFAULT(0), + SENTRY_HANDLER_STRATEGY_CHAIN_AT_START(1); + + private final int value; + + NdkHandlerStrategy(final int value) { + this.value = value; + } + + public int getValue() { + return value; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java index 4757c96879f..8353a32d65d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java @@ -1,6 +1,8 @@ package io.sentry.android.core; -import io.sentry.IHub; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import io.sentry.IScopes; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; @@ -26,8 +28,8 @@ public NdkIntegration(final @Nullable Class sentryNdkClass) { } @Override - public final void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); + public final void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + Objects.requireNonNull(scopes, "Scopes are required"); this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, @@ -36,7 +38,8 @@ public final void register(final @NotNull IHub hub, final @NotNull SentryOptions final boolean enabled = this.options.isEnableNdk(); this.options.getLogger().log(SentryLevel.DEBUG, "NdkIntegration enabled: %s", enabled); - // Note: `hub` isn't used here because the NDK integration writes files to disk which are picked + // Note: `scopes` isn't used here because the NDK integration writes files to disk which are + // picked // up by another integration (EnvelopeFileObserverIntegration). if (enabled && sentryNdkClass != null) { final String cachedDir = this.options.getCacheDirPath(); @@ -53,7 +56,7 @@ public final void register(final @NotNull IHub hub, final @NotNull SentryOptions method.invoke(null, args); this.options.getLogger().log(SentryLevel.DEBUG, "NdkIntegration installed."); - addIntegrationToSdkVersion(); + addIntegrationToSdkVersion("Ndk"); } catch (NoSuchMethodException e) { disableNdkIntegration(this.options); this.options @@ -68,7 +71,7 @@ public final void register(final @NotNull IHub hub, final @NotNull SentryOptions } } - private void disableNdkIntegration(final @NotNull SentryOptions options) { + private void disableNdkIntegration(final @NotNull SentryAndroidOptions options) { options.setEnableNdk(false); options.setEnableScopeSync(false); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java index 317cb604677..c7f3182b97d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java @@ -1,5 +1,7 @@ package io.sentry.android.core; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + import android.annotation.SuppressLint; import android.content.Context; import android.net.ConnectivityManager; @@ -7,16 +9,18 @@ import android.net.NetworkCapabilities; import android.os.Build; import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; import io.sentry.Breadcrumb; +import io.sentry.DateUtils; import io.sentry.Hint; -import io.sentry.IHub; -import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; +import io.sentry.SentryDateProvider; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.TypeCheckHint; -import io.sentry.android.core.internal.util.ConnectivityChecker; +import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; @@ -27,112 +31,116 @@ public final class NetworkBreadcrumbsIntegration implements Integration, Closeable { private final @NotNull Context context; - private final @NotNull BuildInfoProvider buildInfoProvider; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private @Nullable SentryOptions options; - private final @NotNull ILogger logger; - - @TestOnly @Nullable NetworkBreadcrumbsNetworkCallback networkCallback; + @TestOnly @Nullable volatile NetworkBreadcrumbsNetworkCallback networkCallback; public NetworkBreadcrumbsIntegration( - final @NotNull Context context, - final @NotNull BuildInfoProvider buildInfoProvider, - final @NotNull ILogger logger) { - this.context = Objects.requireNonNull(context, "Context is required"); + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); - this.logger = Objects.requireNonNull(logger, "ILogger is required"); } - @SuppressLint("NewApi") @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + Objects.requireNonNull(scopes, "Scopes are required"); SentryAndroidOptions androidOptions = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); - logger.log( - SentryLevel.DEBUG, - "NetworkBreadcrumbsIntegration enabled: %s", - androidOptions.isEnableNetworkEventBreadcrumbs()); + this.options = options; + + options + .getLogger() + .log( + SentryLevel.DEBUG, + "NetworkBreadcrumbsIntegration enabled: %s", + androidOptions.isEnableNetworkEventBreadcrumbs()); if (androidOptions.isEnableNetworkEventBreadcrumbs()) { // The specific error is logged in the ConnectivityChecker method - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) { - networkCallback = null; - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration requires Android 5+"); + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { + options.getLogger().log(SentryLevel.DEBUG, "NetworkCallbacks need Android N+."); return; } - networkCallback = new NetworkBreadcrumbsNetworkCallback(hub, buildInfoProvider); - final boolean registered = - ConnectivityChecker.registerNetworkCallback( - context, logger, buildInfoProvider, networkCallback); - - // The specific error is logged in the ConnectivityChecker method - if (!registered) { - networkCallback = null; - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration not installed."); - return; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + networkCallback = + new NetworkBreadcrumbsNetworkCallback( + scopes, buildInfoProvider, options.getDateProvider()); + + final boolean registered = + AndroidConnectionStatusProvider.addNetworkCallback( + context, options.getLogger(), buildInfoProvider, networkCallback); + if (registered) { + options.getLogger().log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration installed."); + addIntegrationToSdkVersion("NetworkBreadcrumbs"); + } else { + options + .getLogger() + .log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration not installed."); + // The specific error is logged by AndroidConnectionStatusProvider + } } - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion(); } } @Override public void close() throws IOException { - if (networkCallback != null) { - ConnectivityChecker.unregisterNetworkCallback( - context, logger, buildInfoProvider, networkCallback); - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration remove."); + final @Nullable ConnectivityManager.NetworkCallback callbackRef; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + callbackRef = networkCallback; + networkCallback = null; + } + + if (callbackRef != null) { + AndroidConnectionStatusProvider.removeNetworkCallback(callbackRef); } - networkCallback = null; } - @SuppressLint("ObsoleteSdkInt") - @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) static final class NetworkBreadcrumbsNetworkCallback extends ConnectivityManager.NetworkCallback { - final @NotNull IHub hub; + final @NotNull IScopes scopes; final @NotNull BuildInfoProvider buildInfoProvider; - @Nullable Network currentNetwork = null; - @Nullable NetworkCapabilities lastCapabilities = null; + long lastCapabilityNanos = 0; + final @NotNull SentryDateProvider dateProvider; NetworkBreadcrumbsNetworkCallback( - final @NotNull IHub hub, final @NotNull BuildInfoProvider buildInfoProvider) { - this.hub = Objects.requireNonNull(hub, "Hub is required"); + final @NotNull IScopes scopes, + final @NotNull BuildInfoProvider buildInfoProvider, + final @NotNull SentryDateProvider dateProvider) { + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); + this.dateProvider = Objects.requireNonNull(dateProvider, "SentryDateProvider is required"); } @Override public void onAvailable(final @NonNull Network network) { - if (network.equals(currentNetwork)) { - return; - } final Breadcrumb breadcrumb = createBreadcrumb("NETWORK_AVAILABLE"); - hub.addBreadcrumb(breadcrumb); - currentNetwork = network; + scopes.addBreadcrumb(breadcrumb); lastCapabilities = null; } @Override public void onCapabilitiesChanged( final @NonNull Network network, final @NonNull NetworkCapabilities networkCapabilities) { - if (!network.equals(currentNetwork)) { - return; - } + final long nowNanos = dateProvider.now().nanoTimestamp(); final @Nullable NetworkBreadcrumbConnectionDetail connectionDetail = - getNewConnectionDetails(lastCapabilities, networkCapabilities); + getNewConnectionDetails( + lastCapabilities, networkCapabilities, lastCapabilityNanos, nowNanos); if (connectionDetail == null) { return; } lastCapabilities = networkCapabilities; + lastCapabilityNanos = nowNanos; final Breadcrumb breadcrumb = createBreadcrumb("NETWORK_CAPABILITIES_CHANGED"); breadcrumb.setData("download_bandwidth", connectionDetail.downBandwidth); breadcrumb.setData("upload_bandwidth", connectionDetail.upBandwidth); @@ -143,17 +151,13 @@ public void onCapabilitiesChanged( } Hint hint = new Hint(); hint.set(TypeCheckHint.ANDROID_NETWORK_CAPABILITIES, connectionDetail); - hub.addBreadcrumb(breadcrumb, hint); + scopes.addBreadcrumb(breadcrumb, hint); } @Override public void onLost(final @NonNull Network network) { - if (!network.equals(currentNetwork)) { - return; - } final Breadcrumb breadcrumb = createBreadcrumb("NETWORK_LOST"); - hub.addBreadcrumb(breadcrumb); - currentNetwork = null; + scopes.addBreadcrumb(breadcrumb); lastCapabilities = null; } @@ -168,18 +172,23 @@ private Breadcrumb createBreadcrumb(String action) { private @Nullable NetworkBreadcrumbConnectionDetail getNewConnectionDetails( final @Nullable NetworkCapabilities oldCapabilities, - final @NotNull NetworkCapabilities newCapabilities) { + final @NotNull NetworkCapabilities newCapabilities, + final long oldCapabilityNanos, + final long newCapabilityNanos) { if (oldCapabilities == null) { - return new NetworkBreadcrumbConnectionDetail(newCapabilities, buildInfoProvider); + return new NetworkBreadcrumbConnectionDetail( + newCapabilities, buildInfoProvider, newCapabilityNanos); } NetworkBreadcrumbConnectionDetail oldConnectionDetails = - new NetworkBreadcrumbConnectionDetail(oldCapabilities, buildInfoProvider); + new NetworkBreadcrumbConnectionDetail( + oldCapabilities, buildInfoProvider, oldCapabilityNanos); NetworkBreadcrumbConnectionDetail newConnectionDetails = - new NetworkBreadcrumbConnectionDetail(newCapabilities, buildInfoProvider); + new NetworkBreadcrumbConnectionDetail( + newCapabilities, buildInfoProvider, newCapabilityNanos); // We compare the details and if they are similar we return null, so that we don't spam the // user with lots of breadcrumbs for e.g. an increase of signal strength of 1 point - if (newConnectionDetails.isSimilar(oldConnectionDetails)) { + if (oldConnectionDetails.isSimilar(newConnectionDetails)) { return null; } return newConnectionDetails; @@ -188,14 +197,15 @@ private Breadcrumb createBreadcrumb(String action) { static class NetworkBreadcrumbConnectionDetail { final int downBandwidth, upBandwidth, signalStrength; + private long timestampNanos; final boolean isVpn; final @NotNull String type; - @SuppressLint({"NewApi", "ObsoleteSdkInt"}) - @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @SuppressLint({"NewApi"}) NetworkBreadcrumbConnectionDetail( final @NotNull NetworkCapabilities networkCapabilities, - final @NotNull BuildInfoProvider buildInfoProvider) { + final @NotNull BuildInfoProvider buildInfoProvider, + final long capabilityNanos) { Objects.requireNonNull(networkCapabilities, "NetworkCapabilities is required"); Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); this.downBandwidth = networkCapabilities.getLinkDownstreamBandwidthKbps(); @@ -208,8 +218,9 @@ static class NetworkBreadcrumbConnectionDetail { this.signalStrength = strength > -100 ? strength : 0; this.isVpn = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN); String connectionType = - ConnectivityChecker.getConnectionType(networkCapabilities, buildInfoProvider); + AndroidConnectionStatusProvider.getConnectionType(networkCapabilities); this.type = connectionType != null ? connectionType : ""; + this.timestampNanos = capabilityNanos; } /** @@ -219,13 +230,24 @@ static class NetworkBreadcrumbConnectionDetail { * @return true if the details are similar enough, false otherwise */ boolean isSimilar(final @NotNull NetworkBreadcrumbConnectionDetail other) { + int signalDiff = Math.abs(signalStrength - other.signalStrength); + int downBandwidthDiff = Math.abs(downBandwidth - other.downBandwidth); + int upBandwidthDiff = Math.abs(upBandwidth - other.upBandwidth); + // Signal and bandwidth will be reported at most once every 5 seconds. + // This means that if the new connection detail come less than 5 seconds after the previous + // one, we'll report the signal and bandwidth as similar, regardless of their real value. + boolean isTimestampSimilar = + DateUtils.nanosToMillis(Math.abs(timestampNanos - other.timestampNanos)) < 5000; + boolean isSignalSimilar = isTimestampSimilar || signalDiff <= 5; + boolean isDownBandwidthSimilar = + isTimestampSimilar || downBandwidthDiff <= Math.max(1000, Math.abs(downBandwidth) * 0.1); + boolean isUpBandwidthSimilar = + isTimestampSimilar || upBandwidthDiff <= Math.max(1000, Math.abs(upBandwidth) * 0.1); return isVpn == other.isVpn && type.equals(other.type) - && (-5 <= signalStrength - other.signalStrength - && signalStrength - other.signalStrength <= 5) - && (-1000 <= downBandwidth - other.downBandwidth - && downBandwidth - other.downBandwidth <= 1000) - && (-1000 <= upBandwidth - other.upBandwidth && upBandwidth - other.upBandwidth <= 1000); + && isSignalSimilar + && isDownBandwidthSimilar + && isUpBandwidthSimilar; } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java index 70451972a76..193b7342193 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java @@ -2,6 +2,7 @@ import io.sentry.protocol.DebugImage; import java.util.List; +import java.util.Set; import org.jetbrains.annotations.Nullable; final class NoOpDebugImagesLoader implements IDebugImagesLoader { @@ -19,6 +20,11 @@ public static NoOpDebugImagesLoader getInstance() { return null; } + @Override + public @Nullable Set loadDebugImagesForAddresses(Set addresses) { + return null; + } + @Override public void clearDebugImages() {} } 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 eae02976140..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,31 +1,55 @@ 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; +import io.sentry.SpanId; +import io.sentry.SpanStatus; +import io.sentry.android.core.internal.util.AndroidThreadChecker; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; +import io.sentry.protocol.App; import io.sentry.protocol.MeasurementValue; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentrySpan; import io.sentry.protocol.SentryTransaction; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; +import java.util.HashMap; 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; /** Event Processor responsible for adding Android metrics to transactions */ final class PerformanceAndroidEventProcessor implements EventProcessor { + private static final String APP_METRICS_ORIGIN = "auto.ui"; + + private static final String APP_METRICS_CONTENT_PROVIDER_OP = "contentprovider.load"; + private static final String APP_METRICS_ACTIVITIES_OP = "activity.load"; + private static final String APP_METRICS_APPLICATION_OP = "application.load"; + private static final String APP_METRICS_PROCESS_INIT_OP = "process.load"; + private static final long MAX_PROCESS_INIT_APP_START_DIFF_MS = 10000; + private boolean sentStartMeasurement = false; private final @NotNull ActivityFramesTracker activityFramesTracker; private final @NotNull SentryAndroidOptions options; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); PerformanceAndroidEventProcessor( final @NotNull SentryAndroidOptions options, @@ -53,59 +77,301 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { @SuppressWarnings("NullAway") @Override - public synchronized @NotNull SentryTransaction process( + public @NotNull SentryTransaction process( @NotNull SentryTransaction transaction, @NotNull Hint hint) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!options.isTracingEnabled()) { + return transaction; + } + + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + // 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)) { + // 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 = + 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 (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; + + transaction.getMeasurements().put(appStartKey, value); + } + + attachAppStartSpans(appStartMetrics, transaction); + appStartMetrics.onAppStartSpansSent(); + } + } + + @Nullable App appContext = transaction.getContexts().getApp(); + if (appContext == null) { + appContext = new App(); + transaction.getContexts().setApp(appContext); + } + final String appStartType = + appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD + ? "cold" + : "warm"; + appContext.setStartType(appStartType); + } + + setContributingFlags(transaction); + + final SentryId eventId = transaction.getEventId(); + final SpanContext spanContext = transaction.getContexts().getTrace(); + + // only add slow/frozen frames to transactions created by ActivityLifecycleIntegration + // which have the operation UI_LOAD_OP. If a user-defined (or hybrid SDK) transaction + // users it, we'll also add the metrics if available + if (eventId != null + && spanContext != null + && spanContext.getOperation().contentEquals(UI_LOAD_OP)) { + final Map framesMetrics = + activityFramesTracker.takeMetrics(eventId); + if (framesMetrics != null) { + transaction.getMeasurements().putAll(framesMetrics); + } + } - if (!options.isTracingEnabled()) { return transaction; } + } - // 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 (!sentStartMeasurement && hasAppStartSpan(transaction.getSpans())) { - final Long appStartUpInterval = AppStartState.getInstance().getAppStartInterval(); - // if appStartUpInterval is null, metrics are not ready to be sent - if (appStartUpInterval != null) { - final MeasurementValue value = - new MeasurementValue( - (float) appStartUpInterval, MeasurementUnit.Duration.MILLISECOND.apiName()); - - final String appStartKey = - AppStartState.getInstance().isColdStart() - ? MeasurementValue.KEY_APP_START_COLD - : MeasurementValue.KEY_APP_START_WARM; - - transaction.getMeasurements().put(appStartKey, value); - sentStartMeasurement = true; + private void setContributingFlags(SentryTransaction transaction) { + + @Nullable SentrySpan ttidSpan = null; + @Nullable SentrySpan ttfdSpan = null; + for (final @NotNull SentrySpan span : transaction.getSpans()) { + if (ActivityLifecycleIntegration.TTID_OP.equals(span.getOp())) { + ttidSpan = span; + } else if (ActivityLifecycleIntegration.TTFD_OP.equals(span.getOp())) { + ttfdSpan = span; + } + // once both are found we can early exit + if (ttidSpan != null && ttfdSpan != null) { + break; } } - final SentryId eventId = transaction.getEventId(); - final SpanContext spanContext = transaction.getContexts().getTrace(); - - // only add slow/frozen frames to transactions created by ActivityLifecycleIntegration - // which have the operation UI_LOAD_OP. If a user-defined (or hybrid SDK) transaction - // users it, we'll also add the metrics if available - if (eventId != null - && spanContext != null - && spanContext.getOperation().contentEquals(UI_LOAD_OP)) { - final Map framesMetrics = - activityFramesTracker.takeMetrics(eventId); - if (framesMetrics != null) { - transaction.getMeasurements().putAll(framesMetrics); + if (ttidSpan == null && ttfdSpan == null) { + return; + } + + for (final @NotNull SentrySpan span : transaction.getSpans()) { + // as ttid and ttfd spans are artificially created, we don't want to set the flags on them + if (span == ttidSpan || span == ttfdSpan) { + continue; + } + + // let's assume main thread, unless it's set differently + boolean spanOnMainThread = true; + final @Nullable Map spanData = span.getData(); + if (spanData != null) { + final @Nullable Object threadName = spanData.get(SpanDataConvention.THREAD_NAME); + spanOnMainThread = threadName == null || "main".equals(threadName); + } + + // for ttid, only main thread spans are relevant + final boolean withinTtid = + (ttidSpan != null) + && isTimestampWithinSpan(span.getStartTimestamp(), ttidSpan) + && spanOnMainThread; + + final boolean withinTtfd = + (ttfdSpan != null) && isTimestampWithinSpan(span.getStartTimestamp(), ttfdSpan); + + if (withinTtid || withinTtfd) { + @Nullable Map data = span.getData(); + if (data == null) { + data = new ConcurrentHashMap<>(); + span.setData(data); + } + if (withinTtid) { + data.put(SpanDataConvention.CONTRIBUTES_TTID, true); + } + if (withinTtfd) { + data.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + } } } + } - return transaction; + private static boolean isTimestampWithinSpan( + final double timestamp, final @NotNull SentrySpan target) { + return timestamp >= target.getStartTimestamp() + && (target.getTimestamp() == null || timestamp <= target.getTimestamp()); } - private boolean hasAppStartSpan(final @NotNull List spans) { - for (final SentrySpan span : spans) { + private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { + final @NotNull List spans = txn.getSpans(); + for (final @NotNull SentrySpan span : spans) { if (span.getOp().contentEquals(APP_START_COLD) || span.getOp().contentEquals(APP_START_WARM)) { return true; } } - return false; + + final @Nullable SpanContext context = txn.getContexts().getTrace(); + return context != null && context.getOperation().equals(STANDALONE_APP_START_OP); + } + + private void attachAppStartSpans( + final @NotNull AppStartMetrics appStartMetrics, final @NotNull SentryTransaction txn) { + + // We include process init, content providers and application.onCreate spans only on cold start + if (appStartMetrics.getAppStartType() != AppStartMetrics.AppStartType.COLD) { + return; + } + + final @Nullable SpanContext traceContext = txn.getContexts().getTrace(); + if (traceContext == null) { + return; + } + final @NotNull SentryId traceId = traceContext.getTraceId(); + + // determine the app.start.cold span, where all other spans will be attached to + @Nullable SpanId parentSpanId = null; + final @NotNull List spans = txn.getSpans(); + for (final @NotNull SentrySpan span : spans) { + if (span.getOp().contentEquals(APP_START_COLD)) { + parentSpanId = span.getSpanId(); + break; + } + } + + // 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() + && Math.abs(processInitTimeSpan.getDurationMs()) <= MAX_PROCESS_INIT_APP_START_DIFF_MS) { + txn.getSpans() + .add( + timeSpanToSentrySpan( + processInitTimeSpan, + parentSpanId, + traceId, + APP_METRICS_PROCESS_INIT_OP, + isStandalone)); + } + + // Content Providers + final @NotNull List contentProviderOnCreates = + appStartMetrics.getContentProviderOnCreateTimeSpans(); + if (!contentProviderOnCreates.isEmpty()) { + for (final @NotNull TimeSpan contentProvider : contentProviderOnCreates) { + txn.getSpans() + .add( + timeSpanToSentrySpan( + contentProvider, + parentSpanId, + traceId, + APP_METRICS_CONTENT_PROVIDER_OP, + isStandalone)); + } + } + + // Application.onCreate + final @NotNull TimeSpan appOnCreate = appStartMetrics.getApplicationOnCreateTimeSpan(); + if (appOnCreate.hasStopped()) { + txn.getSpans() + .add( + timeSpanToSentrySpan( + appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP, isStandalone)); + } + } + + @NotNull + private static SentrySpan timeSpanToSentrySpan( + final @NotNull TimeSpan span, + final @Nullable SpanId parentSpanId, + final @NotNull SentryId traceId, + 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"); + + if (!isStandaloneAppStart) { + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + } + + return new SentrySpan( + span.getStartTimestampSecs(), + span.getProjectedStopTimestampSecs(), + traceId, + new SpanId(), + parentSpanId, + operation, + span.getDescription(), + SpanStatus.OK, + APP_METRICS_ORIGIN, + new ConcurrentHashMap<>(), + new ConcurrentHashMap<>(), + defaultSpanData); + } + + @Override + public @Nullable Long getOrder() { + return 9000L; } } 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/PhoneStateBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java deleted file mode 100644 index be23c668c7a..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java +++ /dev/null @@ -1,106 +0,0 @@ -package io.sentry.android.core; - -import static android.Manifest.permission.READ_PHONE_STATE; - -import android.content.Context; -import android.telephony.TelephonyManager; -import io.sentry.Breadcrumb; -import io.sentry.IHub; -import io.sentry.Integration; -import io.sentry.SentryLevel; -import io.sentry.SentryOptions; -import io.sentry.android.core.internal.util.Permissions; -import io.sentry.util.Objects; -import java.io.Closeable; -import java.io.IOException; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; - -public final class PhoneStateBreadcrumbsIntegration implements Integration, Closeable { - - private final @NotNull Context context; - private @Nullable SentryAndroidOptions options; - @TestOnly @Nullable PhoneStateChangeListener listener; - private @Nullable TelephonyManager telephonyManager; - - public PhoneStateBreadcrumbsIntegration(final @NotNull Context context) { - this.context = Objects.requireNonNull(context, "Context is required"); - } - - @SuppressWarnings("deprecation") - @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); - this.options = - Objects.requireNonNull( - (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, - "SentryAndroidOptions is required"); - - this.options - .getLogger() - .log( - SentryLevel.DEBUG, - "enableSystemEventBreadcrumbs enabled: %s", - this.options.isEnableSystemEventBreadcrumbs()); - - if (this.options.isEnableSystemEventBreadcrumbs() - && Permissions.hasPermission(context, READ_PHONE_STATE)) { - telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); - if (telephonyManager != null) { - try { - listener = new PhoneStateChangeListener(hub); - telephonyManager.listen(listener, android.telephony.PhoneStateListener.LISTEN_CALL_STATE); - - options.getLogger().log(SentryLevel.DEBUG, "PhoneStateBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion(); - } catch (Throwable e) { - this.options - .getLogger() - .log(SentryLevel.INFO, e, "TelephonyManager is not available or ready to use."); - } - } else { - this.options.getLogger().log(SentryLevel.INFO, "TelephonyManager is not available"); - } - } - } - - @SuppressWarnings("deprecation") - @Override - public void close() throws IOException { - if (telephonyManager != null && listener != null) { - telephonyManager.listen(listener, android.telephony.PhoneStateListener.LISTEN_NONE); - listener = null; - - if (options != null) { - options.getLogger().log(SentryLevel.DEBUG, "PhoneStateBreadcrumbsIntegration removed."); - } - } - } - - @SuppressWarnings("deprecation") - static final class PhoneStateChangeListener extends android.telephony.PhoneStateListener { - - private final @NotNull IHub hub; - - PhoneStateChangeListener(final @NotNull IHub hub) { - this.hub = hub; - } - - @SuppressWarnings("deprecation") - @Override - public void onCallStateChanged(int state, String incomingNumber) { - // incomingNumber is never used and it's always empty if you don't have permission: - // android.permission.READ_CALL_LOG - if (state == TelephonyManager.CALL_STATE_RINGING) { - final Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setType("system"); - breadcrumb.setCategory("device.event"); - breadcrumb.setData("action", "CALL_STATE_RINGING"); - breadcrumb.setMessage("Device ringing"); - breadcrumb.setLevel(SentryLevel.INFO); - hub.addBreadcrumb(breadcrumb); - } - } - } -} 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 0812e550157..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 @@ -1,19 +1,30 @@ package io.sentry.android.core; import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY; -import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot; +import static io.sentry.android.core.internal.util.ScreenshotUtils.captureScreenshot; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import android.app.Activity; +import android.graphics.Bitmap; +import android.view.View; import io.sentry.Attachment; import io.sentry.EventProcessor; import io.sentry.Hint; -import io.sentry.IntegrationName; import io.sentry.SentryEvent; import io.sentry.SentryLevel; 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; @@ -23,27 +34,52 @@ * captured. */ @ApiStatus.Internal -public final class ScreenshotEventProcessor implements EventProcessor, IntegrationName { +public final class ScreenshotEventProcessor implements EventProcessor { private final @NotNull SentryAndroidOptions options; private final @NotNull BuildInfoProvider buildInfoProvider; 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"); - this.debouncer = new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS); + this.debouncer = + new Debouncer( + AndroidCurrentDateProvider.getInstance(), + DEBOUNCE_WAIT_TIME_MS, + DEBOUNCE_MAX_EXECUTIONS); + + this.isReplayAvailable = isReplayAvailable; if (options.isAttachScreenshot()) { - addIntegrationToSdkVersion(); + addIntegrationToSdkVersion("Screenshot"); } } + private boolean isMaskingEnabled() { + return !options.getScreenshot().getMaskViewClasses().isEmpty() && isReplayAvailable; + } + + @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 @NotNull SentryEvent process(final @NotNull SentryEvent event, final @NotNull Hint hint) { if (!event.isErrored()) { @@ -54,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; @@ -72,15 +117,137 @@ public ScreenshotEventProcessor( return event; } - final byte[] screenshot = - takeScreenshot( - activity, options.getMainThreadChecker(), options.getLogger(), buildInfoProvider); + Bitmap screenshot = + captureScreenshot( + activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider); if (screenshot == null) { return event; } - hint.setScreenshot(Attachment.fromScreenshot(screenshot)); + // 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(finalScreenshot, options.getLogger()), + "screenshot.png", + "image/png", + false)); hint.set(ANDROID_ACTIVITY, activity); 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/SendCachedEnvelopeIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java index e0d08325b45..8eea2d71047 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java @@ -1,23 +1,43 @@ package io.sentry.android.core; -import io.sentry.IHub; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import io.sentry.DataCategory; +import io.sentry.IConnectionStatusProvider; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.SendCachedEnvelopeFireAndForgetIntegration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.transport.RateLimiter; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.LazyEvaluator; import io.sentry.util.Objects; +import java.io.Closeable; +import java.io.IOException; import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; -final class SendCachedEnvelopeIntegration implements Integration { +final class SendCachedEnvelopeIntegration + implements Integration, IConnectionStatusProvider.IConnectionStatusObserver, Closeable { private final @NotNull SendCachedEnvelopeFireAndForgetIntegration.SendFireAndForgetFactory factory; private final @NotNull LazyEvaluator startupCrashMarkerEvaluator; + private final AtomicBoolean startupCrashHandled = new AtomicBoolean(false); + private @Nullable IConnectionStatusProvider connectionStatusProvider; + private @Nullable IScopes scopes; + private @Nullable SentryAndroidOptions options; + private @Nullable SendCachedEnvelopeFireAndForgetIntegration.SendFireAndForget sender; + private final AtomicBoolean isInitialized = new AtomicBoolean(false); + private final AtomicBoolean isClosed = new AtomicBoolean(false); + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); public SendCachedEnvelopeIntegration( final @NotNull SendCachedEnvelopeFireAndForgetIntegration.SendFireAndForgetFactory factory, @@ -27,9 +47,9 @@ public SendCachedEnvelopeIntegration( } @Override - public void register(@NotNull IHub hub, @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); - final SentryAndroidOptions androidOptions = + public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); + this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); @@ -39,52 +59,116 @@ public void register(@NotNull IHub hub, @NotNull SentryOptions options) { options.getLogger().log(SentryLevel.ERROR, "No cache dir path is defined in options."); return; } + addIntegrationToSdkVersion("SendCachedEnvelope"); - final SendCachedEnvelopeFireAndForgetIntegration.SendFireAndForget sender = - factory.create(hub, androidOptions); + sendCachedEnvelopes(scopes, this.options); + } - if (sender == null) { - androidOptions.getLogger().log(SentryLevel.ERROR, "SendFireAndForget factory is null."); - return; + @Override + public void close() throws IOException { + isClosed.set(true); + if (connectionStatusProvider != null) { + connectionStatusProvider.removeConnectionStatusObserver(this); } + } - try { - Future future = - androidOptions + @Override + public void onConnectionStatusChanged( + final @NotNull IConnectionStatusProvider.ConnectionStatus status) { + if (scopes != null + && options != null + && status != IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) { + sendCachedEnvelopes(scopes, options); + } + } + + @SuppressWarnings({"NullAway"}) + private void sendCachedEnvelopes( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final Future future = + options .getExecutorService() .submit( () -> { try { + if (isClosed.get()) { + options + .getLogger() + .log( + SentryLevel.INFO, + "SendCachedEnvelopeIntegration, not trying to send after closing."); + return; + } + + if (!isInitialized.getAndSet(true)) { + connectionStatusProvider = options.getConnectionStatusProvider(); + connectionStatusProvider.addConnectionStatusObserver(this); + + sender = factory.create(scopes, options); + } + + if (connectionStatusProvider != null + && connectionStatusProvider.getConnectionStatus() + == IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) { + options + .getLogger() + .log(SentryLevel.INFO, "SendCachedEnvelopeIntegration, no connection."); + return; + } + + // in case there's rate limiting active, skip processing + final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter(); + if (rateLimiter != null + && rateLimiter.isActiveForCategory(DataCategory.All)) { + options + .getLogger() + .log( + SentryLevel.INFO, + "SendCachedEnvelopeIntegration, rate limiting active."); + return; + } + + if (sender == null) { + options + .getLogger() + .log( + SentryLevel.ERROR, + "SendCachedEnvelopeIntegration factory is null."); + return; + } + sender.send(); } catch (Throwable e) { - androidOptions + options .getLogger() .log(SentryLevel.ERROR, "Failed trying to send cached events.", e); } }); - if (startupCrashMarkerEvaluator.getValue()) { - androidOptions - .getLogger() - .log(SentryLevel.DEBUG, "Startup Crash marker exists, blocking flush."); + // startupCrashMarkerEvaluator remains true on subsequent runs, let's ensure we only block on + // the very first execution (=app start) + if (startupCrashMarkerEvaluator.getValue() + && startupCrashHandled.compareAndSet(false, true)) { + options.getLogger().log(SentryLevel.DEBUG, "Startup Crash marker exists, blocking flush."); try { - future.get(androidOptions.getStartupCrashFlushTimeoutMillis(), TimeUnit.MILLISECONDS); + future.get(options.getStartupCrashFlushTimeoutMillis(), TimeUnit.MILLISECONDS); } catch (TimeoutException e) { - androidOptions + options .getLogger() .log(SentryLevel.DEBUG, "Synchronous send timed out, continuing in the background."); } } - androidOptions.getLogger().log(SentryLevel.DEBUG, "SendCachedEnvelopeIntegration installed."); + options.getLogger().log(SentryLevel.DEBUG, "SendCachedEnvelopeIntegration installed."); } catch (RejectedExecutionException e) { - androidOptions + options .getLogger() .log( SentryLevel.ERROR, "Failed to call the executor. Cached events will not be sent. Did you call Sentry.close()?", e); } catch (Throwable e) { - androidOptions + options .getLogger() .log(SentryLevel.ERROR, "Failed to call the executor. Cached events will not be sent", e); } 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 044e727a5c1..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 @@ -1,31 +1,36 @@ package io.sentry.android.core; +import android.annotation.SuppressLint; +import android.app.Application; import android.content.Context; +import android.os.Process; import android.os.SystemClock; -import io.sentry.IHub; +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.SentryDate; import io.sentry.SentryLevel; import io.sentry.SentryOptions; -import io.sentry.android.core.internal.util.BreadcrumbFactory; +import io.sentry.Session; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; import io.sentry.android.fragment.FragmentLifecycleIntegration; import io.sentry.android.timber.SentryTimberIntegration; +import io.sentry.util.AutoClosableReentrantLock; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** Sentry initialization class */ public final class SentryAndroid { - // static to rely on Class load init. - private static final @NotNull SentryDate appStartTime = - AndroidDateUtils.getCurrentSentryDateTime(); // SystemClock.uptimeMillis() isn't affected by phone provider or clock changes. - private static final long appStart = SystemClock.uptimeMillis(); + private static final long sdkInitMillis = SystemClock.uptimeMillis(); static final String SENTRY_FRAGMENT_INTEGRATION_CLASS_NAME = "io.sentry.android.fragment.FragmentLifecycleIntegration"; @@ -33,10 +38,19 @@ public final class SentryAndroid { static final String SENTRY_TIMBER_INTEGRATION_CLASS_NAME = "io.sentry.android.timber.SentryTimberIntegration"; + static final String SENTRY_REPLAY_INTEGRATION_CLASS_NAME = + "io.sentry.android.replay.ReplayIntegration"; + + static final String SENTRY_DISTRIBUTION_INTEGRATION_CLASS_NAME = + "io.sentry.android.distribution.DistributionIntegration"; + private static final String TIMBER_CLASS_NAME = "timber.log.Timber"; private static final String FRAGMENT_CLASS_NAME = "androidx.fragment.app.FragmentManager$FragmentLifecycleCallbacks"; + protected static final @NotNull AutoClosableReentrantLock staticLock = + new AutoClosableReentrantLock(); + private SentryAndroid() {} /** @@ -77,19 +91,19 @@ public static void init( * @param logger your custom logger that implements ILogger * @param configuration Sentry.OptionsConfiguration configuration handler */ - public static synchronized void init( + @SuppressLint("NewApi") + public static void init( @NotNull final Context context, @NotNull ILogger logger, @NotNull Sentry.OptionsConfiguration configuration) { - // if SentryPerformanceProvider was disabled or removed, we set the App Start when - // the SDK is called. - AppStartState.getInstance().setAppStartTime(appStart, appStartTime); - - try { + // 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 LoadClass classLoader = new LoadClass(); + final io.sentry.util.LoadClass classLoader = new io.sentry.util.LoadClass(); final boolean isTimberUpstreamAvailable = classLoader.isClassAvailable(TIMBER_CLASS_NAME, options); final boolean isFragmentUpstreamAvailable = @@ -101,9 +115,13 @@ public static synchronized void init( final boolean isTimberAvailable = (isTimberUpstreamAvailable && classLoader.isClassAvailable(SENTRY_TIMBER_INTEGRATION_CLASS_NAME, options)); + final boolean isReplayAvailable = + classLoader.isClassAvailable(SENTRY_REPLAY_INTEGRATION_CLASS_NAME, options); + final boolean isDistributionAvailable = + classLoader.isClassAvailable(SENTRY_DISTRIBUTION_INTEGRATION_CLASS_NAME, options); final BuildInfoProvider buildInfoProvider = new BuildInfoProvider(logger); - final LoadClass loadClass = new LoadClass(); + final io.sentry.util.LoadClass loadClass = new io.sentry.util.LoadClass(); final ActivityFramesTracker activityFramesTracker = new ActivityFramesTracker(loadClass, options); @@ -120,22 +138,72 @@ public static synchronized void init( loadClass, activityFramesTracker, isFragmentAvailable, - isTimberAvailable); + isTimberAvailable, + isReplayAvailable, + isDistributionAvailable); - configuration.configure(options); + try { + configuration.configure(options); + } catch (Throwable t) { + // let it slip, but log it + options + .getLogger() + .log( + SentryLevel.ERROR, + "Error in the 'OptionsConfiguration.configure' callback.", + t); + } + + // if SentryPerformanceProvider was disabled or removed, + // we set the app start / sdk init time here instead + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + if (options.isEnablePerformanceV2() + && buildInfoProvider.getSdkInfoVersion() >= android.os.Build.VERSION_CODES.N) { + final @NotNull TimeSpan appStartTimeSpan = appStartMetrics.getAppStartTimeSpan(); + if (appStartTimeSpan.hasNotStarted()) { + appStartTimeSpan.setStartedAt(Process.getStartUptimeMillis()); + } + } + if (context.getApplicationContext() instanceof Application) { + appStartMetrics.registerLifecycleCallbacks( + (Application) context.getApplicationContext()); + } + final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); + if (sdkInitTimeSpan.hasNotStarted()) { + sdkInitTimeSpan.setStartedAt(sdkInitMillis); + } AndroidOptionsInitializer.initializeIntegrationsAndProcessors( - options, context, buildInfoProvider, loadClass, activityFramesTracker); + options, + context, + buildInfoProvider, + loadClass, + activityFramesTracker, + isReplayAvailable); deduplicateIntegrations(options, isFragmentAvailable, isTimberAvailable); }, true); - final @NotNull IHub hub = Sentry.getCurrentHub(); - if (hub.getOptions().isEnableAutoSessionTracking() - && ContextUtils.isForegroundImportance(context)) { - hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); - hub.startSession(); + final @NotNull IScopes scopes = Sentry.getCurrentScopes(); + if (ContextUtils.isForegroundImportance()) { + if (scopes.getOptions().isEnableAutoSessionTracking()) { + // The LifecycleWatcher of AppLifecycleIntegration may already started a session + // so only start a session if it's not already started + // This e.g. happens on React Native, or e.g. on deferred SDK init + final AtomicBoolean sessionStarted = new AtomicBoolean(false); + scopes.configureScope( + scope -> { + final @Nullable Session currentSession = scope.getSession(); + if (currentSession != null && currentSession.getStarted() != null) { + sessionStarted.set(true); + } + }); + if (!sessionStarted.get()) { + scopes.startSession(); + } + } + scopes.getOptions().getReplayController().start(); } } catch (IllegalAccessException e) { logger.log(SentryLevel.FATAL, "Fatal error during SentryAndroid.init(...)", e); @@ -155,6 +223,8 @@ public static synchronized 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(); } } @@ -172,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) { @@ -184,6 +255,9 @@ private static void deduplicateIntegrations( timberIntegrations.add(integration); } } + if (integration instanceof SystemEventsBreadcrumbsIntegration) { + systemEventsIntegrations.add(integration); + } } if (fragmentIntegrations.size() > 1) { @@ -199,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 62437a0425f..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 @@ -1,17 +1,23 @@ package io.sentry.android.core; +import android.app.Activity; import android.app.ActivityManager; import android.app.ApplicationExitInfo; import io.sentry.Hint; +import io.sentry.IScope; import io.sentry.ISpan; -import io.sentry.Scope; import io.sentry.Sentry; import io.sentry.SentryEvent; +import io.sentry.SentryFeedbackOptions; +import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SpanStatus; import io.sentry.android.core.internal.util.RootChecker; +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; 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; @@ -31,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 @@ -53,8 +75,8 @@ public final class SentryAndroidOptions extends SentryOptions { * Enables the Auto instrumentation for Activity lifecycle tracing. * *

    - *
  • It also requires setting any of {@link SentryOptions#getEnableTracing()}, {@link - * SentryOptions#getTracesSampleRate()} or {@link SentryOptions#getTracesSampler()}. + *
  • It also requires setting any of {@link SentryOptions#getTracesSampleRate()} or {@link + * SentryOptions#getTracesSampler()}. *
* *
    @@ -77,8 +99,8 @@ 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 Scope}, but only if there's no transaction - * already bound to the Scope. + *

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; @@ -93,13 +115,6 @@ public final class SentryAndroidOptions extends SentryOptions { */ private boolean enableActivityLifecycleTracingAutoFinish = true; - /** - * Profiling traces rate. 101 hz means 101 traces in 1 second. Defaults to 101 to avoid possible - * lockstep sampling. More on - * https://stackoverflow.com/questions/45470758/what-is-lockstep-sampling - */ - private int profilingTracesHz = 101; - /** Interface that loads the debug images list */ private @NotNull IDebugImagesLoader debugImagesLoader = NoOpDebugImagesLoader.getInstance(); @@ -123,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 @@ -160,6 +178,28 @@ public final class SentryAndroidOptions extends SentryOptions { private @Nullable BeforeCaptureCallback beforeViewHierarchyCaptureCallback; + /** Turns NDK on or off. Default is enabled. */ + private boolean enableNdk = true; + + @NotNull + private NdkHandlerStrategy ndkHandlerStrategy = + NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_DEFAULT; + + /** + * Enable the Java to NDK Scope sync. The default value for sentry-java is disabled and enabled + * for sentry-android. + */ + private boolean enableScopeSync = true; + + /** + * Whether to enable automatic trace ID generation. This is mainly used by the Hybrid SDKs to + * control the trace ID generation from the outside. + */ + private boolean enableAutoTraceIdGeneration = true; + + /** Enable or disable intent extras reporting for system event breadcrumbs. Default is false. */ + private boolean enableSystemEventBreadcrumbsExtras = false; + public interface BeforeCaptureCallback { /** @@ -192,24 +232,59 @@ public interface BeforeCaptureCallback { * reporting only the latest one. * *

These events do not affect ANR rate nor are they enriched with additional information from - * {@link Scope} like breadcrumbs. The events are reported with 'HistoricalAppExitInfo' {@link + * {@link IScope} like breadcrumbs. The events are reported with 'HistoricalAppExitInfo' {@link * Mechanism}. */ 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()); setAttachServerName(false); - - // enable scope sync for Android by default - setEnableScopeSync(true); } private @NotNull SdkVersion createSdkVersion() { @@ -279,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; } @@ -333,43 +471,6 @@ public void enableAllAutoBreadcrumbs(boolean enable) { setEnableUserInteractionBreadcrumbs(enable); } - /** - * Returns the interval for profiling traces in milliseconds. - * - * @return the interval for profiling traces in milliseconds. - * @deprecated has no effect and will be removed in future versions. It now just returns 0. - */ - @Deprecated - @SuppressWarnings("InlineMeSuggester") - public int getProfilingTracesIntervalMillis() { - return 0; - } - - /** - * Sets the interval for profiling traces in milliseconds. - * - * @param profilingTracesIntervalMillis - the interval for profiling traces in milliseconds. - * @deprecated has no effect and will be removed in future versions. - */ - @Deprecated - public void setProfilingTracesIntervalMillis(final int profilingTracesIntervalMillis) {} - - /** - * Returns the rate the profiler will sample rates at. 100 hz means 100 traces in 1 second. - * - * @return Rate the profiler will sample rates at. - */ - @ApiStatus.Internal - public int getProfilingTracesHz() { - return profilingTracesHz; - } - - /** Sets the rate the profiler will sample rates at. 100 hz means 100 traces in 1 second. */ - @ApiStatus.Internal - public void setProfilingTracesHz(final int profilingTracesHz) { - this.profilingTracesHz = profilingTracesHz; - } - /** * Returns the Debug image loader * @@ -430,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; } @@ -484,6 +593,16 @@ public void setNativeSdkName(final @Nullable String nativeSdkName) { this.nativeSdkName = nativeSdkName; } + @ApiStatus.Internal + public void setNativeHandlerStrategy(final @NotNull NdkHandlerStrategy ndkHandlerStrategy) { + this.ndkHandlerStrategy = ndkHandlerStrategy; + } + + @ApiStatus.Internal + public int getNdkHandlerStrategy() { + return ndkHandlerStrategy.getValue(); + } + /** * Returns the sdk name for the sentry native ndk module. * @@ -532,6 +651,42 @@ public void setBeforeViewHierarchyCaptureCallback( this.beforeViewHierarchyCaptureCallback = beforeViewHierarchyCaptureCallback; } + /** + * Check if NDK is ON or OFF Default is ON + * + * @return true if ON or false otherwise + */ + public boolean isEnableNdk() { + return enableNdk; + } + + /** + * Sets NDK to ON or OFF + * + * @param enableNdk true if ON or false otherwise + */ + public void setEnableNdk(boolean enableNdk) { + this.enableNdk = enableNdk; + } + + /** + * Returns if the Java to NDK Scope sync is enabled + * + * @return true if enabled or false otherwise + */ + public boolean isEnableScopeSync() { + return enableScopeSync; + } + + /** + * Enables or not the Java to NDK Scope sync + * + * @param enableScopeSync true if enabled or false otherwise + */ + public void setEnableScopeSync(boolean enableScopeSync) { + this.enableScopeSync = enableScopeSync; + } + public boolean isReportHistoricalAnrs() { return reportHistoricalAnrs; } @@ -540,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; } @@ -547,4 +710,182 @@ public boolean isAttachAnrThreadDump() { 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. + */ + public boolean isEnablePerformanceV2() { + return enablePerformanceV2; + } + + /** + * Enables or disables the Performance V2 SDK features. + * + *

With this change - Cold app start spans will provide more accurate timings - Cold app start + * spans will be enriched with detailed ContentProvider, Application and Activity startup times + * + * @param enablePerformanceV2 true if enabled or false otherwise + */ + 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; + } + + @ApiStatus.Internal + public void setFrameMetricsCollector( + final @Nullable SentryFrameMetricsCollector frameMetricsCollector) { + this.frameMetricsCollector = frameMetricsCollector; + } + + public boolean isEnableAutoTraceIdGeneration() { + return enableAutoTraceIdGeneration; + } + + public void setEnableAutoTraceIdGeneration(final boolean enableAutoTraceIdGeneration) { + this.enableAutoTraceIdGeneration = enableAutoTraceIdGeneration; + } + + public boolean isEnableSystemEventBreadcrumbsExtras() { + return enableSystemEventBreadcrumbsExtras; + } + + public void setEnableSystemEventBreadcrumbsExtras( + final boolean enableSystemEventBreadcrumbsExtras) { + this.enableSystemEventBreadcrumbsExtras = enableSystemEventBreadcrumbsExtras; + } + + /** + * 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 showForm( + final @Nullable SentryId associatedEventId, + final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { + final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); + if (activity == null) { + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log( + SentryLevel.ERROR, + "Cannot show user feedback dialog, no activity is available. " + + "Make sure to call SentryAndroid.init() in your Application.onCreate() method."); + return; + } + + new SentryUserFeedbackForm.Builder(activity) + .associatedEventId(associatedEventId) + .configurator(configurator) + .create() + .show(); + } + } } 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/SentryFrameMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetrics.java new file mode 100644 index 00000000000..cf2241757ca --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetrics.java @@ -0,0 +1,118 @@ +package io.sentry.android.core; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +final class SentryFrameMetrics { + + private int slowFrameCount; + private int frozenFrameCount; + + private long slowFrameDelayNanos; + private long frozenFrameDelayNanos; + + private long totalDurationNanos; + + public SentryFrameMetrics() {} + + public SentryFrameMetrics( + final int slowFrameCount, + final long slowFrameDelayNanos, + final int frozenFrameCount, + final long frozenFrameDelayNanos, + final long totalDurationNanos) { + this.slowFrameCount = slowFrameCount; + this.slowFrameDelayNanos = slowFrameDelayNanos; + + this.frozenFrameCount = frozenFrameCount; + this.frozenFrameDelayNanos = frozenFrameDelayNanos; + this.totalDurationNanos = totalDurationNanos; + } + + public void addFrame( + final long durationNanos, + final long delayNanos, + final boolean isSlow, + final boolean isFrozen) { + totalDurationNanos += durationNanos; + if (isFrozen) { + frozenFrameDelayNanos += delayNanos; + frozenFrameCount += 1; + } else if (isSlow) { + slowFrameDelayNanos += delayNanos; + slowFrameCount += 1; + } + } + + public int getSlowFrameCount() { + return slowFrameCount; + } + + public int getFrozenFrameCount() { + return frozenFrameCount; + } + + public long getSlowFrameDelayNanos() { + return slowFrameDelayNanos; + } + + public long getFrozenFrameDelayNanos() { + return frozenFrameDelayNanos; + } + + /** Returns the sum of the slow and frozen frames. */ + public int getSlowFrozenFrameCount() { + return slowFrameCount + frozenFrameCount; + } + + public long getTotalDurationNanos() { + return totalDurationNanos; + } + + public void clear() { + slowFrameCount = 0; + slowFrameDelayNanos = 0; + + frozenFrameCount = 0; + frozenFrameDelayNanos = 0; + + totalDurationNanos = 0; + } + + @NotNull + public SentryFrameMetrics duplicate() { + return new SentryFrameMetrics( + slowFrameCount, + slowFrameDelayNanos, + frozenFrameCount, + frozenFrameDelayNanos, + totalDurationNanos); + } + + /** + * @param other the other frame metrics to compare to, usually the older one + * @return the difference between two frame metrics (this minus other) + */ + @NotNull + public SentryFrameMetrics diffTo(final @NotNull SentryFrameMetrics other) { + return new SentryFrameMetrics( + slowFrameCount - other.slowFrameCount, + slowFrameDelayNanos - other.slowFrameDelayNanos, + frozenFrameCount - other.frozenFrameCount, + frozenFrameDelayNanos - other.frozenFrameDelayNanos, + totalDurationNanos - other.totalDurationNanos); + } + + /** + * @return true if the frame metrics contain valid data, meaning all values are greater or equal + * to 0 + */ + public boolean containsValidData() { + return slowFrameCount >= 0 + && slowFrameDelayNanos >= 0 + && frozenFrameCount >= 0 + && frozenFrameDelayNanos >= 0 + && totalDurationNanos >= 0; + } +} 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/SentryInitProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryInitProvider.java index 6d88bdad631..749eda07efc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryInitProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryInitProvider.java @@ -21,7 +21,9 @@ public boolean onCreate() { logger.log(SentryLevel.FATAL, "App. Context from ContentProvider is null"); return false; } - if (ManifestMetadataReader.isAutoInit(context, logger)) { + + if (ManifestMetadataReader.isAutoInit(context, logger) + && !ContextUtils.appIsLibraryForComposePreview(context)) { SentryAndroid.init(context, logger); SentryIntegrationPackageStorage.getInstance().addIntegration("AutoInit"); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java index a942d51878c..1e649c1783f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java @@ -2,8 +2,11 @@ import android.util.Log; import io.sentry.Breadcrumb; +import io.sentry.ScopesAdapter; import io.sentry.Sentry; import io.sentry.SentryLevel; +import io.sentry.SentryLogLevel; +import io.sentry.logger.SentryLogParameters; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -44,73 +47,107 @@ private static void addAsBreadcrumb( Sentry.addBreadcrumb(breadcrumb); } + private static void addAsLog( + @NotNull final SentryLogLevel level, + @Nullable final String msg, + @Nullable final Throwable tr) { + final @NotNull ScopesAdapter scopes = ScopesAdapter.getInstance(); + // Check if logs are enabled before doing expensive operations + if (!scopes.getOptions().getLogs().isEnabled()) { + return; + } + final @Nullable String trMessage = tr != null ? tr.getMessage() : null; + final @NotNull SentryLogParameters params = new SentryLogParameters(); + params.setOrigin("auto.log.logcat"); + + if (tr == null || trMessage == null) { + scopes.logger().log(level, params, msg); + } else { + scopes.logger().log(level, params, msg != null ? (msg + "\n" + trMessage) : trMessage); + } + } + public static int v(@Nullable String tag, @Nullable String msg) { addAsBreadcrumb(tag, SentryLevel.DEBUG, msg); + addAsLog(SentryLogLevel.TRACE, msg, null); return Log.v(tag, msg); } public static int v(@Nullable String tag, @Nullable String msg, @Nullable Throwable tr) { addAsBreadcrumb(tag, SentryLevel.DEBUG, msg, tr); + addAsLog(SentryLogLevel.TRACE, msg, tr); return Log.v(tag, msg, tr); } public static int d(@Nullable String tag, @Nullable String msg) { addAsBreadcrumb(tag, SentryLevel.DEBUG, msg); + addAsLog(SentryLogLevel.DEBUG, msg, null); return Log.d(tag, msg); } public static int d(@Nullable String tag, @Nullable String msg, @Nullable Throwable tr) { addAsBreadcrumb(tag, SentryLevel.DEBUG, msg, tr); + addAsLog(SentryLogLevel.DEBUG, msg, tr); return Log.d(tag, msg, tr); } public static int i(@Nullable String tag, @Nullable String msg) { addAsBreadcrumb(tag, SentryLevel.INFO, msg); + addAsLog(SentryLogLevel.INFO, msg, null); return Log.i(tag, msg); } public static int i(@Nullable String tag, @Nullable String msg, @Nullable Throwable tr) { addAsBreadcrumb(tag, SentryLevel.INFO, msg, tr); + addAsLog(SentryLogLevel.INFO, msg, tr); return Log.i(tag, msg, tr); } public static int w(@Nullable String tag, @Nullable String msg) { addAsBreadcrumb(tag, SentryLevel.WARNING, msg); + addAsLog(SentryLogLevel.WARN, msg, null); return Log.w(tag, msg); } public static int w(@Nullable String tag, @Nullable String msg, @Nullable Throwable tr) { addAsBreadcrumb(tag, SentryLevel.WARNING, msg, tr); + addAsLog(SentryLogLevel.WARN, msg, tr); return Log.w(tag, msg, tr); } public static int w(@Nullable String tag, @Nullable Throwable tr) { addAsBreadcrumb(tag, SentryLevel.WARNING, tr); + addAsLog(SentryLogLevel.WARN, null, tr); return Log.w(tag, tr); } public static int e(@Nullable String tag, @Nullable String msg) { addAsBreadcrumb(tag, SentryLevel.ERROR, msg); + addAsLog(SentryLogLevel.ERROR, msg, null); return Log.e(tag, msg); } public static int e(@Nullable String tag, @Nullable String msg, @Nullable Throwable tr) { addAsBreadcrumb(tag, SentryLevel.ERROR, msg, tr); + addAsLog(SentryLogLevel.ERROR, msg, tr); return Log.e(tag, msg, tr); } public static int wtf(@Nullable String tag, @Nullable String msg) { addAsBreadcrumb(tag, SentryLevel.ERROR, msg); + addAsLog(SentryLogLevel.FATAL, msg, null); return Log.wtf(tag, msg); } public static int wtf(@Nullable String tag, @Nullable Throwable tr) { addAsBreadcrumb(tag, SentryLevel.ERROR, tr); + addAsLog(SentryLogLevel.FATAL, null, tr); return Log.wtf(tag, tr); } public static int wtf(@Nullable String tag, @Nullable String msg, @Nullable Throwable tr) { addAsBreadcrumb(tag, SentryLevel.ERROR, msg, tr); + addAsLog(SentryLogLevel.FATAL, msg, tr); return Log.wtf(tag, msg, tr); } } 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 7ba270351b3..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 @@ -1,60 +1,68 @@ package io.sentry.android.core; -import android.app.Activity; +import static io.sentry.Sentry.APP_START_PROFILING_CONFIG_FILE_NAME; + +import android.annotation.SuppressLint; import android.app.Application; import android.content.Context; import android.content.pm.ProviderInfo; import android.net.Uri; -import android.os.Bundle; +import android.os.Process; import android.os.SystemClock; -import io.sentry.SentryDate; +import io.sentry.IContinuousProfiler; +import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ITransactionProfiler; +import io.sentry.JsonObjectReader; +import io.sentry.SentryAppStartProfilingOptions; +import io.sentry.SentryExecutorService; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.TracesSampler; +import io.sentry.TracesSamplingDecision; +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.InputStreamReader; +import java.io.Reader; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; -/** - * SentryPerformanceProvider is responsible for collecting data (eg appStart) as early as possible - * as ContentProvider is the only reliable hook for libraries that works across all the supported - * SDK versions. When minSDK is >= 24, we could use Process.getStartUptimeMillis() We could also use - * AppComponentFactory but it depends on androidx.core.app.AppComponentFactory - */ @ApiStatus.Internal -public final class SentryPerformanceProvider extends EmptySecureContentProvider - implements Application.ActivityLifecycleCallbacks { +public final class SentryPerformanceProvider extends EmptySecureContentProvider { // static to rely on Class load - private static @NotNull SentryDate appStartTime = AndroidDateUtils.getCurrentSentryDateTime(); // SystemClock.uptimeMillis() isn't affected by phone provider or clock changes. - private static long appStartMillis = SystemClock.uptimeMillis(); + private static final long sdkInitMillis = SystemClock.uptimeMillis(); + + private @Nullable Application app; - private boolean firstActivityCreated = false; - private boolean firstActivityResumed = false; + private final @NotNull ILogger logger; + private final @NotNull BuildInfoProvider buildInfoProvider; - private @Nullable Application application; + @TestOnly + SentryPerformanceProvider( + final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider) { + this.logger = logger; + this.buildInfoProvider = buildInfoProvider; + } public SentryPerformanceProvider() { - AppStartState.getInstance().setAppStartTime(appStartMillis, appStartTime); + logger = new AndroidLogger(); + buildInfoProvider = new BuildInfoProvider(logger); } @Override public boolean onCreate() { - Context context = getContext(); - - if (context == null) { - return false; - } - - // it returns null if ContextImpl, so let's check for nullability - if (context.getApplicationContext() != null) { - context = context.getApplicationContext(); - } - - if (context instanceof Application) { - application = ((Application) context); - application.registerActivityLifecycleCallbacks(this); - } - + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + onAppLaunched(getContext(), appStartMetrics); + launchAppStartProfiler(appStartMetrics); return true; } @@ -74,53 +82,196 @@ public String getType(@NotNull Uri uri) { return null; } - @TestOnly - static void setAppStartTime( - final long appStartMillisLong, final @NotNull SentryDate appStartTimeDate) { - appStartMillis = appStartMillisLong; - appStartTime = appStartTimeDate; + @Override + public void shutdown() { + try (final @NotNull ISentryLifecycleToken ignored = AppStartMetrics.staticLock.acquire()) { + final @Nullable ITransactionProfiler appStartProfiler = + AppStartMetrics.getInstance().getAppStartProfiler(); + if (appStartProfiler != null) { + appStartProfiler.close(); + } + final @Nullable IContinuousProfiler appStartContinuousProfiler = + AppStartMetrics.getInstance().getAppStartContinuousProfiler(); + if (appStartContinuousProfiler != null) { + appStartContinuousProfiler.close(true); + } + } } - @Override - public void onActivityCreated(@NotNull Activity activity, @Nullable Bundle savedInstanceState) { - // Hybrid Apps like RN or Flutter init the Android SDK after the MainActivity of the App - // has been created, and some frameworks overwrites the behaviour of activity lifecycle - // or it's already too late to get the callback for the very first Activity, hence we - // register the ActivityLifecycleCallbacks here, since this Provider is always run first. - if (!firstActivityCreated) { - // if Activity has savedInstanceState then its a warm start - // https://developer.android.com/topic/performance/vitals/launch-time#warm - final boolean coldStart = savedInstanceState == null; - AppStartState.getInstance().setColdStart(coldStart); - - firstActivityCreated = true; + private void launchAppStartProfiler(final @NotNull AppStartMetrics appStartMetrics) { + final @Nullable Context context = getContext(); + + if (context == null) { + logger.log(SentryLevel.FATAL, "App. Context from ContentProvider is null"); + return; + } + + final @NotNull File cacheDir = AndroidOptionsInitializer.getCacheDir(context); + final @NotNull File configFile = new File(cacheDir, APP_START_PROFILING_CONFIG_FILE_NAME); + + // No config exists: app start profiling is not enabled + if (!configFile.exists() || !configFile.canRead()) { + return; + } + + try (final @NotNull Reader reader = + new BufferedReader(new InputStreamReader(new FileInputStream(configFile)))) { + final @Nullable SentryAppStartProfilingOptions profilingOptions = + deserializeProfilingConfig(reader); + + if (profilingOptions == null) { + logger.log( + SentryLevel.WARNING, + "Unable to deserialize the SentryAppStartProfilingOptions. App start profiling will not start."); + 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); + return; + } + + if (!profilingOptions.isProfilingEnabled()) { + logger.log( + SentryLevel.INFO, "Profiling is not enabled. App start profiling will not start."); + return; + } + + if (profilingOptions.isEnableAppStartProfiling()) { + createAndStartTransactionProfiler(context, profilingOptions, appStartMetrics); + } + } catch (FileNotFoundException e) { + logger.log(SentryLevel.ERROR, "App start profiling config file not found. ", e); + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Error reading app start profiling config file. ", e); } } - @Override - public void onActivityStarted(@NotNull Activity activity) {} + /** + * 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; + } + } - @Override - public void onActivityResumed(@NotNull Activity activity) { - if (!firstActivityResumed) { - // sets App start as finished when the very first activity calls onResume - firstActivityResumed = true; - AppStartState.getInstance().setAppStartEnd(); + private void createAndStartContinuousProfiler( + final @NotNull Context context, + final @NotNull SentryAppStartProfilingOptions profilingOptions, + final @NotNull AppStartMetrics appStartMetrics) { + + if (!profilingOptions.isContinuousProfileSampled()) { + logger.log(SentryLevel.DEBUG, "App start profiling was not sampled. It will not start."); + return; } - if (application != null) { - application.unregisterActivityLifecycleCallbacks(this); + + final @NotNull SentryExecutorService startupExecutorService = new SentryExecutorService(); + final @NotNull IContinuousProfiler appStartContinuousProfiler = + new AndroidContinuousProfiler( + buildInfoProvider, + new SentryFrameMetricsCollector( + context.getApplicationContext(), logger, buildInfoProvider), + logger, + profilingOptions.getProfilingTracesDirPath(), + profilingOptions.getProfilingTracesHz(), + () -> startupExecutorService); + appStartMetrics.setAppStartProfiler(null); + appStartMetrics.setAppStartContinuousProfiler(appStartContinuousProfiler); + logger.log(SentryLevel.DEBUG, "App start continuous profiling started."); + SentryOptions sentryOptions = SentryOptions.empty(); + // Let's fake a sampler to accept the sampling decision that was calculated on last run + sentryOptions.setProfileSessionSampleRate( + profilingOptions.isContinuousProfileSampled() ? 1.0 : 0.0); + appStartContinuousProfiler.startProfiler( + profilingOptions.getProfileLifecycle(), new TracesSampler(sentryOptions)); + } + + private void createAndStartTransactionProfiler( + final @NotNull Context context, + final @NotNull SentryAppStartProfilingOptions profilingOptions, + final @NotNull AppStartMetrics appStartMetrics) { + final @NotNull TracesSamplingDecision appStartSamplingDecision = + new TracesSamplingDecision( + profilingOptions.isTraceSampled(), + profilingOptions.getTraceSampleRate(), + profilingOptions.isProfileSampled(), + profilingOptions.getProfileSampleRate()); + // We store any sampling decision, so we can respect it when the first transaction starts + appStartMetrics.setAppStartSamplingDecision(appStartSamplingDecision); + + if (!(appStartSamplingDecision.getProfileSampled() && appStartSamplingDecision.getSampled())) { + logger.log(SentryLevel.DEBUG, "App start profiling was not sampled. It will not start."); + return; } + + final @NotNull SentryExecutorService executorService = new SentryExecutorService(); + final @NotNull ITransactionProfiler appStartProfiler = + new AndroidTransactionProfiler( + context, + buildInfoProvider, + new SentryFrameMetricsCollector(context, logger, buildInfoProvider), + logger, + profilingOptions.getProfilingTracesDirPath(), + profilingOptions.isProfilingEnabled(), + profilingOptions.getProfilingTracesHz(), + () -> executorService); + appStartMetrics.setAppStartContinuousProfiler(null); + appStartMetrics.setAppStartProfiler(appStartProfiler); + logger.log(SentryLevel.DEBUG, "App start profiling started."); + appStartProfiler.start(); } - @Override - public void onActivityPaused(@NotNull Activity activity) {} + @SuppressLint("NewApi") + private void onAppLaunched( + final @Nullable Context context, final @NotNull AppStartMetrics appStartMetrics) { - @Override - public void onActivityStopped(@NotNull Activity activity) {} + // sdk-init uses static field init as start time + final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); + sdkInitTimeSpan.setStartedAt(sdkInitMillis); - @Override - public void onActivitySaveInstanceState(@NotNull Activity activity, @NotNull Bundle outState) {} + // performance v2: Uses Process.getStartUptimeMillis() + // requires API level 24+ + if (buildInfoProvider.getSdkInfoVersion() >= android.os.Build.VERSION_CODES.N) { + final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); + appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); + } - @Override - public void onActivityDestroyed(@NotNull Activity activity) {} + if (context instanceof Application) { + app = (Application) context; + } + if (app == null) { + return; + } + + appStartMetrics.registerLifecycleCallbacks(app); + } } 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 new file mode 100644 index 00000000000..f842f18674b --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java @@ -0,0 +1,130 @@ +package io.sentry.android.core; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Build; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.Button; +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); + init(context, attrs, defStyleAttr, defStyleRes); + } + + @SuppressLint("SetTextI18n") + @SuppressWarnings("deprecation") + private void init( + final @NotNull Context context, + final @Nullable AttributeSet attrs, + final int defStyleAttr, + final int defStyleRes) { + try (final @NotNull TypedArray typedArray = + context.obtainStyledAttributes( + attrs, R.styleable.SentryUserFeedbackButton, defStyleAttr, defStyleRes)) { + final float dimensionScale = context.getResources().getDisplayMetrics().density; + final float drawablePadding = + typedArray.getDimension(R.styleable.SentryUserFeedbackButton_android_drawablePadding, -1); + final int drawableStart = + typedArray.getResourceId(R.styleable.SentryUserFeedbackButton_android_drawableStart, -1); + final boolean textAllCaps = + typedArray.getBoolean(R.styleable.SentryUserFeedbackButton_android_textAllCaps, false); + final int background = + typedArray.getResourceId(R.styleable.SentryUserFeedbackButton_android_background, -1); + final float padding = + typedArray.getDimension(R.styleable.SentryUserFeedbackButton_android_padding, -1); + final int textColor = + typedArray.getColor(R.styleable.SentryUserFeedbackButton_android_textColor, -1); + final @Nullable String text = + typedArray.getString(R.styleable.SentryUserFeedbackButton_android_text); + + // If the drawable padding is not set, set it to 4dp + if (drawablePadding == -1) { + setCompoundDrawablePadding((int) (4 * dimensionScale)); + } + + // If the drawable start is not set, set it to the default drawable + if (drawableStart == -1) { + setCompoundDrawablesRelativeWithIntrinsicBounds( + R.drawable.sentry_user_feedback_button_logo_24, 0, 0, 0); + } + + // Set the text all caps + setAllCaps(textAllCaps); + + // If the background is not set, set it to the default background + if (background == -1) { + setBackgroundResource(R.drawable.sentry_oval_button_ripple_background); + } + + // If the padding is not set, set it to 12dp + if (padding == -1) { + int defaultPadding = (int) (12 * dimensionScale); + setPadding(defaultPadding, defaultPadding, defaultPadding, defaultPadding); + } + + // If the text color is not set, set it to the default text color + if (textColor == -1) { + // We need the TypedValue to resolve the color from the theme + final @NotNull TypedValue typedValue = new TypedValue(); + context.getTheme().resolveAttribute(android.R.attr.colorForeground, typedValue, true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + setTextColor(context.getResources().getColor(typedValue.resourceId, context.getTheme())); + } else { + setTextColor(context.getResources().getColor(typedValue.resourceId)); + } + } + + // If the text is not set, set it to "Report a Bug" + if (text == null) { + setText("Report a Bug"); + } + } + + // Set the default ClickListener to open the SentryUserFeedbackForm + setOnClickListener(delegate); + } + + @Override + public void setOnClickListener(final @Nullable OnClickListener listener) { + delegate = listener; + super.setOnClickListener( + v -> { + 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 new file mode 100644 index 00000000000..155464b7b73 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java @@ -0,0 +1,114 @@ +package io.sentry.android.core; + +import android.content.Context; +import io.sentry.SentryFeedbackOptions; +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * @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 SentryUserFeedbackForm.OptionsConfiguration configuration, + final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { + super(context, themeResId, associatedEventId, configuration, configurator); + } + + /** + * @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. + * + * @param context the parent context + * @deprecated Use {@link SentryUserFeedbackForm.Builder#Builder(Context)} instead. + */ + @Deprecated + public Builder(final @NotNull Context context) { + super(context); + } + + /** + * Creates a builder for a {@link SentryUserFeedbackDialog} that uses an explicit theme + * resource. + * + * @param context the parent context + * @param themeResId the resource ID of the theme + * @deprecated Use {@link SentryUserFeedbackForm.Builder#Builder(Context, int)} instead. + */ + @Deprecated + public Builder(Context context, int themeResId) { + super(context, themeResId); + } + + /** + * Creates a builder for a {@link SentryUserFeedbackDialog} with a configuration. + * + * @param context the parent context + * @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) { + super(context, configuration); + } + + /** + * 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 + * @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) { + super(context, themeResId, configuration); + } + + @Deprecated + @Override + public Builder configurator( + final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { + super.configurator(configurator); + return this; + } + + @Deprecated + @Override + public Builder associatedEventId(final @Nullable SentryId associatedEventId) { + super.associatedEventId(associatedEventId); + return this; + } + + @Deprecated + @Override + public SentryUserFeedbackDialog create() { + return new SentryUserFeedbackDialog( + context, themeResId, associatedEventId, configuration, configurator); + } + } + + /** + * @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 new file mode 100644 index 00000000000..074a4a6ea51 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java @@ -0,0 +1,364 @@ +package io.sentry.android.core; + +import io.sentry.DateUtils; +import io.sentry.IPerformanceContinuousCollector; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ISpan; +import io.sentry.ITransaction; +import io.sentry.NoOpSpan; +import io.sentry.NoOpTransaction; +import io.sentry.SentryDate; +import io.sentry.SentryNanotimeDate; +import io.sentry.SpanDataConvention; +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; +import io.sentry.protocol.MeasurementValue; +import io.sentry.util.AutoClosableReentrantLock; +import java.util.Iterator; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentSkipListSet; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public class SpanFrameMetricsCollector + implements IPerformanceContinuousCollector, + SentryFrameMetricsCollector.FrameMetricsCollectorListener { + + // 30s span duration at 120fps = 3600 frames + // this is just an upper limit for frames.size, ensuring that the buffer does not + // 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(0, 0); + + private final boolean enabled; + protected final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; + + private volatile @Nullable String listenerId; + + // all running spans, sorted by span start nano time + private final @NotNull SortedSet runningSpans = + new TreeSet<>( + (o1, o2) -> { + if (o1 == o2) { + return 0; + } + int timeDiff = o1.getStartDate().compareTo(o2.getStartDate()); + if (timeDiff != 0) { + return timeDiff; + } + // TreeSet uses compareTo to check for duplicates, so ensure that + // two non-equal spans with the same start date are not considered equal + return o1.getSpanContext() + .getSpanId() + .toString() + .compareTo(o2.getSpanContext().getSpanId().toString()); + }); + + // all collected frames, sorted by frame end time + // this is a concurrent set, as the frames are added on the main thread, + // but span starts/finish may happen on any thread + // the list only holds Frames, but in order to query for a specific span NanoTimeStamp is used + private final @NotNull ConcurrentSkipListSet frames = new ConcurrentSkipListSet<>(); + + // assume 60fps until we get a value reported by the system + private long lastKnownFrameDurationNanos = 16_666_666L; + + public SpanFrameMetricsCollector( + final @NotNull SentryAndroidOptions options, + final @NotNull SentryFrameMetricsCollector frameMetricsCollector) { + this.frameMetricsCollector = frameMetricsCollector; + + enabled = options.isEnablePerformanceV2() && options.isEnableFramesTracking(); + } + + @Override + public void onSpanStarted(final @NotNull ISpan span) { + if (!enabled) { + return; + } + if (span instanceof NoOpSpan) { + return; + } + if (span instanceof NoOpTransaction) { + return; + } + + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + runningSpans.add(span); + + if (listenerId == null) { + listenerId = frameMetricsCollector.startCollection(this); + } + } + } + + @Override + public void onSpanFinished(final @NotNull ISpan span) { + if (!enabled) { + return; + } + + if (span instanceof NoOpSpan) { + return; + } + + if (span instanceof NoOpTransaction) { + return; + } + + // ignore span if onSpanStarted was never called for it + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!runningSpans.contains(span)) { + return; + } + } + + captureFrameMetrics(span); + + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (runningSpans.isEmpty()) { + clear(); + } else { + // otherwise only remove old/irrelevant frames + final @NotNull ISpan oldestSpan = runningSpans.first(); + frames.headSet(new Frame(toNanoTime(oldestSpan.getStartDate()))).clear(); + } + } + } + + private void captureFrameMetrics(@NotNull final ISpan span) { + // TODO lock still required? + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + boolean removed = runningSpans.remove(span); + if (!removed) { + return; + } + + final @Nullable SentryDate spanFinishDate = span.getFinishDate(); + if (spanFinishDate == null) { + return; + } + + final long spanStartNanos = toNanoTime(span.getStartDate()); + final long spanEndNanos = toNanoTime(spanFinishDate); + final long spanDurationNanos = spanEndNanos - spanStartNanos; + if (spanDurationNanos <= 0) { + return; + } + + final @NotNull SentryFrameMetrics frameMetrics = new SentryFrameMetrics(); + + long frameDurationNanos = lastKnownFrameDurationNanos; + + if (!frames.isEmpty()) { + // determine relevant start in frames list + final Iterator iterator = frames.tailSet(new Frame(spanStartNanos)).iterator(); + + //noinspection WhileLoopReplaceableByForEach + while (iterator.hasNext()) { + final @NotNull Frame frame = iterator.next(); + + if (frame.startNanos > spanEndNanos) { + break; + } + + if (frame.startNanos >= spanStartNanos && frame.endNanos <= spanEndNanos) { + // if the frame is contained within the span, add it 1:1 to the span metrics + frameMetrics.addFrame( + frame.durationNanos, frame.delayNanos, frame.isSlow, frame.isFrozen); + } else if ((spanStartNanos > frame.startNanos && spanStartNanos < frame.endNanos) + || (spanEndNanos > frame.startNanos && spanEndNanos < frame.endNanos)) { + // span start or end are within frame + // calculate the intersection + final long durationBeforeSpan = Math.max(0, spanStartNanos - frame.startNanos); + final long delayBeforeSpan = + Math.max(0, durationBeforeSpan - frame.expectedDurationNanos); + final long delayWithinSpan = + Math.min(frame.delayNanos - delayBeforeSpan, spanDurationNanos); + + final long frameStart = Math.max(spanStartNanos, frame.startNanos); + final long frameEnd = Math.min(spanEndNanos, frame.endNanos); + final long frameDuration = frameEnd - frameStart; + frameMetrics.addFrame( + frameDuration, + delayWithinSpan, + SentryFrameMetricsCollector.isSlow(frameDuration, frame.expectedDurationNanos), + SentryFrameMetricsCollector.isFrozen(frameDuration)); + } + + frameDurationNanos = frame.expectedDurationNanos; + } + } + + int totalFrameCount = frameMetrics.getSlowFrozenFrameCount(); + + final long nextScheduledFrameNanos = frameMetricsCollector.getLastKnownFrameStartTimeNanos(); + // nextScheduledFrameNanos might be -1 if no frames have been scheduled for drawing yet + // e.g. can happen during early app start + if (nextScheduledFrameNanos != -1) { + totalFrameCount += + addPendingFrameDelay( + frameMetrics, frameDurationNanos, spanEndNanos, nextScheduledFrameNanos); + totalFrameCount += + interpolateFrameCount(frameMetrics, frameDurationNanos, spanDurationNanos); + } + final long frameDelayNanos = + frameMetrics.getSlowFrameDelayNanos() + frameMetrics.getFrozenFrameDelayNanos(); + final double frameDelayInSeconds = frameDelayNanos / 1e9d; + + span.setData(SpanDataConvention.FRAMES_TOTAL, totalFrameCount); + span.setData(SpanDataConvention.FRAMES_SLOW, frameMetrics.getSlowFrameCount()); + span.setData(SpanDataConvention.FRAMES_FROZEN, frameMetrics.getFrozenFrameCount()); + span.setData(SpanDataConvention.FRAMES_DELAY, frameDelayInSeconds); + + if (span instanceof ITransaction) { + span.setMeasurement(MeasurementValue.KEY_FRAMES_TOTAL, totalFrameCount); + span.setMeasurement(MeasurementValue.KEY_FRAMES_SLOW, frameMetrics.getSlowFrameCount()); + span.setMeasurement(MeasurementValue.KEY_FRAMES_FROZEN, frameMetrics.getFrozenFrameCount()); + span.setMeasurement(MeasurementValue.KEY_FRAMES_DELAY, frameDelayInSeconds); + } + } + } + + @Override + public void clear() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (listenerId != null) { + frameMetricsCollector.stopCollection(listenerId); + listenerId = null; + } + frames.clear(); + runningSpans.clear(); + } + } + + @Override + public void onFrameMetricCollected( + long frameStartNanos, + long frameEndNanos, + long durationNanos, + long delayNanos, + boolean isSlow, + boolean isFrozen, + float refreshRate) { + + // buffer is full, skip adding new frames for now + // once a span finishes, the buffer will trimmed + if (frames.size() > MAX_FRAMES_COUNT) { + return; + } + + final long expectedFrameDurationNanos = + (long) ((double) ONE_SECOND_NANOS / (double) refreshRate); + lastKnownFrameDurationNanos = expectedFrameDurationNanos; + + if (isSlow || isFrozen) { + frames.add( + new Frame( + frameStartNanos, + frameEndNanos, + durationNanos, + delayNanos, + isSlow, + isFrozen, + expectedFrameDurationNanos)); + } + } + + private static int interpolateFrameCount( + final @NotNull SentryFrameMetrics frameMetrics, + final long frameDurationNanos, + final long spanDurationNanos) { + // if there are no content changes on Android, also no new frame metrics are provided by the + // system + // in order to match the span duration with the total frame count, + // we simply interpolate the total number of frames based on the span duration + // this way the data is more sound and we also match the output of the cocoa SDK + final long frameMetricsDurationNanos = frameMetrics.getTotalDurationNanos(); + final long nonRenderedDuration = spanDurationNanos - frameMetricsDurationNanos; + if (nonRenderedDuration > 0) { + return (int) Math.ceil((double) nonRenderedDuration / frameDurationNanos); + } + return 0; + } + + private static int addPendingFrameDelay( + @NotNull final SentryFrameMetrics frameMetrics, + final long frameDurationNanos, + final long spanEndNanos, + final long nextScheduledFrameNanos) { + final long pendingDurationNanos = Math.max(0, spanEndNanos - nextScheduledFrameNanos); + final boolean isSlow = + SentryFrameMetricsCollector.isSlow(pendingDurationNanos, frameDurationNanos); + if (isSlow) { + // add a single slow/frozen frame + final boolean isFrozen = SentryFrameMetricsCollector.isFrozen(pendingDurationNanos); + final long pendingDelayNanos = Math.max(0, pendingDurationNanos - frameDurationNanos); + frameMetrics.addFrame(pendingDurationNanos, pendingDelayNanos, true, isFrozen); + return 1; + } + return 0; + } + + /** + * Because {@link SentryNanotimeDate#nanoTimestamp()} only gives you millisecond precision, but + * diff does ¯\_(ツ)_/¯ + * + * @param date the input date + * @return a non-unix timestamp in nano precision, similar to {@link System#nanoTime()}. + */ + private static long toNanoTime(final @NotNull SentryDate date) { + // SentryNanotimeDate nanotime is based on System.nanotime(), like EMPTY_NANO_TIME, + // thus diff will simply return the System.nanotime() value of date + if (date instanceof SentryNanotimeDate) { + return date.diff(EMPTY_NANO_TIME); + } + + // e.g. SentryLongDate is unix time based - upscaled to nanos, + // we need to project it back to System.nanotime() format + long nowUnixInNanos = DateUtils.millisToNanos(System.currentTimeMillis()); + long shiftInNanos = nowUnixInNanos - date.nanoTimestamp(); + return System.nanoTime() - shiftInNanos; + } + + private static class Frame implements Comparable { + private final long startNanos; + private final long endNanos; + private final long durationNanos; + private final long delayNanos; + private final boolean isSlow; + private final boolean isFrozen; + private final long expectedDurationNanos; + + Frame(final long timestampNanos) { + this(timestampNanos, timestampNanos, 0, 0, false, false, 0); + } + + Frame( + final long startNanos, + final long endNanos, + final long durationNanos, + final long delayNanos, + final boolean isSlow, + final boolean isFrozen, + final long expectedFrameDurationNanos) { + this.startNanos = startNanos; + this.endNanos = endNanos; + this.durationNanos = durationNanos; + this.delayNanos = delayNanos; + this.isSlow = isSlow; + this.isFrozen = isFrozen; + this.expectedDurationNanos = expectedFrameDurationNanos; + } + + @Override + public int compareTo(final @NotNull Frame o) { + return Long.compare(this.endNanos, o.endNanos); + } + } +} 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 26f8e2bfb5c..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 @@ -1,88 +1,112 @@ package io.sentry.android.core; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_DELETED; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_DISABLED; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_ENABLED; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE; import static android.content.Intent.ACTION_AIRPLANE_MODE_CHANGED; -import static android.content.Intent.ACTION_APP_ERROR; -import static android.content.Intent.ACTION_BATTERY_LOW; -import static android.content.Intent.ACTION_BATTERY_OKAY; -import static android.content.Intent.ACTION_BOOT_COMPLETED; -import static android.content.Intent.ACTION_BUG_REPORT; +import static android.content.Intent.ACTION_BATTERY_CHANGED; import static android.content.Intent.ACTION_CAMERA_BUTTON; import static android.content.Intent.ACTION_CONFIGURATION_CHANGED; import static android.content.Intent.ACTION_DATE_CHANGED; import static android.content.Intent.ACTION_DEVICE_STORAGE_LOW; import static android.content.Intent.ACTION_DEVICE_STORAGE_OK; import static android.content.Intent.ACTION_DOCK_EVENT; +import static android.content.Intent.ACTION_DREAMING_STARTED; +import static android.content.Intent.ACTION_DREAMING_STOPPED; import static android.content.Intent.ACTION_INPUT_METHOD_CHANGED; import static android.content.Intent.ACTION_LOCALE_CHANGED; -import static android.content.Intent.ACTION_MEDIA_BAD_REMOVAL; -import static android.content.Intent.ACTION_MEDIA_MOUNTED; -import static android.content.Intent.ACTION_MEDIA_UNMOUNTABLE; -import static android.content.Intent.ACTION_MEDIA_UNMOUNTED; -import static android.content.Intent.ACTION_POWER_CONNECTED; -import static android.content.Intent.ACTION_POWER_DISCONNECTED; -import static android.content.Intent.ACTION_REBOOT; import static android.content.Intent.ACTION_SCREEN_OFF; import static android.content.Intent.ACTION_SCREEN_ON; import static android.content.Intent.ACTION_SHUTDOWN; import static android.content.Intent.ACTION_TIMEZONE_CHANGED; import static android.content.Intent.ACTION_TIME_CHANGED; import static io.sentry.TypeCheckHint.ANDROID_INTENT; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Process; import io.sentry.Breadcrumb; import io.sentry.Hint; -import io.sentry.IHub; -import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; +import io.sentry.android.core.internal.util.Debouncer; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import io.sentry.util.StringUtils; import java.io.Closeable; import java.io.IOException; -import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; -public final class SystemEventsBreadcrumbsIntegration implements Integration, Closeable { +public final class SystemEventsBreadcrumbsIntegration + implements Integration, Closeable, AppState.AppStateListener { private final @NotNull Context context; - @TestOnly @Nullable SystemEventsBroadcastReceiver receiver; + @TestOnly @Nullable volatile SystemEventsBroadcastReceiver receiver; private @Nullable SentryAndroidOptions options; - private final @NotNull List actions; + private @Nullable IScopes scopes; + + private final @NotNull String[] actions; + private volatile boolean isClosed = false; + private volatile boolean isStopped = false; + private volatile IntentFilter filter = null; + private volatile HandlerThread handlerThread = null; + private final @NotNull AtomicBoolean isReceiverRegistered = new AtomicBoolean(false); + 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, getDefaultActions()); + 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 @Nullable Handler handler) { + this.context = ContextUtils.getApplicationContext(context); + this.actions = actions; + this.customHandler = handler; } public SystemEventsBreadcrumbsIntegration( final @NotNull Context context, final @NotNull List actions) { - this.context = Objects.requireNonNull(context, "Context is required"); - this.actions = Objects.requireNonNull(actions, "Actions list is required"); + this.context = ContextUtils.getApplicationContext(context); + this.actions = new String[actions.size()]; + actions.toArray(this.actions); } @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + Objects.requireNonNull(scopes, "Scopes are required"); this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); + this.scopes = scopes; this.options .getLogger() @@ -92,136 +116,335 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio this.options.isEnableSystemEventBreadcrumbs()); if (this.options.isEnableSystemEventBreadcrumbs()) { - receiver = new SystemEventsBroadcastReceiver(hub, this.options.getLogger()); - final IntentFilter filter = new IntentFilter(); - for (String item : actions) { - filter.addAction(item); + AppState.getInstance().addAppStateListener(this); + + if (ContextUtils.isForegroundImportance()) { + registerReceiver(this.scopes, this.options); } + } + } + + private void registerReceiver( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + + if (!options.isEnableSystemEventBreadcrumbs()) { + return; + } + + if (isClosed || isStopped || receiver != null) { + return; + } + + try { + options + .getExecutorService() + .submit( + () -> { + try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) { + if (isClosed || isStopped || receiver != null) { + return; + } + + receiver = new SystemEventsBroadcastReceiver(scopes, options); + if (filter == null) { + filter = new IntentFilter(); + for (String item : actions) { + filter.addAction(item); + } + } + if (customHandler == null && handlerThread == null) { + handlerThread = + new HandlerThread( + "SystemEventsReceiver", Process.THREAD_PRIORITY_BACKGROUND); + handlerThread.start(); + } + try { + // registerReceiver can throw SecurityException but it's not documented in the + // official docs + + // onReceive will be called on this handler thread + @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 + .getLogger() + .log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration installed."); + addIntegrationToSdkVersion("SystemEventsBreadcrumbs"); + } + } catch (Throwable e) { + options.setEnableSystemEventBreadcrumbs(false); + options + .getLogger() + .log( + SentryLevel.ERROR, + "Failed to initialize SystemEventsBreadcrumbsIntegration.", + e); + } + } + }); + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Failed to start SystemEventsBreadcrumbsIntegration on executor thread."); + } + } + + @SuppressWarnings("Convert2MethodRef") // older AGP versions do not support method references + private void scheduleUnregisterReceiver() { + if (options == null) { + return; + } + + try { + options.getExecutorService().submit(() -> unregisterReceiver(options)); + } catch (RejectedExecutionException e) { + unregisterReceiver(options); + } + } + + private void unregisterReceiver(final @NotNull SentryAndroidOptions options) { + final @Nullable SystemEventsBroadcastReceiver receiverRef; + try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) { + isStopped = true; + receiverRef = receiver; + receiver = null; + } + + if (receiverRef != null) { try { - // registerReceiver can throw SecurityException but it's not documented in the official docs - context.registerReceiver(receiver, filter); - this.options - .getLogger() - .log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion(); - } catch (Throwable e) { - this.options.setEnableSystemEventBreadcrumbs(false); - this.options + context.unregisterReceiver(receiverRef); + } catch (Throwable exception) { + options .getLogger() - .log(SentryLevel.ERROR, "Failed to initialize SystemEventsBreadcrumbsIntegration.", e); + .log( + SentryLevel.ERROR, exception, "Failed to unregister SystemEventsBroadcastReceiver"); } } } - @SuppressWarnings("deprecation") - private static @NotNull List getDefaultActions() { - final List actions = new ArrayList<>(); - actions.add(ACTION_APPWIDGET_DELETED); - actions.add(ACTION_APPWIDGET_DISABLED); - actions.add(ACTION_APPWIDGET_ENABLED); - actions.add("android.appwidget.action.APPWIDGET_HOST_RESTORED"); - actions.add("android.appwidget.action.APPWIDGET_RESTORED"); - actions.add(ACTION_APPWIDGET_UPDATE); - actions.add("android.appwidget.action.APPWIDGET_UPDATE_OPTIONS"); - actions.add(ACTION_POWER_CONNECTED); - actions.add(ACTION_POWER_DISCONNECTED); - actions.add(ACTION_SHUTDOWN); - actions.add(ACTION_AIRPLANE_MODE_CHANGED); - actions.add(ACTION_BATTERY_LOW); - actions.add(ACTION_BATTERY_OKAY); - actions.add(ACTION_BOOT_COMPLETED); - actions.add(ACTION_CAMERA_BUTTON); - actions.add(ACTION_CONFIGURATION_CHANGED); - actions.add("android.intent.action.CONTENT_CHANGED"); - actions.add(ACTION_DATE_CHANGED); - actions.add(ACTION_DEVICE_STORAGE_LOW); - actions.add(ACTION_DEVICE_STORAGE_OK); - actions.add(ACTION_DOCK_EVENT); - actions.add("android.intent.action.DREAMING_STARTED"); - actions.add("android.intent.action.DREAMING_STOPPED"); - actions.add(ACTION_INPUT_METHOD_CHANGED); - actions.add(ACTION_LOCALE_CHANGED); - actions.add(ACTION_REBOOT); - actions.add(ACTION_SCREEN_OFF); - actions.add(ACTION_SCREEN_ON); - actions.add(ACTION_TIMEZONE_CHANGED); - actions.add(ACTION_TIME_CHANGED); - actions.add("android.os.action.DEVICE_IDLE_MODE_CHANGED"); - actions.add("android.os.action.POWER_SAVE_MODE_CHANGED"); - // The user pressed the "Report" button in the crash/ANR dialog. - actions.add(ACTION_APP_ERROR); - // Show activity for reporting a bug. - actions.add(ACTION_BUG_REPORT); - - // consider if somebody mounted or ejected a sdcard - actions.add(ACTION_MEDIA_BAD_REMOVAL); - actions.add(ACTION_MEDIA_MOUNTED); - actions.add(ACTION_MEDIA_UNMOUNTABLE); - actions.add(ACTION_MEDIA_UNMOUNTED); + @Override + public void close() throws IOException { + try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) { + isClosed = true; + filter = null; + if (handlerThread != null) { + handlerThread.quit(); + } + handlerThread = null; + } + + AppState.getInstance().removeAppStateListener(this); + scheduleUnregisterReceiver(); + + if (options != null) { + options.getLogger().log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration removed."); + } + } + + public static @NotNull List getDefaultActions() { + return Arrays.asList(getDefaultActionsInternal()); + } + @SuppressWarnings("deprecation") + private static @NotNull String[] getDefaultActionsInternal() { + final String[] actions = new String[19]; + actions[0] = ACTION_SHUTDOWN; + actions[1] = ACTION_AIRPLANE_MODE_CHANGED; + actions[2] = ACTION_BATTERY_CHANGED; + actions[3] = ACTION_CAMERA_BUTTON; + actions[4] = ACTION_CONFIGURATION_CHANGED; + actions[5] = ACTION_DATE_CHANGED; + actions[6] = ACTION_DEVICE_STORAGE_LOW; + actions[7] = ACTION_DEVICE_STORAGE_OK; + actions[8] = ACTION_DOCK_EVENT; + actions[9] = ACTION_DREAMING_STARTED; + actions[10] = ACTION_DREAMING_STOPPED; + actions[11] = ACTION_INPUT_METHOD_CHANGED; + actions[12] = ACTION_LOCALE_CHANGED; + actions[13] = ACTION_SCREEN_OFF; + actions[14] = ACTION_SCREEN_ON; + actions[15] = ACTION_TIMEZONE_CHANGED; + actions[16] = ACTION_TIME_CHANGED; + actions[17] = "android.os.action.DEVICE_IDLE_MODE_CHANGED"; + actions[18] = "android.os.action.POWER_SAVE_MODE_CHANGED"; return actions; } @Override - public void close() throws IOException { - if (receiver != null) { - context.unregisterReceiver(receiver); - receiver = null; - - if (options != null) { - options.getLogger().log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration remove."); - } + public void onForeground() { + if (scopes == null || options == null) { + return; } + + isStopped = false; + + registerReceiver(scopes, options); } - static final class SystemEventsBroadcastReceiver extends BroadcastReceiver { + @Override + public void onBackground() { + scheduleUnregisterReceiver(); + } + + final class SystemEventsBroadcastReceiver extends BroadcastReceiver { - private final @NotNull IHub hub; - private final @NotNull ILogger logger; + private static final long DEBOUNCE_WAIT_TIME_MS = 60 * 1000; + private final @NotNull IScopes scopes; + private final @NotNull SentryAndroidOptions options; + private final @NotNull Debouncer batteryChangedDebouncer = + new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS, 0); - SystemEventsBroadcastReceiver(final @NotNull IHub hub, final @NotNull ILogger logger) { - this.hub = hub; - this.logger = logger; + SystemEventsBroadcastReceiver( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + this.scopes = scopes; + this.options = options; } @Override - public void onReceive(Context context, Intent intent) { - final Breadcrumb breadcrumb = new Breadcrumb(); + public void onReceive(final Context context, final @NotNull Intent intent) { + final @Nullable String action = intent.getAction(); + final boolean isBatteryChanged = ACTION_BATTERY_CHANGED.equals(action); + + @Nullable BatteryState batteryState = null; + if (isBatteryChanged) { + if (batteryChangedDebouncer.checkForDebounce()) { + // aligning with iOS which only captures battery status changes every minute at maximum + return; + } + + // For battery changes, check if the actual values have changed + final @Nullable Float batteryLevel = DeviceInfoUtil.getBatteryLevel(intent, options); + final @Nullable Integer currentBatteryLevel = + batteryLevel != null ? batteryLevel.intValue() : null; + final @Nullable Boolean currentChargingState = DeviceInfoUtil.isCharging(intent, options); + batteryState = new BatteryState(currentBatteryLevel, currentChargingState); + + // Only create breadcrumb if battery state has actually changed + if (batteryState.equals(previousBatteryState)) { + return; + } + + previousBatteryState = batteryState; + } + + final BatteryState state = batteryState; + final long now = System.currentTimeMillis(); + final Breadcrumb breadcrumb = createBreadcrumb(now, intent, action, state); + final Hint hint = new Hint(); + hint.set(ANDROID_INTENT, intent); + scopes.addBreadcrumb(breadcrumb, hint); + } + + // in theory this should be ThreadLocal, but we won't have more than 1 thread accessing it, + // so we save some memory here and CPU cycles. 64 is because all intent actions we subscribe for + // are less than 64 chars. We also don't care about encoding as those are always UTF. + private final char[] buf = new char[64]; + + @TestOnly + @Nullable + String getStringAfterDotFast(final @Nullable String str) { + if (str == null) { + return null; + } + + final int len = str.length(); + int bufIndex = buf.length; + + // the idea here is to iterate from the end of the string and copy the characters to a + // pre-allocated buffer in reverse order. When we find a dot, we create a new string + // from the buffer. This way we use a fixed size buffer and do a bare minimum of iterations. + for (int i = len - 1; i >= 0; i--) { + final char c = str.charAt(i); + if (c == '.') { + return new String(buf, bufIndex, buf.length - bufIndex); + } + if (bufIndex == 0) { + // Overflow — fallback to safe version + return StringUtils.getStringAfterDot(str); + } + buf[--bufIndex] = c; + } + + // No dot found — return original + return str; + } + + private @NotNull Breadcrumb createBreadcrumb( + final long timeMs, + final @NotNull Intent intent, + final @Nullable String action, + final @Nullable BatteryState batteryState) { + final Breadcrumb breadcrumb = new Breadcrumb(timeMs); breadcrumb.setType("system"); breadcrumb.setCategory("device.event"); - final String action = intent.getAction(); - String shortAction = StringUtils.getStringAfterDot(action); + final String shortAction = getStringAfterDotFast(action); if (shortAction != null) { breadcrumb.setData("action", shortAction); } - final Bundle extras = intent.getExtras(); - final Map newExtras = new HashMap<>(); - if (extras != null && !extras.isEmpty()) { - for (String item : extras.keySet()) { - try { - @SuppressWarnings("deprecation") - Object value = extras.get(item); - if (value != null) { - newExtras.put(item, value.toString()); + if (batteryState != null) { + if (batteryState.level != null) { + breadcrumb.setData("level", batteryState.level); + } + if (batteryState.charging != null) { + breadcrumb.setData("charging", batteryState.charging); + } + } else if (options.isEnableSystemEventBreadcrumbsExtras()) { + final Bundle extras = intent.getExtras(); + if (extras != null && !extras.isEmpty()) { + final Map newExtras = new HashMap<>(extras.size()); + for (String item : extras.keySet()) { + try { + @SuppressWarnings("deprecation") + Object value = extras.get(item); + if (value != null) { + newExtras.put(item, value.toString()); + } + } catch (Throwable exception) { + options + .getLogger() + .log( + SentryLevel.ERROR, + exception, + "%s key of the %s action threw an error.", + item, + action); } - } catch (Throwable exception) { - logger.log( - SentryLevel.ERROR, - exception, - "%s key of the %s action threw an error.", - item, - action); } + breadcrumb.setData("extras", newExtras); } - breadcrumb.setData("extras", newExtras); } breadcrumb.setLevel(SentryLevel.INFO); + return breadcrumb; + } + } - final Hint hint = new Hint(); - hint.set(ANDROID_INTENT, intent); + static final class BatteryState { + private final @Nullable Integer level; + private final @Nullable Boolean charging; + + BatteryState(final @Nullable Integer level, final @Nullable Boolean charging) { + this.level = level; + this.charging = charging; + } - hub.addBreadcrumb(breadcrumb, hint); + @Override + public boolean equals(final @Nullable Object other) { + if (!(other instanceof BatteryState)) return false; + BatteryState that = (BatteryState) other; + return Objects.equals(level, that.level) && Objects.equals(charging, that.charging); + } + + @Override + public int hashCode() { + return Objects.hash(level, charging); } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java deleted file mode 100644 index e4577b43ecc..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java +++ /dev/null @@ -1,119 +0,0 @@ -package io.sentry.android.core; - -import static android.content.Context.SENSOR_SERVICE; -import static io.sentry.TypeCheckHint.ANDROID_SENSOR_EVENT; - -import android.content.Context; -import android.hardware.Sensor; -import android.hardware.SensorEvent; -import android.hardware.SensorEventListener; -import android.hardware.SensorManager; -import io.sentry.Breadcrumb; -import io.sentry.Hint; -import io.sentry.IHub; -import io.sentry.Integration; -import io.sentry.SentryLevel; -import io.sentry.SentryOptions; -import io.sentry.util.Objects; -import java.io.Closeable; -import java.io.IOException; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; - -public final class TempSensorBreadcrumbsIntegration - implements Integration, Closeable, SensorEventListener { - - private final @NotNull Context context; - private @Nullable IHub hub; - private @Nullable SentryAndroidOptions options; - - @TestOnly @Nullable SensorManager sensorManager; - - public TempSensorBreadcrumbsIntegration(final @NotNull Context context) { - this.context = Objects.requireNonNull(context, "Context is required"); - } - - @SuppressWarnings("deprecation") - @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - this.hub = Objects.requireNonNull(hub, "Hub is required"); - this.options = - Objects.requireNonNull( - (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, - "SentryAndroidOptions is required"); - - this.options - .getLogger() - .log( - SentryLevel.DEBUG, - "enableSystemEventsBreadcrumbs enabled: %s", - this.options.isEnableSystemEventBreadcrumbs()); - - if (this.options.isEnableSystemEventBreadcrumbs()) { - try { - sensorManager = (SensorManager) context.getSystemService(SENSOR_SERVICE); - if (sensorManager != null) { - final Sensor defaultSensor = - sensorManager.getDefaultSensor(Sensor.TYPE_AMBIENT_TEMPERATURE); - if (defaultSensor != null) { - sensorManager.registerListener(this, defaultSensor, SensorManager.SENSOR_DELAY_NORMAL); - - options - .getLogger() - .log(SentryLevel.DEBUG, "TempSensorBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion(); - } else { - this.options - .getLogger() - .log(SentryLevel.INFO, "TYPE_AMBIENT_TEMPERATURE is not available."); - } - } else { - this.options.getLogger().log(SentryLevel.INFO, "SENSOR_SERVICE is not available."); - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, e, "Failed to init. the SENSOR_SERVICE."); - } - } - } - - @Override - public void close() throws IOException { - if (sensorManager != null) { - sensorManager.unregisterListener(this); - sensorManager = null; - - if (options != null) { - options.getLogger().log(SentryLevel.DEBUG, "TempSensorBreadcrumbsIntegration removed."); - } - } - } - - @Override - public void onSensorChanged(final @NotNull SensorEvent event) { - final float[] values = event.values; - // return if data is not available or zero'ed - if (values == null || values.length == 0 || values[0] == 0f) { - return; - } - - if (hub != null) { - final Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setType("system"); - breadcrumb.setCategory("device.event"); - breadcrumb.setData("action", "TYPE_AMBIENT_TEMPERATURE"); - breadcrumb.setData("accuracy", event.accuracy); - breadcrumb.setData("timestamp", event.timestamp); - breadcrumb.setLevel(SentryLevel.INFO); - breadcrumb.setData("degree", event.values[0]); // Celsius - - final Hint hint = new Hint(); - hint.set(ANDROID_SENSOR_EVENT, event); - - hub.addBreadcrumb(breadcrumb, hint); - } - } - - @Override - public void onAccuracyChanged(Sensor sensor, int accuracy) {} -} 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 dda56272729..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 @@ -1,10 +1,14 @@ package io.sentry.android.core; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + import android.app.Activity; import android.app.Application; import android.os.Bundle; import android.view.Window; -import io.sentry.IHub; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; +import io.sentry.IScopes; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; @@ -14,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; @@ -21,16 +28,26 @@ public final class UserInteractionIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { private final @NotNull Application application; - private @Nullable IHub hub; + 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 LoadClass classLoader) { + 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); } private void startTracking(final @NotNull Activity activity) { @@ -42,15 +59,27 @@ private void startTracking(final @NotNull Activity activity) { return; } - if (hub != null && options != null) { + 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(); } final SentryGestureListener gestureListener = - new SentryGestureListener(activity, hub, options); - window.setCallback(new SentryWindowCallback(delegate, activity, gestureListener, options)); + new SentryGestureListener(activity, scopes, options); + final SentryWindowCallback wrapper = + new SentryWindowCallback(delegate, activity, gestureListener, options); + window.setCallback(wrapper); + synchronized (wrappedWindowsLock) { + wrappedWindows.put(window, new WeakReference<>(wrapper)); + } } } @@ -62,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(); @@ -71,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(); } } @@ -100,13 +149,13 @@ public void onActivitySaveInstanceState(@NotNull Activity activity, @NotNull Bun public void onActivityDestroyed(@NotNull Activity activity) {} @Override - public void register(@NotNull IHub hub, @NotNull SentryOptions options) { + public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); - this.hub = Objects.requireNonNull(hub, "Hub is required"); + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); final boolean integrationEnabled = this.options.isEnableUserInteractionBreadcrumbs() @@ -116,16 +165,19 @@ public void register(@NotNull IHub hub, @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(); - } else { - options - .getLogger() - .log( - SentryLevel.INFO, - "androidx.core is not available, UserInteractionIntegration won't be installed"); + 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); + } + } } } } @@ -134,6 +186,21 @@ public void register(@NotNull IHub hub, @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 b667e048884..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 @@ -1,5 +1,7 @@ package io.sentry.android.core; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + import android.app.Activity; import android.view.View; import android.view.ViewGroup; @@ -9,20 +11,21 @@ import io.sentry.Hint; import io.sentry.ILogger; import io.sentry.ISerializer; -import io.sentry.IntegrationName; import io.sentry.SentryEvent; import io.sentry.SentryLevel; import io.sentry.android.core.internal.gestures.ViewUtils; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; -import io.sentry.android.core.internal.util.AndroidMainThreadChecker; +import io.sentry.android.core.internal.util.AndroidThreadChecker; +import io.sentry.android.core.internal.util.ClassUtil; import io.sentry.android.core.internal.util.Debouncer; import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; +import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.ViewHierarchy; import io.sentry.protocol.ViewHierarchyNode; import io.sentry.util.HintUtils; import io.sentry.util.JsonSerializationUtils; import io.sentry.util.Objects; -import io.sentry.util.thread.IMainThreadChecker; +import io.sentry.util.thread.IThreadChecker; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -34,23 +37,37 @@ /** ViewHierarchyEventProcessor responsible for taking a snapshot of the current view hierarchy. */ @ApiStatus.Internal -public final class ViewHierarchyEventProcessor implements EventProcessor, IntegrationName { +public final class ViewHierarchyEventProcessor implements EventProcessor { private final @NotNull SentryAndroidOptions options; private final @NotNull Debouncer debouncer; private static final long CAPTURE_TIMEOUT_MS = 1000; private static final long DEBOUNCE_WAIT_TIME_MS = 2000; + private static final int DEBOUNCE_MAX_EXECUTIONS = 3; public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options) { this.options = Objects.requireNonNull(options, "SentryAndroidOptions is required"); - this.debouncer = new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS); + this.debouncer = + new Debouncer( + AndroidCurrentDateProvider.getInstance(), + DEBOUNCE_WAIT_TIME_MS, + DEBOUNCE_MAX_EXECUTIONS); if (options.isAttachViewHierarchy()) { - addIntegrationToSdkVersion(); + addIntegrationToSdkVersion("ViewHierarchy"); } } + @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 @NotNull SentryEvent process(final @NotNull SentryEvent event, final @NotNull Hint hint) { if (!event.isErrored()) { @@ -84,7 +101,7 @@ public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options) snapshotViewHierarchy( activity, options.getViewHierarchyExporters(), - options.getMainThreadChecker(), + options.getThreadChecker(), options.getLogger()); if (viewHierarchy != null) { @@ -96,13 +113,13 @@ public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options) public static byte[] snapshotViewHierarchyAsData( @Nullable Activity activity, - @NotNull IMainThreadChecker mainThreadChecker, + @NotNull IThreadChecker threadChecker, @NotNull ISerializer serializer, @NotNull ILogger logger) { @Nullable ViewHierarchy viewHierarchy = - snapshotViewHierarchy(activity, new ArrayList<>(0), mainThreadChecker, logger); + snapshotViewHierarchy(activity, new ArrayList<>(0), threadChecker, logger); if (viewHierarchy == null) { logger.log(SentryLevel.ERROR, "Could not get ViewHierarchy."); @@ -127,14 +144,14 @@ public static byte[] snapshotViewHierarchyAsData( public static ViewHierarchy snapshotViewHierarchy( final @Nullable Activity activity, final @NotNull ILogger logger) { return snapshotViewHierarchy( - activity, new ArrayList<>(0), AndroidMainThreadChecker.getInstance(), logger); + activity, new ArrayList<>(0), AndroidThreadChecker.getInstance(), logger); } @Nullable public static ViewHierarchy snapshotViewHierarchy( final @Nullable Activity activity, final @NotNull List exporters, - final @NotNull IMainThreadChecker mainThreadChecker, + final @NotNull IThreadChecker threadChecker, final @NotNull ILogger logger) { if (activity == null) { @@ -155,7 +172,7 @@ public static ViewHierarchy snapshotViewHierarchy( } try { - if (mainThreadChecker.isMainThread()) { + if (threadChecker.isMainThread()) { return snapshotViewHierarchy(decorView, exporters); } else { final CountDownLatch latch = new CountDownLatch(1); @@ -235,15 +252,14 @@ private static void addChildren( private static ViewHierarchyNode viewToNode(@NotNull final View view) { @NotNull final ViewHierarchyNode node = new ViewHierarchyNode(); - @Nullable String className = view.getClass().getCanonicalName(); - if (className == null) { - className = view.getClass().getSimpleName(); - } + @Nullable String className = ClassUtil.getClassName(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 } @@ -270,4 +286,9 @@ private static ViewHierarchyNode viewToNode(@NotNull final View view) { return node; } + + @Override + public @Nullable Long getOrder() { + return 11000L; + } } 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 bd3e0809b5d..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 @@ -9,17 +9,22 @@ import io.sentry.SentryOptions; import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.android.core.AnrV2Integration; -import io.sentry.android.core.AppStartState; 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; import io.sentry.cache.EnvelopeCache; import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.FileUtils; 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; @@ -29,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; @@ -46,16 +52,27 @@ public AndroidEnvelopeCache(final @NotNull SentryAndroidOptions options) { this.currentDateProvider = currentDateProvider; } + @SuppressWarnings("deprecation") @Override public void store(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { - super.store(envelope, hint); + storeInternalAndroid(envelope, hint); + } + + @Override + public boolean storeEnvelope(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { + return storeInternalAndroid(envelope, hint); + } + + private boolean storeInternalAndroid(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { + final boolean didStore = super.storeEnvelope(envelope, hint); final SentryAndroidOptions options = (SentryAndroidOptions) this.options; + final TimeSpan sdkInitTimeSpan = AppStartMetrics.getInstance().getSdkInitTimeSpan(); - final Long appStartTime = AppStartState.getInstance().getAppStartMillis(); if (HintUtils.hasType(hint, UncaughtExceptionHandlerIntegration.UncaughtExceptionHint.class) - && appStartTime != null) { - long timeSinceSdkInit = currentDateProvider.getCurrentTimeMillis() - appStartTime; + && sdkInitTimeSpan.hasStarted()) { + long timeSinceSdkInit = + currentDateProvider.getCurrentTimeMillis() - sdkInitTimeSpan.getStartUptimeMs(); if (timeSinceSdkInit <= options.getStartupCrashDurationThresholdMillis()) { options .getLogger() @@ -67,25 +84,16 @@ public void store(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { } } - 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() { @@ -98,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) { @@ -137,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/debugmeta/AssetsDebugMetaLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java index bf600bdb897..d0dd4981a3c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java @@ -6,11 +6,14 @@ import android.content.res.AssetManager; import io.sentry.ILogger; import io.sentry.SentryLevel; +import io.sentry.android.core.ContextUtils; import io.sentry.internal.debugmeta.IDebugMetaLoader; import java.io.BufferedInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.util.Collections; +import java.util.List; import java.util.Properties; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -22,12 +25,12 @@ public final class AssetsDebugMetaLoader implements IDebugMetaLoader { private final @NotNull ILogger logger; public AssetsDebugMetaLoader(final @NotNull Context context, final @NotNull ILogger logger) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); this.logger = logger; } @Override - public @Nullable Properties loadDebugMeta() { + public @Nullable List loadDebugMeta() { final AssetManager assets = context.getAssets(); // one may have thousands of asset files and looking up this list might slow down the SDK init. // quite a bit, for this reason, we try to open the file directly and take care of errors @@ -36,9 +39,9 @@ public AssetsDebugMetaLoader(final @NotNull Context context, final @NotNull ILog new BufferedInputStream(assets.open(DEBUG_META_PROPERTIES_FILENAME))) { final Properties properties = new Properties(); properties.load(is); - return properties; + return Collections.singletonList(properties); } catch (FileNotFoundException e) { - logger.log(SentryLevel.INFO, e, "%s file was not found.", DEBUG_META_PROPERTIES_FILENAME); + logger.log(SentryLevel.INFO, "%s file was not found.", DEBUG_META_PROPERTIES_FILENAME); } catch (IOException e) { logger.log(SentryLevel.ERROR, "Error getting Proguard UUIDs.", e); } catch (RuntimeException e) { 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 224d60491c6..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,12 +1,13 @@ package io.sentry.android.core.internal.gestures; -import android.content.res.Resources; import android.view.View; import android.widget.AbsListView; import android.widget.ScrollView; import androidx.core.view.ScrollingView; +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; @@ -14,53 +15,38 @@ @ApiStatus.Internal public final class AndroidViewGestureTargetLocator implements GestureTargetLocator { - private final boolean isAndroidXAvailable; - private final int[] coordinates = new int[2]; + private static final String ORIGIN = "old_view_system"; - public AndroidViewGestureTargetLocator(final boolean isAndroidXAvailable) { + private final @NotNull LazyEvaluator isAndroidXAvailable; + + public AndroidViewGestureTargetLocator( + final @NotNull LazyEvaluator isAndroidXAvailable) { this.isAndroidXAvailable = isAndroidXAvailable; } @Override public @Nullable UiElement locate( - @NotNull Object root, float x, float y, UiElement.Type targetType) { + @Nullable Object root, float x, float y, UiElement.Type targetType) { if (!(root instanceof View)) { return null; } final View view = (View) root; - if (touchWithinBounds(view, x, y)) { - if (targetType == UiElement.Type.CLICKABLE && isViewTappable(view)) { - return createUiElement(view); - } else if (targetType == UiElement.Type.SCROLLABLE - && isViewScrollable(view, isAndroidXAvailable)) { - return createUiElement(view); - } + if (targetType == UiElement.Type.CLICKABLE && isViewTappable(view)) { + return createUiElement(view); + } else if (targetType == UiElement.Type.SCROLLABLE + && 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 = targetView.getClass().getCanonicalName(); - if (className == null) { - className = targetView.getClass().getSimpleName(); - } - return new UiElement(targetView, className, resourceName, null); - } catch (Resources.NotFoundException ignored) { + final @Nullable String resourceName = ViewUtils.getResourceIdOrNull(targetView); + if (resourceName == null) { return null; } - } - - private boolean touchWithinBounds(final @NotNull View view, final float x, final float y) { - view.getLocationOnScreen(coordinates); - int vx = coordinates[0]; - int vy = coordinates[1]; - - int w = view.getWidth(); - int h = view.getHeight(); - - return !(x < vx || x > vx + w || y < vy || y > vy + h); + @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 3dda6183cde..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 @@ -10,9 +10,9 @@ import android.view.Window; import io.sentry.Breadcrumb; import io.sentry.Hint; -import io.sentry.IHub; +import io.sentry.IScope; +import io.sentry.IScopes; import io.sentry.ITransaction; -import io.sentry.Scope; import io.sentry.SentryLevel; import io.sentry.SpanStatus; import io.sentry.TransactionContext; @@ -32,24 +32,32 @@ @ApiStatus.Internal public final class SentryGestureListener implements GestureDetector.OnGestureListener { + private enum GestureType { + Click, + Scroll, + Swipe, + Unknown + } + static final String UI_ACTION = "ui.action"; + private static final String TRACE_ORIGIN = "auto.ui.gesture_listener"; private final @NotNull WeakReference activityRef; - private final @NotNull IHub hub; + private final @NotNull IScopes scopes; private final @NotNull SentryAndroidOptions options; private @Nullable UiElement activeUiElement = null; private @Nullable ITransaction activeTransaction = null; - private @Nullable String activeEventType = null; + private @NotNull GestureType activeEventType = GestureType.Unknown; private final ScrollState scrollState = new ScrollState(); public SentryGestureListener( final @NotNull Activity currentActivity, - final @NotNull IHub hub, + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { this.activityRef = new WeakReference<>(currentActivity); - this.hub = hub; + this.scopes = scopes; this.options = options; } @@ -60,7 +68,7 @@ public void onUp(final @NotNull MotionEvent motionEvent) { return; } - if (scrollState.type == null) { + if (scrollState.type == GestureType.Unknown) { options .getLogger() .log(SentryLevel.DEBUG, "Unable to define scroll type. No breadcrumb captured."); @@ -106,8 +114,8 @@ public boolean onSingleTapUp(final @Nullable MotionEvent motionEvent) { return false; } - addBreadcrumb(target, "click", Collections.emptyMap(), motionEvent); - startTracing(target, "click"); + addBreadcrumb(target, GestureType.Click, Collections.emptyMap(), motionEvent); + startTracing(target, GestureType.Click); return false; } @@ -122,7 +130,7 @@ public boolean onScroll( return false; } - if (scrollState.type == null) { + if (scrollState.type == GestureType.Unknown) { final @Nullable UiElement target = ViewUtils.findTarget( options, decorView, firstEvent.getX(), firstEvent.getY(), UiElement.Type.SCROLLABLE); @@ -131,6 +139,7 @@ public boolean onScroll( options .getLogger() .log(SentryLevel.DEBUG, "Unable to find scroll target. No breadcrumb captured."); + scrollState.type = GestureType.Scroll; return false; } else { options @@ -139,7 +148,7 @@ public boolean onScroll( } scrollState.setTarget(target); - scrollState.type = "scroll"; + scrollState.type = GestureType.Scroll; } return false; } @@ -150,7 +159,7 @@ public boolean onFling( final @Nullable MotionEvent motionEvent1, final float v, final float v1) { - scrollState.type = "swipe"; + scrollState.type = GestureType.Swipe; return false; } @@ -163,7 +172,7 @@ public void onLongPress(MotionEvent motionEvent) {} // region utils private void addBreadcrumb( final @NotNull UiElement target, - final @NotNull String eventType, + final @NotNull GestureType eventType, final @NotNull Map additionalData, final @NotNull MotionEvent motionEvent) { @@ -171,25 +180,32 @@ private void addBreadcrumb( return; } + final String type = getGestureType(eventType); + final Hint hint = new Hint(); hint.set(ANDROID_MOTION_EVENT, motionEvent); hint.set(ANDROID_VIEW, target.getView()); - hub.addBreadcrumb( + scopes.addBreadcrumb( Breadcrumb.userInteraction( - eventType, - target.getResourceName(), - target.getClassName(), - target.getTag(), - additionalData), + type, target.getResourceName(), target.getClassName(), target.getTag(), additionalData), hint); } - private void startTracing(final @NotNull UiElement target, final @NotNull String eventType) { - final UiElement uiElement = activeUiElement; + private void startTracing(final @NotNull UiElement target, final @NotNull GestureType eventType) { + + final boolean isNewGestureSameAsActive = + (eventType == activeEventType && target.equals(activeUiElement)); + final boolean isClickGesture = eventType == GestureType.Click; + // we always want to start new transaction/traces for clicks, for swipe/scroll only if the + // target changed + final boolean isNewInteraction = isClickGesture || !isNewGestureSameAsActive; + if (!(options.isTracingEnabled() && options.isEnableUserInteractionTracing())) { - if (!(target.equals(uiElement) && eventType.equals(activeEventType))) { - TracingUtils.startNewTrace(hub); + if (isNewInteraction) { + if (options.isEnableAutoTraceIdGeneration()) { + TracingUtils.startNewTrace(scopes); + } activeUiElement = target; activeEventType = eventType; } @@ -205,9 +221,7 @@ private void startTracing(final @NotNull UiElement target, final @NotNull String final @Nullable String viewIdentifier = target.getIdentifier(); if (activeTransaction != null) { - if (target.equals(uiElement) - && eventType.equals(activeEventType) - && !activeTransaction.isFinished()) { + if (!isNewInteraction && !activeTransaction.isFinished()) { options .getLogger() .log( @@ -230,20 +244,43 @@ private void startTracing(final @NotNull UiElement target, final @NotNull String } } + // 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 + "." + eventType; + final String op = UI_ACTION + "." + getGestureType(eventType); final TransactionOptions transactionOptions = new TransactionOptions(); transactionOptions.setWaitForChildren(true); + + // Set deadline timeout based on configured option + final long deadlineTimeoutMillis = options.getDeadlineTimeout(); + // No deadline when zero or negative value is set + transactionOptions.setDeadlineTimeout( + deadlineTimeoutMillis <= 0 ? null : deadlineTimeoutMillis); + transactionOptions.setIdleTimeout(options.getIdleTimeout()); transactionOptions.setTrimEnd(true); + transactionOptions.setOrigin(TRACE_ORIGIN + "." + target.getOrigin()); final ITransaction transaction = - hub.startTransaction( + scopes.startTransaction( new TransactionContext(name, TransactionNameSource.COMPONENT, op), transactionOptions); - hub.configureScope( + scopes.configureScope( scope -> { applyScope(scope, transaction); }); @@ -255,21 +292,29 @@ private void startTracing(final @NotNull UiElement target, final @NotNull String void stopTracing(final @NotNull SpanStatus status) { if (activeTransaction != null) { - activeTransaction.finish(status); + final SpanStatus currentStatus = activeTransaction.getStatus(); + // status might be set by other integrations, let's not overwrite it + if (currentStatus == null) { + activeTransaction.finish(status); + } else { + activeTransaction.finish(); + } } - hub.configureScope( + scopes.configureScope( scope -> { + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef clearScope(scope); }); activeTransaction = null; if (activeUiElement != null) { activeUiElement = null; } - activeEventType = null; + activeEventType = GestureType.Unknown; } @VisibleForTesting - void clearScope(final @NotNull Scope scope) { + void clearScope(final @NotNull IScope scope) { scope.withTransaction( transaction -> { if (transaction == activeTransaction) { @@ -279,7 +324,7 @@ void clearScope(final @NotNull Scope scope) { } @VisibleForTesting - void applyScope(final @NotNull Scope scope, final @NotNull ITransaction transaction) { + void applyScope(final @NotNull IScope scope, final @NotNull ITransaction transaction) { scope.withTransaction( scopeTransaction -> { // we'd not like to overwrite existent transactions bound to the Scope manually @@ -317,7 +362,7 @@ void applyScope(final @NotNull Scope scope, final @NotNull ITransaction transact return null; } - final View decorView = window.getDecorView(); + final View decorView = window.peekDecorView(); if (decorView == null) { options .getLogger() @@ -326,11 +371,33 @@ void applyScope(final @NotNull Scope scope, final @NotNull ITransaction transact } return decorView; } + + @NotNull + private static String getGestureType(final @NotNull GestureType eventType) { + final @NotNull String type; + switch (eventType) { + case Click: + type = "click"; + break; + case Scroll: + type = "scroll"; + break; + case Swipe: + type = "swipe"; + break; + default: + case Unknown: + type = "unknown"; + break; + } + return type; + } + // endregion // region scroll logic private static final class ScrollState { - private @Nullable String type = null; + private @NotNull GestureType type = GestureType.Unknown; private @Nullable UiElement target; private float startX = 0f; private float startY = 0f; @@ -367,7 +434,7 @@ private void setTarget(final @NotNull UiElement target) { private void reset() { target = null; - type = null; + type = GestureType.Unknown; startX = 0f; startY = 0f; } 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 473a59b5b00..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 @@ -3,7 +3,6 @@ import android.content.Context; 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; @@ -16,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, @@ -27,7 +30,7 @@ public SentryWindowCallback( final @Nullable SentryOptions options) { this( delegate, - new GestureDetectorCompat(context, gestureListener), + new SentryGestureDetector(context, gestureListener), gestureListener, options, new MotionEventObtainer() {}); @@ -35,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) { @@ -65,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) { @@ -73,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 6e7dab2ef5a..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,14 +1,15 @@ 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 io.sentry.util.Objects; -import java.util.LinkedList; +import java.util.ArrayDeque; +import java.util.List; import java.util.Queue; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -17,13 +18,62 @@ @ApiStatus.Internal public final class ViewUtils { + /** + * 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 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 localX, final float localY) { + if (view == null) { + return false; + } + + final int w = view.getWidth(); + final int h = view.getHeight(); + + return !(localX < 0 || localX > w || localY < 0 || localY > 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); + } + /** * Finds a target view, that has been selected/clicked by the given coordinates x and y and the * 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} @@ -35,26 +85,43 @@ public final class ViewUtils { final float y, final UiElement.Type targetType) { - final Queue queue = new LinkedList<>(); - queue.add(decorView); + final List locators = options.getGestureTargetLocators(); + 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 = Objects.requireNonNull(queue.poll(), "view is required"); + while (!queue.isEmpty()) { + final ViewWithLocation current = queue.poll(); + final View view = current.view; + + 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)); + } } } - for (GestureTargetLocator locator : options.getGestureTargetLocators()) { + // 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); if (newTarget != null) { if (targetType == UiElement.Type.CLICKABLE) { target = newTarget; - } else { + } else if (targetType == UiElement.Type.SCROLLABLE) { return newTarget; } } @@ -63,6 +130,18 @@ public final class ViewUtils { 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. @@ -71,32 +150,37 @@ public final class ViewUtils { * @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/gestures/WindowCallbackAdapter.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/WindowCallbackAdapter.java index a6568ad057e..c6a3e627115 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/WindowCallbackAdapter.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/WindowCallbackAdapter.java @@ -16,11 +16,11 @@ import org.jetbrains.annotations.Nullable; @Open -class WindowCallbackAdapter implements Window.Callback { +public class WindowCallbackAdapter implements Window.Callback { private final @NotNull Window.Callback delegate; - WindowCallbackAdapter(final Window.@NotNull Callback delegate) { + public WindowCallbackAdapter(final Window.@NotNull Callback delegate) { this.delegate = delegate; } 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 c381bacf0a6..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,9 @@ 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; import java.io.IOException; @@ -17,17 +18,28 @@ public final class AssetsModulesLoader extends ModulesLoader { private final @NotNull Context context; - public AssetsModulesLoader(final @NotNull Context context, final @NotNull ILogger logger) { - super(logger); - this.context = context; + 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 + try { + options + .getExecutorService() + .submit( + () -> { + getOrLoadModules(); + }); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "AssetsModulesLoader submit failed", e); + } } @Override protected Map loadModules() { final Map modules = new TreeMap<>(); - try { - final InputStream stream = context.getAssets().open(EXTERNAL_MODULES_FILENAME); + try (final InputStream stream = context.getAssets().open(EXTERNAL_MODULES_FILENAME)) { return parseStream(stream); } catch (FileNotFoundException e) { logger.log(SentryLevel.INFO, "%s file was not found.", EXTERNAL_MODULES_FILENAME); 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 da90653092e..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,6 +22,9 @@ 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; @@ -38,76 +41,148 @@ public class ThreadDumpParser { private static final Pattern BEGIN_MANAGED_THREAD_RE = Pattern.compile("\"(.*)\" (.*) ?prio=(\\d+)\\s+tid=(\\d+)\\s*(.*)"); + + 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 + // 1:index + // 2:pc + // 3:mapinfo + // 4:filename + // 5:mapoffset + // 6:function + // 7:fnoffset + // 8:buildid private static final Pattern NATIVE_RE = Pattern.compile( - " (?:native: )?#\\d+ \\S+ [0-9a-fA-F]+\\s+(.*?)\\s+\\((.*)\\+(\\d+)\\)(?: \\(.*\\))?"); - private static final Pattern NATIVE_NO_LOC_RE = - Pattern.compile( - " (?:native: )?#\\d+ \\S+ [0-9a-fA-F]+\\s+(.*)\\s*\\(?(.*)\\)?(?: \\(.*\\))?"); + // " native: #12 pc 0xabcd1234" + " *(?:native: )?#(\\d+) \\S+ ([0-9a-fA-F]+)" + // The map info includes a filename and an optional offset into the file + + ("\\s+(" + // "/path/to/file.ext", + + "(.*?)" + // optional " (deleted)" suffix (deleted files) needed here to bias regex + // correctly + + "(?:\\s+\\(deleted\\))?" + // " (offset 0xabcd1234)", if the mapping is not into the beginning of the file + + "(?:\\s+\\(offset (.*?)\\))?" + + ")") + // Optional function + + ("(?:\\s+\\((?:" + + "\\?\\?\\?" // " (???) marks a missing function, so don't capture it in a group + + "|(.*?)(?:\\+(\\d+))?" // " (func+1234)", offset is + // optional + + ")\\))?") + // Optional " (BuildId: abcd1234abcd1234abcd1234abcd1234abcd1234)" + + "(?:\\s+\\(BuildId: (.*?)\\))?"); + private static final Pattern JAVA_RE = - Pattern.compile(" at (?:(.+)\\.)?([^.]+)\\.([^.]+)\\((.*):([\\d-]+)\\)"); + Pattern.compile(" *at (?:(.+)\\.)?([^.]+)\\.([^.]+)\\((.*):([\\d-]+)\\)"); private static final Pattern JNI_RE = - Pattern.compile(" at (?:(.+)\\.)?([^.]+)\\.([^.]+)\\(Native method\\)"); + Pattern.compile(" *at (?:(.+)\\.)?([^.]+)\\.([^.]+)\\(Native method\\)"); private static final Pattern LOCKED_RE = - Pattern.compile(" - locked \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)"); + Pattern.compile(" *- locked \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)"); private static final Pattern SLEEPING_ON_RE = - Pattern.compile(" - sleeping on \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)"); + Pattern.compile(" *- sleeping on \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)"); private static final Pattern WAITING_ON_RE = - Pattern.compile(" - waiting on \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)"); + Pattern.compile(" *- waiting on \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)"); private static final Pattern WAITING_TO_LOCK_RE = Pattern.compile( - " - waiting to lock \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)"); + " *- waiting to lock \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)"); private static final Pattern WAITING_TO_LOCK_HELD_RE = Pattern.compile( - " - waiting to lock \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)" + " *- waiting to lock \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)" + "(?: held by thread (\\d+))"); private static final Pattern WAITING_TO_LOCK_UNKNOWN_RE = - Pattern.compile(" - waiting to lock an unknown object"); + Pattern.compile(" *- waiting to lock an unknown object"); private static final Pattern BLANK_RE = Pattern.compile("\\s+"); private final @NotNull SentryOptions options; 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; this.stackTraceFactory = new SentryStackTraceFactory(options); + this.debugImages = new HashMap<>(); + this.threads = new ArrayList<>(); + } + + @NotNull + public List getDebugImages() { + return new ArrayList<>(debugImages.values()); } @NotNull - public List parse(final @NotNull Lines lines) { - final List sentryThreads = new ArrayList<>(); + public List getThreads() { + return threads; + } + + @Nullable + 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(); if (line == null) { options.getLogger().log(SentryLevel.WARNING, "Internal error while parsing thread dump."); - return sentryThreads; + return; } final String text = line.text; // we only handle managed threads, as unmanaged/not attached do not have the thread id and // our protocol does not support this case - if (matches(beginManagedThreadRe, text)) { + if (matches(beginManagedThreadRe, text) || matches(beginUnmanagedNativeThreadRe, text)) { lines.rewind(); final SentryThread thread = parseThread(lines); if (thread != null) { - sentryThreads.add(thread); + threads.add(thread); } + } else if (matches(pidRe, text)) { + processId = getLong(pidRe, 1, null); + } else { + artContextParser.parseLine(text); } } - return sentryThreads; + + markThreads(); } private SentryThread parseThread(final @NotNull Lines lines) { final SentryThread sentryThread = new SentryThread(); final Matcher beginManagedThreadRe = BEGIN_MANAGED_THREAD_RE.matcher(""); + final Matcher beginUnmanagedNativeThreadRe = BEGIN_UNMANAGED_NATIVE_THREAD_RE.matcher(""); // thread attributes if (!lines.hasNext()) { @@ -126,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 @@ -137,18 +216,27 @@ private SentryThread parseThread(final @NotNull Lines lines) { sentryThread.setState(state); } } - 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); + } else if (matches(beginUnmanagedNativeThreadRe, line.text)) { + final Long sysTid = getLong(beginUnmanagedNativeThreadRe, 3, null); + if (sysTid == null) { + options.getLogger().log(SentryLevel.DEBUG, "No thread id in the dump, skipping thread."); + // tid is required by our protocol + return null; + } + sentryThread.setId(sysTid); + sentryThread.setName(beginUnmanagedNativeThreadRe.group(1)); + 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; } @@ -160,7 +248,6 @@ private SentryStackTrace parseStacktrace( SentryStackFrame lastJavaFrame = null; final Matcher nativeRe = NATIVE_RE.matcher(""); - final Matcher nativeNoLocRe = NATIVE_NO_LOC_RE.matcher(""); final Matcher javaRe = JAVA_RE.matcher(""); final Matcher jniRe = JNI_RE.matcher(""); final Matcher lockedRe = LOCKED_RE.matcher(""); @@ -170,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(); @@ -178,19 +266,11 @@ private SentryStackTrace parseStacktrace( break; } final String text = line.text; - if (matches(nativeRe, text)) { - final SentryStackFrame frame = new SentryStackFrame(); - frame.setPackage(nativeRe.group(1)); - frame.setFunction(nativeRe.group(2)); - frame.setLineno(getInteger(nativeRe, 3, null)); - frames.add(frame); - lastJavaFrame = null; - } else if (matches(nativeNoLocRe, text)) { - final SentryStackFrame frame = new SentryStackFrame(); - frame.setPackage(nativeNoLocRe.group(1)); - frame.setFunction(nativeNoLocRe.group(2)); - frames.add(frame); - lastJavaFrame = null; + 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); @@ -203,6 +283,31 @@ private SentryStackTrace parseStacktrace( frame.setInApp(stackTraceFactory.isInApp(module)); frames.add(frame); lastJavaFrame = frame; + } else if (matches(nativeRe, text)) { + final SentryStackFrame frame = new SentryStackFrame(); + frame.setPackage(nativeRe.group(3)); + frame.setFunction(nativeRe.group(6)); + frame.setLineno(getInteger(nativeRe, 7, null)); + frame.setInstructionAddr("0x" + nativeRe.group(2)); + frame.setPlatform("native"); + + final String buildId = nativeRe.group(8); + final String debugId = buildId == null ? null : NativeEventUtils.buildIdToDebugId(buildId); + if (debugId != null) { + if (!debugImages.containsKey(debugId)) { + final DebugImage debugImage = new DebugImage(); + debugImage.setDebugId(debugId); + debugImage.setType("elf"); + debugImage.setCodeFile(nativeRe.group(4)); + debugImage.setCodeId(buildId); + debugImages.put(debugId, debugImage); + } + // The addresses in the thread dump are relative to the image + frame.setAddrMode("rel:" + debugId); + } + + frames.add(frame); + lastJavaFrame = null; } else if (matches(jniRe, text)) { final SentryStackFrame frame = new SentryStackFrame(); final String packageName = jniRe.group(1); @@ -211,6 +316,7 @@ private SentryStackTrace parseStacktrace( frame.setModule(module); frame.setFunction(jniRe.group(3)); frame.setInApp(stackTraceFactory.isInApp(module)); + frame.setNative(true); frames.add(frame); lastJavaFrame = frame; } else if (matches(lockedRe, text)) { @@ -284,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(); @@ -318,8 +442,8 @@ private Long getLong( @Nullable private Integer getInteger( - final @NotNull Matcher matcher, final int group, final @Nullable Integer defaultValue) { - final String str = matcher.group(group); + final @NotNull Matcher matcher, final int groupIndex, final @Nullable Integer defaultValue) { + final String str = matcher.group(groupIndex); if (str == null || str.length() == 0) { return defaultValue; } else { 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 new file mode 100644 index 00000000000..3f05beeceb2 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java @@ -0,0 +1,831 @@ +package io.sentry.android.core.internal.util; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.ConnectivityManager.NetworkCallback; +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; +import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.android.core.AppState; +import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.ContextUtils; +import io.sentry.transport.ICurrentDateProvider; +import io.sentry.util.AutoClosableReentrantLock; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +/** + * Note: ConnectivityManager sometimes throws SecurityExceptions on Android 11. Hence all relevant + * calls are guarded with try/catch. see https://issuetracker.google.com/issues/175055271 for more + * details + */ +@ApiStatus.Internal +public final class AndroidConnectionStatusProvider + implements IConnectionStatusProvider, AppState.AppStateListener { + + private final @NotNull Context context; + private final @NotNull SentryOptions options; + 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; + + private static final @NotNull AutoClosableReentrantLock connectivityManagerLock = + new AutoClosableReentrantLock(); + private static volatile @Nullable ConnectivityManager connectivityManager; + + private static final @NotNull AutoClosableReentrantLock childCallbacksLock = + new AutoClosableReentrantLock(); + private static final @NotNull List childCallbacks = new ArrayList<>(); + + private static final int[] transports = { + NetworkCapabilities.TRANSPORT_WIFI, + NetworkCapabilities.TRANSPORT_CELLULAR, + NetworkCapabilities.TRANSPORT_ETHERNET, + NetworkCapabilities.TRANSPORT_BLUETOOTH + }; + + private static final int[] capabilities = new int[2]; + + private volatile @Nullable NetworkCapabilities cachedNetworkCapabilities; + private volatile @Nullable Network currentNetwork; + private volatile long lastCacheUpdateTime = 0; + private static final long CACHE_TTL_MS = 2 * 60 * 1000L; // 2 minutes + private final @NotNull AtomicBoolean isConnected = new AtomicBoolean(false); + + 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; + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.M) { + capabilities[1] = NetworkCapabilities.NET_CAPABILITY_VALIDATED; + } + + // Register network callback immediately for caching + //noinspection Convert2MethodRef + submitSafe(() -> ensureNetworkCallbackRegistered()); + + AppState.getInstance().addAppStateListener(this); + } + + /** + * Enhanced network connectivity check for Android 15. Checks for NET_CAPABILITY_INTERNET, + * NET_CAPABILITY_VALIDATED, and proper transport types. + * https://medium.com/@doronkakuli/adapting-your-network-connectivity-checks-for-android-15-a-practical-guide-2b1850619294 + */ + @SuppressLint("InlinedApi") + private boolean isNetworkEffectivelyConnected( + final @Nullable NetworkCapabilities networkCapabilities) { + if (networkCapabilities == null) { + return false; + } + + // Check for general internet capability AND validated status + boolean hasInternetAndValidated = + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.M) { + hasInternetAndValidated = + hasInternetAndValidated + && networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED); + } + + if (!hasInternetAndValidated) { + return false; + } + + // Additionally, ensure it's a recognized transport type for general internet access + for (final int transport : transports) { + if (networkCapabilities.hasTransport(transport)) { + return true; + } + } + return false; + } + + /** Get connection status from cached NetworkCapabilities or fallback to legacy method. */ + private @NotNull ConnectionStatus getConnectionStatusFromCache() { + if (cachedNetworkCapabilities != null) { + return isNetworkEffectivelyConnected(cachedNetworkCapabilities) + ? ConnectionStatus.CONNECTED + : ConnectionStatus.DISCONNECTED; + } + + // Fallback to legacy method when NetworkCapabilities not available + final ConnectivityManager connectivityManager = + getConnectivityManager(context, options.getLogger()); + if (connectivityManager != null) { + return getConnectionStatus(context, connectivityManager, options.getLogger()); + } + + return ConnectionStatus.UNKNOWN; + } + + /** Get connection type from cached NetworkCapabilities or fallback to legacy method. */ + private @Nullable String getConnectionTypeFromCache() { + final NetworkCapabilities capabilities = cachedNetworkCapabilities; + if (capabilities != null) { + return getConnectionType(capabilities); + } + + // Fallback to legacy method when NetworkCapabilities not available + return getConnectionType(context, options.getLogger(), buildInfoProvider); + } + + private void ensureNetworkCallbackRegistered() { + if (!ContextUtils.isForegroundImportance()) { + return; + } + + if (networkCallback != null) { + return; // Already registered + } + + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (networkCallback != null) { + return; + } + + final @NotNull NetworkCallback callback = + new NetworkCallback() { + @Override + public void onAvailable(final @NotNull Network network) { + currentNetwork = network; + + // have to only dispatch this on first registration + when the connection got + // re-established + // otherwise it would've been dispatched on every foreground + if (!isConnected.getAndSet(true)) { + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + for (final @NotNull NetworkCallback cb : childCallbacks) { + cb.onAvailable(network); + } + } + } + } + + @RequiresApi(Build.VERSION_CODES.O) + @Override + public void onUnavailable() { + clearCacheAndNotifyObservers(); + + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + for (final @NotNull NetworkCallback cb : childCallbacks) { + cb.onUnavailable(); + } + } + } + + @Override + public void onLost(final @NotNull Network network) { + if (!network.equals(currentNetwork)) { + return; + } + clearCacheAndNotifyObservers(); + + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + for (final @NotNull NetworkCallback cb : childCallbacks) { + cb.onLost(network); + } + } + } + + private void clearCacheAndNotifyObservers() { + isConnected.set(false); + // Clear cached capabilities and network reference atomically + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + cachedNetworkCapabilities = null; + currentNetwork = null; + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + + options + .getLogger() + .log(SentryLevel.DEBUG, "Cache cleared - network lost/unavailable"); + + // Notify all observers with DISCONNECTED status directly + // No need to query ConnectivityManager - we know the network is gone + for (final @NotNull IConnectionStatusObserver observer : + connectionStatusObservers) { + observer.onConnectionStatusChanged(ConnectionStatus.DISCONNECTED); + } + } + } + + @Override + public void onCapabilitiesChanged( + @NonNull Network network, @NonNull NetworkCapabilities networkCapabilities) { + if (!network.equals(currentNetwork)) { + return; + } + updateCacheAndNotifyObservers(network, networkCapabilities); + + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + for (final @NotNull NetworkCallback cb : childCallbacks) { + cb.onCapabilitiesChanged(network, networkCapabilities); + } + } + } + + private void updateCacheAndNotifyObservers( + @Nullable Network network, @Nullable NetworkCapabilities networkCapabilities) { + // Check if this change is meaningful before notifying observers + final boolean shouldUpdate = isSignificantChange(networkCapabilities); + + // Only notify observers if something meaningful changed + if (shouldUpdate) { + updateCache(networkCapabilities); + + final @NotNull ConnectionStatus status = getConnectionStatusFromCache(); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + for (final @NotNull IConnectionStatusObserver observer : + connectionStatusObservers) { + observer.onConnectionStatusChanged(status); + } + } + } + } + + /** + * Check if NetworkCapabilities change is significant for our observers. Only notify for + * changes that affect connectivity status or connection type. + */ + private boolean isSignificantChange(@Nullable NetworkCapabilities newCapabilities) { + final NetworkCapabilities oldCapabilities = cachedNetworkCapabilities; + + // Always significant if transitioning between null and non-null + if ((oldCapabilities == null) != (newCapabilities == null)) { + return true; + } + + // If both null, no change + if (oldCapabilities == null && newCapabilities == null) { + return false; + } + + // Check significant capability changes + if (hasSignificantCapabilityChanges(oldCapabilities, newCapabilities)) { + return true; + } + + // Check significant transport changes + if (hasSignificantTransportChanges(oldCapabilities, newCapabilities)) { + return true; + } + + return false; + } + + /** Check if capabilities that affect connectivity status changed. */ + private boolean hasSignificantCapabilityChanges( + @NotNull NetworkCapabilities old, @NotNull NetworkCapabilities new_) { + // Check capabilities we care about for connectivity determination + for (int capability : capabilities) { + if (capability != 0 + && old.hasCapability(capability) != new_.hasCapability(capability)) { + return true; + } + } + + return false; + } + + /** Check if transport types that affect connection type changed. */ + private boolean hasSignificantTransportChanges( + @NotNull NetworkCapabilities old, @NotNull NetworkCapabilities new_) { + // Check transports we care about for connection type determination + for (int transport : transports) { + if (old.hasTransport(transport) != new_.hasTransport(transport)) { + return true; + } + } + + return false; + } + }; + + if (registerNetworkCallback( + context, options.getLogger(), buildInfoProvider, handler, callback)) { + networkCallback = callback; + options.getLogger().log(SentryLevel.DEBUG, "Network callback registered successfully"); + } else { + options.getLogger().log(SentryLevel.WARNING, "Failed to register network callback"); + } + } + } + + @SuppressLint({"NewApi", "MissingPermission"}) + private void updateCache(@Nullable NetworkCapabilities networkCapabilities) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + try { + if (networkCapabilities != null) { + cachedNetworkCapabilities = networkCapabilities; + } else { + if (!Permissions.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { + options + .getLogger() + .log( + SentryLevel.INFO, + "No permission (ACCESS_NETWORK_STATE) to check network status."); + cachedNetworkCapabilities = null; + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + return; + } + + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.M) { + cachedNetworkCapabilities = null; + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + return; + } + + // Fallback: query current active network + final ConnectivityManager connectivityManager = + getConnectivityManager(context, options.getLogger()); + if (connectivityManager != null) { + final Network activeNetwork = connectivityManager.getActiveNetwork(); + + cachedNetworkCapabilities = + activeNetwork != null + ? connectivityManager.getNetworkCapabilities(activeNetwork) + : null; + } else { + cachedNetworkCapabilities = + null; // Clear cached capabilities if connectivity manager is null + } + } + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Cache updated - Status: " + + getConnectionStatusFromCache() + + ", Type: " + + getConnectionTypeFromCache()); + } catch (Throwable t) { + options.getLogger().log(SentryLevel.WARNING, "Failed to update connection status cache", t); + cachedNetworkCapabilities = null; + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + } + } + } + + private boolean isCacheValid() { + return (timeProvider.getCurrentTimeMillis() - lastCacheUpdateTime) < CACHE_TTL_MS; + } + + @Override + public @NotNull ConnectionStatus getConnectionStatus() { + if (!isCacheValid()) { + updateCache(null); + } + return getConnectionStatusFromCache(); + } + + @Override + public @Nullable String getConnectionType() { + if (!isCacheValid()) { + updateCache(null); + } + return getConnectionTypeFromCache(); + } + + @Override + public boolean addConnectionStatusObserver(final @NotNull IConnectionStatusObserver observer) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + connectionStatusObservers.add(observer); + } + ensureNetworkCallbackRegistered(); + + // Network callback is already registered during initialization + return networkCallback != null; + } + + @Override + public void removeConnectionStatusObserver(final @NotNull IConnectionStatusObserver observer) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + connectionStatusObservers.remove(observer); + // Keep the callback registered for caching even if no observers + } + } + + private void unregisterNetworkCallback(final boolean clearObservers) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (clearObservers) { + connectionStatusObservers.clear(); + } + + final @Nullable NetworkCallback callbackRef = networkCallback; + networkCallback = null; + + if (callbackRef != null) { + unregisterNetworkCallback(context, options.getLogger(), callbackRef); + } + // Clear cached state + cachedNetworkCapabilities = null; + currentNetwork = null; + lastCacheUpdateTime = 0; + } + options.getLogger().log(SentryLevel.DEBUG, "Network callback unregistered"); + } + + /** Clean up resources - should be called when the provider is no longer needed */ + @Override + public void close() { + submitSafe( + () -> { + unregisterNetworkCallback(/* clearObservers= */ true); + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + childCallbacks.clear(); + } + try (final @NotNull ISentryLifecycleToken ignored = connectivityManagerLock.acquire()) { + connectivityManager = null; + } + AppState.getInstance().removeAppStateListener(this); + }); + } + + @Override + public void onForeground() { + if (networkCallback != null) { + return; + } + + submitSafe( + () -> { + // proactively update cache and notify observers on foreground to ensure connectivity + // state is not stale + updateCache(null); + + final @NotNull ConnectionStatus status = getConnectionStatusFromCache(); + if (status == ConnectionStatus.DISCONNECTED) { + // onLost is not called retroactively when we registerNetworkCallback (as opposed to + // onAvailable), so we have to do it manually for the DISCONNECTED case + isConnected.set(false); + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + for (final @NotNull NetworkCallback cb : childCallbacks) { + //noinspection DataFlowIssue + cb.onLost(null); + } + } + } + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + for (final @NotNull IConnectionStatusObserver observer : connectionStatusObservers) { + observer.onConnectionStatusChanged(status); + } + } + + // this will ONLY do the necessary parts like registerNetworkCallback and onAvailable, but + // it won't updateCache and notify observes because we just did it above and the cached + // capabilities will be the same + ensureNetworkCallbackRegistered(); + }); + } + + @Override + public void onBackground() { + if (networkCallback == null) { + return; + } + + submitSafe( + () -> { + //noinspection Convert2MethodRef + unregisterNetworkCallback(/* clearObservers= */ false); + }); + } + + /** + * Get the cached NetworkCapabilities for advanced use cases. Returns null if cache is stale or no + * capabilities are available. + * + * @return cached NetworkCapabilities or null + */ + @TestOnly + @Nullable + public NetworkCapabilities getCachedNetworkCapabilities() { + return cachedNetworkCapabilities; + } + + /** + * Return the Connection status + * + * @param context the Context + * @param connectivityManager the ConnectivityManager + * @param logger the Logger + * @return true if connected or no permission to check, false otherwise + */ + @SuppressWarnings({"deprecation", "MissingPermission"}) + private static @NotNull IConnectionStatusProvider.ConnectionStatus getConnectionStatus( + final @NotNull Context context, + final @NotNull ConnectivityManager connectivityManager, + final @NotNull ILogger logger) { + if (!Permissions.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { + logger.log(SentryLevel.INFO, "No permission (ACCESS_NETWORK_STATE) to check network status."); + return ConnectionStatus.NO_PERMISSION; + } + + try { + final android.net.NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); + if (activeNetworkInfo == null) { + logger.log(SentryLevel.INFO, "NetworkInfo is null, there's no active network."); + return ConnectionStatus.DISCONNECTED; + } + return activeNetworkInfo.isConnected() + ? ConnectionStatus.CONNECTED + : ConnectionStatus.DISCONNECTED; + } catch (Throwable t) { + logger.log(SentryLevel.WARNING, "Could not retrieve Connection Status", t); + return ConnectionStatus.UNKNOWN; + } + } + + /** + * Check the connection type of the active network + * + * @param context the App. context + * @param logger the logger from options + * @param buildInfoProvider the BuildInfoProvider provider + * @return the connection type wifi, ethernet, cellular or null + */ + @SuppressLint({"ObsoleteSdkInt", "MissingPermission", "NewApi"}) + public static @Nullable String getConnectionType( + final @NotNull Context context, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { + final ConnectivityManager connectivityManager = getConnectivityManager(context, logger); + if (connectivityManager == null) { + return null; + } + if (!Permissions.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { + logger.log(SentryLevel.INFO, "No permission (ACCESS_NETWORK_STATE) to check network status."); + return null; + } + + try { + boolean ethernet = false; + boolean wifi = false; + boolean cellular = false; + + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.M) { + + final Network activeNetwork = connectivityManager.getActiveNetwork(); + if (activeNetwork == null) { + logger.log(SentryLevel.INFO, "Network is null and cannot check network status"); + return null; + } + final NetworkCapabilities networkCapabilities = + connectivityManager.getNetworkCapabilities(activeNetwork); + if (networkCapabilities == null) { + logger.log(SentryLevel.INFO, "NetworkCapabilities is null and cannot check network type"); + return null; + } + if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) { + ethernet = true; + } + if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + wifi = true; + } + if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { + cellular = true; + } + } else { + // ideally using connectivityManager.getAllNetworks(), but its >= Android L only + + // for some reason linting didn't allow a single @SuppressWarnings("deprecation") on method + // signature + @SuppressWarnings("deprecation") + final android.net.NetworkInfo activeNetworkInfo = + connectivityManager.getActiveNetworkInfo(); + + if (activeNetworkInfo == null) { + logger.log(SentryLevel.INFO, "NetworkInfo is null, there's no active network."); + return null; + } + + @SuppressWarnings("deprecation") + final int type = activeNetworkInfo.getType(); + + @SuppressWarnings("deprecation") + final int TYPE_ETHERNET = ConnectivityManager.TYPE_ETHERNET; + + @SuppressWarnings("deprecation") + final int TYPE_WIFI = ConnectivityManager.TYPE_WIFI; + + @SuppressWarnings("deprecation") + final int TYPE_MOBILE = ConnectivityManager.TYPE_MOBILE; + + switch (type) { + case TYPE_ETHERNET: + ethernet = true; + break; + case TYPE_WIFI: + wifi = true; + break; + case TYPE_MOBILE: + cellular = true; + break; + } + } + + // TODO: change the protocol to be a list of transports as a device may have the capability of + // multiple + if (ethernet) { + return "ethernet"; + } + if (wifi) { + return "wifi"; + } + if (cellular) { + return "cellular"; + } + } catch (Throwable exception) { + logger.log(SentryLevel.ERROR, "Failed to retrieve network info", exception); + } + + return null; + } + + /** + * Check the connection type of the active network + * + * @param networkCapabilities the NetworkCapabilities to check the transport type + * @return the connection type wifi, ethernet, cellular or null + */ + public static @Nullable String getConnectionType( + final @NotNull NetworkCapabilities networkCapabilities) { + // TODO: change the protocol to be a list of transports as a device may have the capability of + // multiple + + if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) { + return "ethernet"; + } + if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + return "wifi"; + } + if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { + return "cellular"; + } + + return null; + } + + private static @Nullable ConnectivityManager getConnectivityManager( + final @NotNull Context context, final @NotNull ILogger logger) { + if (connectivityManager != null) { + return connectivityManager; + } + + try (final @NotNull ISentryLifecycleToken ignored = connectivityManagerLock.acquire()) { + if (connectivityManager != null) { + return connectivityManager; // Double-checked locking + } + + connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (connectivityManager == null) { + logger.log(SentryLevel.INFO, "ConnectivityManager is null and cannot check network status"); + } + return connectivityManager; + } + } + + public static boolean addNetworkCallback( + final @NotNull Context context, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider, + final @NotNull NetworkCallback networkCallback) { + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { + logger.log(SentryLevel.DEBUG, "NetworkCallbacks need Android N+."); + return false; + } + + if (!Permissions.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { + logger.log(SentryLevel.INFO, "No permission (ACCESS_NETWORK_STATE) to check network status."); + return false; + } + + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + childCallbacks.add(networkCallback); + } + return true; + } + + public static void removeNetworkCallback(final @NotNull NetworkCallback networkCallback) { + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + childCallbacks.remove(networkCallback); + } + } + + @SuppressLint({"MissingPermission", "NewApi"}) + 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+."); + return false; + } + final ConnectivityManager connectivityManager = getConnectivityManager(context, logger); + if (connectivityManager == null) { + return false; + } + if (!Permissions.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { + logger.log(SentryLevel.INFO, "No permission (ACCESS_NETWORK_STATE) to check network status."); + return false; + } + try { + if (handler != null) { + connectivityManager.registerDefaultNetworkCallback(networkCallback, handler); + } else { + connectivityManager.registerDefaultNetworkCallback(networkCallback); + } + } catch (Throwable t) { + logger.log(SentryLevel.WARNING, "registerDefaultNetworkCallback failed", t); + return false; + } + return true; + } + + @SuppressLint("NewApi") + static void unregisterNetworkCallback( + final @NotNull Context context, + final @NotNull ILogger logger, + final @NotNull NetworkCallback networkCallback) { + + final ConnectivityManager connectivityManager = getConnectivityManager(context, logger); + if (connectivityManager == null) { + return; + } + try { + connectivityManager.unregisterNetworkCallback(networkCallback); + } catch (Throwable t) { + logger.log(SentryLevel.WARNING, "unregisterNetworkCallback failed", t); + } + } + + @TestOnly + @NotNull + public List getStatusObservers() { + return connectionStatusObservers; + } + + @TestOnly + @Nullable + public NetworkCallback getNetworkCallback() { + return networkCallback; + } + + @TestOnly + @NotNull + public static List getChildCallbacks() { + return childCallbacks; + } + + private void submitSafe(@NotNull Runnable r) { + try { + options.getExecutorService().submit(r); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "AndroidConnectionStatusProvider submit failed", e); + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidMainThreadChecker.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidMainThreadChecker.java deleted file mode 100644 index a893fa87d16..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidMainThreadChecker.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.sentry.android.core.internal.util; - -import android.os.Looper; -import io.sentry.util.thread.IMainThreadChecker; -import org.jetbrains.annotations.ApiStatus; - -/** Class that checks if a given thread is the Android Main/UI thread */ -@ApiStatus.Internal -public final class AndroidMainThreadChecker implements IMainThreadChecker { - - private static final AndroidMainThreadChecker instance = new AndroidMainThreadChecker(); - - public static AndroidMainThreadChecker getInstance() { - return instance; - } - - private AndroidMainThreadChecker() {} - - @Override - public boolean isMainThread(final long threadId) { - return Looper.getMainLooper().getThread().getId() == threadId; - } -} 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 new file mode 100644 index 00000000000..7228c849cf7 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidThreadChecker.java @@ -0,0 +1,76 @@ +package io.sentry.android.core.internal.util; + +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.Process; +import io.sentry.protocol.SentryThread; +import io.sentry.util.thread.IThreadChecker; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** Class that checks if a given thread is the Android Main/UI thread */ +@ApiStatus.Internal +public final class AndroidThreadChecker implements IThreadChecker { + + private static final AndroidThreadChecker instance = new AndroidThreadChecker(); + public static volatile long mainThreadSystemId = Process.myTid(); + + public static AndroidThreadChecker getInstance() { + return instance; + } + + private AndroidThreadChecker() { + // The first time this class is loaded, we make sure to set the correct mainThreadId + 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 getThreadId(Looper.getMainLooper().getThread()) == threadId; + } + + @Override + public boolean isMainThread(final @NotNull Thread thread) { + return isMainThread(getThreadId(thread)); + } + + @Override + public boolean isMainThread() { + return isMainThread(Thread.currentThread()); + } + + @Override + public @NotNull String getCurrentThreadName() { + return isMainThread() ? "main" : Thread.currentThread().getName(); + } + + @Override + public boolean isMainThread(final @NotNull SentryThread sentryThread) { + final Long threadId = sentryThread.getId(); + return threadId != null && isMainThread(threadId); + } + + @Override + public long currentThreadSystemId() { + return Process.myTid(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ClassUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ClassUtil.java new file mode 100644 index 00000000000..8b730e267ae --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ClassUtil.java @@ -0,0 +1,19 @@ +package io.sentry.android.core.internal.util; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public class ClassUtil { + + public static @Nullable String getClassName(final @Nullable Object object) { + if (object == null) { + return null; + } + final @Nullable String canonicalName = object.getClass().getCanonicalName(); + if (canonicalName != null) { + return canonicalName; + } + return object.getClass().getSimpleName(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ConnectivityChecker.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ConnectivityChecker.java deleted file mode 100644 index 113ad55120d..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ConnectivityChecker.java +++ /dev/null @@ -1,276 +0,0 @@ -package io.sentry.android.core.internal.util; - -import android.Manifest; -import android.annotation.SuppressLint; -import android.content.Context; -import android.net.ConnectivityManager; -import android.net.Network; -import android.net.NetworkCapabilities; -import android.os.Build; -import io.sentry.ILogger; -import io.sentry.SentryLevel; -import io.sentry.android.core.BuildInfoProvider; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** - * Note: ConnectivityManager sometimes throws SecurityExceptions on Android 11. Hence all relevant - * calls are guarded with try/catch. see https://issuetracker.google.com/issues/175055271 for more - * details - */ -@ApiStatus.Internal -public final class ConnectivityChecker { - - public enum Status { - CONNECTED, - NOT_CONNECTED, - NO_PERMISSION, - UNKNOWN - } - - private ConnectivityChecker() {} - - /** - * Return the Connection status - * - * @return the ConnectionStatus - */ - public static @NotNull ConnectivityChecker.Status getConnectionStatus( - final @NotNull Context context, final @NotNull ILogger logger) { - final ConnectivityManager connectivityManager = getConnectivityManager(context, logger); - if (connectivityManager == null) { - return Status.UNKNOWN; - } - return getConnectionStatus(context, connectivityManager, logger); - // getActiveNetworkInfo might return null if VPN doesn't specify its - // underlying network - - // when min. API 24, use: - // connectivityManager.registerDefaultNetworkCallback(...) - } - - /** - * Return the Connection status - * - * @param context the Context - * @param connectivityManager the ConnectivityManager - * @param logger the Logger - * @return true if connected or no permission to check, false otherwise - */ - @SuppressWarnings({"deprecation", "MissingPermission"}) - private static @NotNull ConnectivityChecker.Status getConnectionStatus( - final @NotNull Context context, - final @NotNull ConnectivityManager connectivityManager, - final @NotNull ILogger logger) { - if (!Permissions.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { - logger.log(SentryLevel.INFO, "No permission (ACCESS_NETWORK_STATE) to check network status."); - return Status.NO_PERMISSION; - } - - try { - final android.net.NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); - if (activeNetworkInfo == null) { - logger.log(SentryLevel.INFO, "NetworkInfo is null, there's no active network."); - return Status.NOT_CONNECTED; - } - return activeNetworkInfo.isConnected() ? Status.CONNECTED : Status.NOT_CONNECTED; - } catch (Throwable t) { - logger.log(SentryLevel.ERROR, "Could not retrieve Connection Status", t); - return Status.UNKNOWN; - } - } - - /** - * Check the connection type of the active network - * - * @param context the App. context - * @param logger the logger from options - * @param buildInfoProvider the BuildInfoProvider provider - * @return the connection type wifi, ethernet, cellular or null - */ - @SuppressLint({"ObsoleteSdkInt", "MissingPermission", "NewApi"}) - public static @Nullable String getConnectionType( - final @NotNull Context context, - final @NotNull ILogger logger, - final @NotNull BuildInfoProvider buildInfoProvider) { - final ConnectivityManager connectivityManager = getConnectivityManager(context, logger); - if (connectivityManager == null) { - return null; - } - if (!Permissions.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { - logger.log(SentryLevel.INFO, "No permission (ACCESS_NETWORK_STATE) to check network status."); - return null; - } - - try { - boolean ethernet = false; - boolean wifi = false; - boolean cellular = false; - - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.M) { - - final Network activeNetwork = connectivityManager.getActiveNetwork(); - if (activeNetwork == null) { - logger.log(SentryLevel.INFO, "Network is null and cannot check network status"); - return null; - } - final NetworkCapabilities networkCapabilities = - connectivityManager.getNetworkCapabilities(activeNetwork); - if (networkCapabilities == null) { - logger.log(SentryLevel.INFO, "NetworkCapabilities is null and cannot check network type"); - return null; - } - if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) { - ethernet = true; - } - if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { - wifi = true; - } - if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { - cellular = true; - } - } else { - // ideally using connectivityManager.getAllNetworks(), but its >= Android L only - - // for some reason linting didn't allow a single @SuppressWarnings("deprecation") on method - // signature - @SuppressWarnings("deprecation") - final android.net.NetworkInfo activeNetworkInfo = - connectivityManager.getActiveNetworkInfo(); - - if (activeNetworkInfo == null) { - logger.log(SentryLevel.INFO, "NetworkInfo is null, there's no active network."); - return null; - } - - @SuppressWarnings("deprecation") - final int type = activeNetworkInfo.getType(); - - @SuppressWarnings("deprecation") - final int TYPE_ETHERNET = ConnectivityManager.TYPE_ETHERNET; - - @SuppressWarnings("deprecation") - final int TYPE_WIFI = ConnectivityManager.TYPE_WIFI; - - @SuppressWarnings("deprecation") - final int TYPE_MOBILE = ConnectivityManager.TYPE_MOBILE; - - switch (type) { - case TYPE_ETHERNET: - ethernet = true; - break; - case TYPE_WIFI: - wifi = true; - break; - case TYPE_MOBILE: - cellular = true; - break; - } - } - - // TODO: change the protocol to be a list of transports as a device may have the capability of - // multiple - if (ethernet) { - return "ethernet"; - } - if (wifi) { - return "wifi"; - } - if (cellular) { - return "cellular"; - } - - } catch (Throwable exception) { - logger.log(SentryLevel.ERROR, "Failed to retrieve network info", exception); - } - - return null; - } - - /** - * Check the connection type of the active network - * - * @param networkCapabilities the NetworkCapabilities to check the transport type - * @return the connection type wifi, ethernet, cellular or null - */ - @SuppressLint("NewApi") - public static @Nullable String getConnectionType( - final @NotNull NetworkCapabilities networkCapabilities, - final @NotNull BuildInfoProvider buildInfoProvider) { - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) { - return null; - } - // TODO: change the protocol to be a list of transports as a device may have the capability of - // multiple - - if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) { - return "ethernet"; - } - if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { - return "wifi"; - } - if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { - return "cellular"; - } - - return null; - } - - private static @Nullable ConnectivityManager getConnectivityManager( - final @NotNull Context context, final @NotNull ILogger logger) { - final ConnectivityManager connectivityManager = - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - if (connectivityManager == null) { - logger.log(SentryLevel.INFO, "ConnectivityManager is null and cannot check network status"); - } - return connectivityManager; - } - - @SuppressLint({"MissingPermission", "NewApi"}) - public static boolean registerNetworkCallback( - final @NotNull Context context, - final @NotNull ILogger logger, - final @NotNull BuildInfoProvider buildInfoProvider, - final @NotNull ConnectivityManager.NetworkCallback networkCallback) { - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { - logger.log(SentryLevel.DEBUG, "NetworkCallbacks need Android N+."); - return false; - } - final ConnectivityManager connectivityManager = getConnectivityManager(context, logger); - if (connectivityManager == null) { - return false; - } - if (!Permissions.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { - logger.log(SentryLevel.INFO, "No permission (ACCESS_NETWORK_STATE) to check network status."); - return false; - } - try { - connectivityManager.registerDefaultNetworkCallback(networkCallback); - } catch (Throwable t) { - logger.log(SentryLevel.ERROR, "registerDefaultNetworkCallback failed", t); - return false; - } - return true; - } - - @SuppressLint("NewApi") - public static void unregisterNetworkCallback( - final @NotNull Context context, - final @NotNull ILogger logger, - final @NotNull BuildInfoProvider buildInfoProvider, - final @NotNull ConnectivityManager.NetworkCallback networkCallback) { - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) { - return; - } - final ConnectivityManager connectivityManager = getConnectivityManager(context, logger); - if (connectivityManager == null) { - return; - } - try { - connectivityManager.unregisterNetworkCallback(networkCallback); - } catch (Throwable t) { - logger.log(SentryLevel.ERROR, "unregisterNetworkCallback failed", t); - } - } -} 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 8dcb994fbc9..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 @@ -1,5 +1,7 @@ package io.sentry.android.core.internal.util; +import io.sentry.ISentryLifecycleToken; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.FileUtils; import java.io.File; import java.io.IOException; @@ -14,6 +16,7 @@ public final class CpuInfoUtils { private static final CpuInfoUtils instance = new CpuInfoUtils(); + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); public static CpuInfoUtils getInstance() { return instance; @@ -34,34 +37,34 @@ private CpuInfoUtils() {} * * @return A list with the frequency of each core of the cpu in Mhz */ - public synchronized @NotNull List readMaxFrequencies() { - if (!cpuMaxFrequenciesMhz.isEmpty()) { - return cpuMaxFrequenciesMhz; - } - File[] cpuDirs = new File(getSystemCpuPath()).listFiles(); - if (cpuDirs == null) { - return new ArrayList<>(); - } - - for (File cpuDir : cpuDirs) { - if (!cpuDir.getName().matches("cpu[0-9]+")) continue; - File cpuMaxFreqFile = new File(cpuDir, CPUINFO_MAX_FREQ_PATH); + public @NotNull List readMaxFrequencies() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!cpuMaxFrequenciesMhz.isEmpty()) { + return cpuMaxFrequenciesMhz; + } + File[] cpuDirs = new File(getSystemCpuPath()).listFiles(); + if (cpuDirs == null) { + return new ArrayList<>(); + } - if (!cpuMaxFreqFile.exists() || !cpuMaxFreqFile.canRead()) continue; + for (File cpuDir : cpuDirs) { + if (!cpuDir.getName().matches("cpu[0-9]+")) continue; + File cpuMaxFreqFile = new File(cpuDir, CPUINFO_MAX_FREQ_PATH); - long khz; - try { - String content = FileUtils.readText(cpuMaxFreqFile); - if (content == null) continue; - khz = Long.parseLong(content.trim()); - } catch (NumberFormatException e) { - continue; - } catch (IOException e) { - continue; + long khz; + try { + String content = FileUtils.readText(cpuMaxFreqFile); + if (content == null) continue; + khz = Long.parseLong(content.trim()); + } catch (NumberFormatException e) { + continue; + } catch (IOException e) { + continue; + } + cpuMaxFrequenciesMhz.add((int) (khz / 1000)); } - cpuMaxFrequenciesMhz.add((int) (khz / 1000)); + return cpuMaxFrequenciesMhz; } - return cpuMaxFrequenciesMhz; } @VisibleForTesting @@ -72,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/Debouncer.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/Debouncer.java index 738904df248..8808947c0cc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/Debouncer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/Debouncer.java @@ -1,6 +1,8 @@ package io.sentry.android.core.internal.util; import io.sentry.transport.ICurrentDateProvider; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -10,24 +12,35 @@ public class Debouncer { private final long waitTimeMs; private final @NotNull ICurrentDateProvider timeProvider; + private final @NotNull AtomicInteger executions = new AtomicInteger(0); + private final int maxExecutions; - private Long lastExecutionTime = null; + private final @NotNull AtomicLong lastExecutionTime = new AtomicLong(0); - public Debouncer(final @NotNull ICurrentDateProvider timeProvider, final long waitTimeMs) { + public Debouncer( + final @NotNull ICurrentDateProvider timeProvider, + final long waitTimeMs, + final int maxExecutions) { this.timeProvider = timeProvider; this.waitTimeMs = waitTimeMs; + this.maxExecutions = maxExecutions <= 0 ? 1 : maxExecutions; } /** - * @return true if the execution should be debounced due to the last execution being within within - * waitTimeMs, otherwise false. + * @return true if the execution should be debounced due to maxExecutions executions being made + * within waitTimeMs, otherwise false. */ public boolean checkForDebounce() { final long now = timeProvider.getCurrentTimeMillis(); - if (lastExecutionTime == null || (lastExecutionTime + waitTimeMs) <= now) { - lastExecutionTime = now; + if (lastExecutionTime.get() == 0 || (lastExecutionTime.get() + waitTimeMs) <= now) { + executions.set(0); + lastExecutionTime.set(now); return false; } + if (executions.incrementAndGet() < maxExecutions) { + return false; + } + executions.set(0); return true; } } 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 10c160377b2..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 @@ -1,31 +1,73 @@ +/* + * https://github.com/firebase/firebase-android-sdk/blob/master/firebase-perf/src/main/java/com/google/firebase/perf/util/FirstDrawDoneListener.java + * + * 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. + */ + package io.sentry.android.core.internal.util; -import android.annotation.SuppressLint; +import android.app.Activity; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.view.View; import android.view.ViewTreeObserver; -import androidx.annotation.RequiresApi; +import android.view.Window; +import androidx.annotation.Nullable; import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.internal.gestures.NoOpWindowCallback; +import io.sentry.android.core.performance.WindowContentChangedCallback; import java.util.concurrent.atomic.AtomicReference; import org.jetbrains.annotations.NotNull; /** * OnDrawListener that unregisters itself and invokes callback when the next draw is done. This API * 16+ implementation is an approximation of the initial-display-time defined by Android Vitals. - * - *

Adapted from Firebase - * under the Apache License, Version 2.0. */ -@SuppressLint("ObsoleteSdkInt") -@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN) public class FirstDrawDoneListener implements ViewTreeObserver.OnDrawListener { private final @NotNull Handler mainThreadHandler = new Handler(Looper.getMainLooper()); private final @NotNull AtomicReference viewReference; private final @NotNull Runnable callback; + public static void registerForNextDraw( + final @NotNull Activity activity, + final @NotNull Runnable drawDoneCallback, + final @NotNull BuildInfoProvider buildInfoProvider) { + + @Nullable Window window = activity.getWindow(); + if (window != null) { + @Nullable View decorView = window.peekDecorView(); + if (decorView != null) { + registerForNextDraw(decorView, drawDoneCallback, buildInfoProvider); + } else { + final @Nullable Window.Callback oldCallback = window.getCallback(); + window.setCallback( + new WindowContentChangedCallback( + oldCallback != null ? oldCallback : new NoOpWindowCallback(), + () -> { + @Nullable View newDecorView = window.peekDecorView(); + if (newDecorView != null) { + // let's set the old callback again, so we don't intercept anymore + window.setCallback(oldCallback); + registerForNextDraw(newDecorView, drawDoneCallback, buildInfoProvider); + } + })); + } + } + } + /** Registers a post-draw callback for the next draw of a view. */ public static void registerForNextDraw( final @NotNull View view, @@ -35,8 +77,8 @@ public static void registerForNextDraw( // Handle bug prior to API 26 where OnDrawListener from the floating ViewTreeObserver is not // merged into the real ViewTreeObserver. // https://android.googlesource.com/platform/frameworks/base/+/9f8ec54244a5e0343b9748db3329733f259604f3 - if (buildInfoProvider.getSdkInfoVersion() < 26 - && !isAliveAndAttached(view, buildInfoProvider)) { + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.O + && !isAliveAndAttached(view)) { view.addOnAttachStateChangeListener( new View.OnAttachStateChangeListener() { @Override @@ -70,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); } @@ -83,17 +132,7 @@ public void onDraw() { * @return true if the View is already attached and the ViewTreeObserver is not a floating * placeholder. */ - private static boolean isAliveAndAttached( - final @NotNull View view, final @NotNull BuildInfoProvider buildInfoProvider) { - return view.getViewTreeObserver().isAlive() && isAttachedToWindow(view, buildInfoProvider); - } - - @SuppressLint("NewApi") - private static boolean isAttachedToWindow( - final @NotNull View view, final @NotNull BuildInfoProvider buildInfoProvider) { - if (buildInfoProvider.getSdkInfoVersion() >= 19) { - return view.isAttachedToWindow(); - } - return view.getWindowToken() != null; + private static boolean isAliveAndAttached(final @NotNull View view) { + return view.getViewTreeObserver().isAlive() && view.isAttachedToWindow(); } } 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/RootChecker.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/RootChecker.java index ceb241ce061..2823f278a7d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/RootChecker.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/RootChecker.java @@ -1,3 +1,31 @@ +/* + * Root detection implementation adapted from Ravencoin Android: + * https://github.com/Menwitz/ravencoin-android/blob/7b68378c046e2fd0d6f30cea59cbd87fcb6db12d/app/src/main/java/com/ravencoin/tools/security/RootHelper.java + * + * RavenWallet + *

+ * Created by Mihail Gutan on 5/19/16. + * 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. + */ + package io.sentry.android.core.internal.util; import android.annotation.SuppressLint; @@ -42,12 +70,12 @@ public RootChecker( buildInfoProvider, logger, new String[] { - "/system/app/Superuser.apk", "/sbin/su", + "/data/local/xbin/su", "/system/bin/su", "/system/xbin/su", - "/data/local/xbin/su", "/data/local/bin/su", + "/system/app/Superuser.apk", "/system/sd/xbin/su", "/system/bin/failsafe/su", "/data/local/su", @@ -84,12 +112,11 @@ public RootChecker( /** * Check if the device is rooted or not - * https://medium.com/@thehimanshugoel/10-best-security-practices-in-android-applications-that-every-developer-must-know-99c8cd07c0bb * * @return whether the device is rooted or not */ public boolean isDeviceRooted() { - return checkTestKeys() || checkRootFiles() || checkSUExist() || checkRootPackages(logger); + return checkRootA() || checkRootB() || checkRootC() || checkRootPackages(logger); } /** @@ -99,7 +126,7 @@ public boolean isDeviceRooted() { * * @return whether if it contains test keys or not */ - private boolean checkTestKeys() { + private boolean checkRootA() { final String buildTags = buildInfoProvider.getBuildTags(); return buildTags != null && buildTags.contains("test-keys"); } @@ -110,7 +137,7 @@ private boolean checkTestKeys() { * * @return whether if the root files exist or not */ - private boolean checkRootFiles() { + private boolean checkRootB() { for (final String path : rootFiles) { try { if (new File(path).exists()) { @@ -129,15 +156,15 @@ private boolean checkRootFiles() { * * @return whether su exists or not */ - private boolean checkSUExist() { - Process process = null; + private boolean checkRootC() { + Process p = null; final String[] su = {"/system/xbin/which", "su"}; try { - process = runtime.exec(su); + p = runtime.exec(su); try (final BufferedReader reader = - new BufferedReader(new InputStreamReader(process.getInputStream(), UTF_8))) { + new BufferedReader(new InputStreamReader(p.getInputStream(), UTF_8))) { return reader.readLine() != null; } } catch (IOException e) { @@ -145,8 +172,8 @@ private boolean checkSUExist() { } catch (Throwable e) { logger.log(SentryLevel.DEBUG, "Error when trying to check if SU exists.", e); } finally { - if (process != null) { - process.destroy(); + if (p != null) { + p.destroy(); } } return false; 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 88f3e126dee..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 @@ -5,15 +5,20 @@ import android.graphics.Bitmap; import android.graphics.Canvas; import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.view.PixelCopy; import android.view.View; +import android.view.Window; import androidx.annotation.Nullable; import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.android.core.BuildInfoProvider; -import io.sentry.util.thread.IMainThreadChecker; +import io.sentry.util.thread.IThreadChecker; import java.io.ByteArrayOutputStream; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -22,61 +27,164 @@ public class ScreenshotUtils { private static final long CAPTURE_TIMEOUT_MS = 1000; + // Used by Hybrid SDKs + /** + * @noinspection unused + */ public static @Nullable byte[] takeScreenshot( final @NotNull Activity activity, final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider) { - return takeScreenshot( - activity, AndroidMainThreadChecker.getInstance(), logger, buildInfoProvider); + return takeScreenshot(activity, AndroidThreadChecker.getInstance(), logger, buildInfoProvider); } + // Used by Hybrid SDKs + @SuppressLint("NewApi") public static @Nullable byte[] takeScreenshot( final @NotNull Activity activity, - final @NotNull IMainThreadChecker mainThreadChecker, + final @NotNull IThreadChecker threadChecker, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { + + final @Nullable Bitmap screenshot = + captureScreenshot(activity, threadChecker, logger, buildInfoProvider); + return compressBitmapToPng(screenshot, logger); + } + + public static @Nullable Bitmap captureScreenshot( + final @NotNull Activity activity, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { + return captureScreenshot( + activity, AndroidThreadChecker.getInstance(), logger, buildInfoProvider); + } + + @SuppressLint("NewApi") + public static @Nullable Bitmap captureScreenshot( + final @NotNull Activity activity, + final @NotNull IThreadChecker threadChecker, final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider) { + // We are keeping BuildInfoProvider param for compatibility, as it's being used by + // cross-platform SDKs - if (!isActivityValid(activity, buildInfoProvider) - || activity.getWindow() == null - || activity.getWindow().getDecorView() == null - || activity.getWindow().getDecorView().getRootView() == null) { + if (!isActivityValid(activity)) { logger.log(SentryLevel.DEBUG, "Activity isn't valid, not taking screenshot."); return null; } - final View view = activity.getWindow().getDecorView().getRootView(); + final @Nullable Window window = activity.getWindow(); + if (window == null) { + logger.log(SentryLevel.DEBUG, "Activity window is null, not taking screenshot."); + return null; + } + + final @Nullable View decorView = window.peekDecorView(); + if (decorView == null) { + logger.log(SentryLevel.DEBUG, "DecorView is null, not taking screenshot."); + return null; + } + + final @Nullable View view = decorView.getRootView(); + if (view == null) { + logger.log(SentryLevel.DEBUG, "Root view is null, not taking screenshot."); + return null; + } + if (view.getWidth() <= 0 || view.getHeight() <= 0) { logger.log(SentryLevel.DEBUG, "View's width and height is zeroed, not taking screenshot."); return null; } - try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { - // ARGB_8888 -> This configuration is very flexible and offers the best quality + try { final Bitmap bitmap = - Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888); + Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.RGB_565); - final Canvas canvas = new Canvas(bitmap); - if (mainThreadChecker.isMainThread()) { - view.draw(canvas); - } else { - final @NotNull CountDownLatch latch = new CountDownLatch(1); - activity.runOnUiThread( - () -> { - try { - view.draw(canvas); + final @NotNull CountDownLatch latch = new CountDownLatch(1); + + // Use Pixel Copy API on new devices, fallback to canvas rendering on older ones + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.O) { + + final HandlerThread thread = new HandlerThread("SentryScreenshot"); + thread.start(); + + boolean success = false; + final AtomicInteger copyResultCode = new AtomicInteger(-1); + try { + final Handler handler = new Handler(thread.getLooper()); + + PixelCopy.request( + window, + bitmap, + copyResult -> { + copyResultCode.set(copyResult); latch.countDown(); - } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Taking screenshot failed (view.draw).", e); - } - }); + }, + handler); + + 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); + } finally { + thread.quit(); + } + + if (!success) { + logger.log( + SentryLevel.WARNING, + "PixelCopy failed for screenshot capture (result=%d).", + copyResultCode.get()); + return null; + } + } else { + final Canvas canvas = new Canvas(bitmap); + if (threadChecker.isMainThread()) { + view.draw(canvas); + latch.countDown(); + } else { + activity.runOnUiThread( + () -> { + try { + view.draw(canvas); + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Taking screenshot failed (view.draw).", e); + } finally { + latch.countDown(); + } + }); + } + if (!latch.await(CAPTURE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { return null; } } + return bitmap; + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e); + } + return null; + } + /** + * Compresses the supplied Bitmap to a PNG byte array. After compression, the Bitmap will be + * recycled. + * + * @param bitmap The bitmap to compress + * @param logger the logger + * @return the Bitmap in PNG format, or null if the bitmap was null, recycled or compressing faile + */ + public static @Nullable byte[] compressBitmapToPng( + final @Nullable Bitmap bitmap, final @NotNull ILogger logger) { + if (bitmap == null || bitmap.isRecycled()) { + return null; + } + try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { // 0 meaning compress for small size, 100 meaning compress for max quality. // Some formats, like PNG which is lossless, will ignore the quality setting. bitmap.compress(Bitmap.CompressFormat.PNG, 0, byteArrayOutputStream); + bitmap.recycle(); if (byteArrayOutputStream.size() <= 0) { logger.log(SentryLevel.DEBUG, "Screenshot is 0 bytes, not attaching the image."); @@ -86,18 +194,12 @@ public class ScreenshotUtils { // screenshot png is around ~100-150 kb return byteArrayOutputStream.toByteArray(); } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e); + logger.log(SentryLevel.ERROR, "Compressing bitmap failed.", e); } return null; } - @SuppressLint("NewApi") - private static boolean isActivityValid( - final @NotNull Activity activity, final @NotNull BuildInfoProvider buildInfoProvider) { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - return !activity.isFinishing() && !activity.isDestroyed(); - } else { - return !activity.isFinishing(); - } + private static boolean isActivityValid(final @NotNull Activity activity) { + return !activity.isFinishing() && !activity.isDestroyed(); } } 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 f8957982e7f..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 @@ -13,27 +13,42 @@ import android.view.FrameMetrics; 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.UUID; 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; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @ApiStatus.Internal 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 SentryOptions options; - private @Nullable Handler handler; + + private final @NotNull ILogger logger; + private volatile @Nullable Handler handler; + private final @NotNull AutoClosableReentrantLock handlerLock = new AutoClosableReentrantLock(); private @Nullable WeakReference currentWindow; private final @NotNull Map listenerMap = new ConcurrentHashMap<>(); @@ -41,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, @@ -54,22 +73,41 @@ public SentryFrameMetricsCollector( this(context, options, buildInfoProvider, new WindowFrameMetricsManager() {}); } - @SuppressWarnings("deprecation") + @SuppressLint("NewApi") + public SentryFrameMetricsCollector( + final @NotNull Context context, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { + this(context, logger, buildInfoProvider, new WindowFrameMetricsManager() {}); + } + @SuppressLint({"NewApi", "DiscouragedPrivateApi"}) public SentryFrameMetricsCollector( final @NotNull Context context, final @NotNull SentryOptions options, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull WindowFrameMetricsManager windowFrameMetricsManager) { - Objects.requireNonNull(context, "The context is required"); - this.options = Objects.requireNonNull(options, "SentryOptions is required"); + this(context, options.getLogger(), buildInfoProvider, windowFrameMetricsManager); + } + + @SuppressWarnings("deprecation") + @SuppressLint({"NewApi", "PrivateApi", "DiscouragedPrivateApi"}) + public SentryFrameMetricsCollector( + final @NotNull Context context, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider, + final @NotNull WindowFrameMetricsManager windowFrameMetricsManager) { + final @NotNull Context appContext = + Objects.requireNonNull( + ContextUtils.getApplicationContext(context), "The context is required"); + this.logger = Objects.requireNonNull(logger, "Logger is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); this.windowFrameMetricsManager = Objects.requireNonNull(windowFrameMetricsManager, "WindowFrameMetricsManager is required"); // registerActivityLifecycleCallbacks is only available if Context is an AppContext - if (!(context instanceof Application)) { + if (!(appContext instanceof Application)) { return; } // FrameMetrics api is only available since sdk version N @@ -78,32 +116,43 @@ public SentryFrameMetricsCollector( } isAvailable = true; - HandlerThread handlerThread = - new HandlerThread("io.sentry.android.core.internal.util.SentryFrameMetricsCollector"); - handlerThread.setUncaughtExceptionHandler( - (thread, e) -> - options.getLogger().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. - ((Application) context).registerActivityLifecycleCallbacks(this); + ((Application) appContext).registerActivityLifecycleCallbacks(this); // 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 - new Handler(Looper.getMainLooper()).post(() -> choreographer = Choreographer.getInstance()); - // Let's get the last frame timestamp from the choreographer private field - try { - choreographerLastFrameTimeField = Choreographer.class.getDeclaredField("mLastFrameTimeNanos"); - choreographerLastFrameTimeField.setAccessible(true); - } catch (NoSuchFieldException e) { - options - .getLogger() - .log(SentryLevel.ERROR, "Unable to get the frame timestamp from the choreographer: ", e); - } + // The Choreographer instance should be initialized asynchronously on the main thread to avoid + // reflection during SDK init. + new Handler(Looper.getMainLooper()) + .post( + () -> { + try { + choreographer = Choreographer.getInstance(); + } catch (Throwable e) { + logger.log( + SentryLevel.ERROR, + "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); + } + }); + frameMetricsAvailableListener = (window, frameMetrics, dropCountSinceLastInvocation) -> { final long now = System.nanoTime(); @@ -112,9 +161,17 @@ public SentryFrameMetricsCollector( ? window.getContext().getDisplay().getRefreshRate() : window.getWindowManager().getDefaultDisplay().getRefreshRate(); + final long expectedFrameDuration = (long) (oneSecondInNanos / refreshRate); + final long cpuDuration = getFrameCpuDuration(frameMetrics); + + // if totalDurationNanos is smaller than expectedFrameTimeNanos, + // it means that the frame was drawn within it's time budget, thus 0 delay + 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; } @@ -127,15 +184,47 @@ public SentryFrameMetricsCollector( lastFrameStartNanos = startTime; lastFrameEndNanos = startTime + cpuDuration; + // Most frames take just a few nanoseconds longer than the optimal calculated + // duration. + // Therefore we subtract one, because otherwise almost all frames would be slow. + final boolean isSlow = + 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(lastFrameEndNanos, cpuDuration, refreshRate); + l.onFrameMetricCollected( + startTime, + lastFrameEndNanos, + cpuDuration, + delayNanos, + isSlow, + isFrozen, + refreshRate); } }; } + public static boolean isFrozen(long frameDuration) { + return frameDuration > frozenFrameThresholdNanos; + } + + public static boolean isSlow(long frameDuration, final long expectedFrameDuration) { + return frameDuration > expectedFrameDuration; + } + /** - * 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) { @@ -144,19 +233,7 @@ private long getFrameStartTimestamp(final @NotNull FrameMetrics frameMetrics) { return frameMetrics.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP); } - // Let's read the choreographer private field to get start timestamp of the frame, which - // uses System.nanoTime() under the hood - if (choreographer != null && choreographerLastFrameTimeField != null) { - try { - Long choreographerFrameStartTime = - (Long) choreographerLastFrameTimeField.get(choreographer); - if (choreographerFrameStartTime != null) { - return choreographerFrameStartTime; - } - } catch (IllegalAccessException ignored) { - } - } - return -1; + return getLastKnownFrameStartTimeNanos(); } /** @@ -165,6 +242,8 @@ private long getFrameStartTimestamp(final @NotNull FrameMetrics frameMetrics) { */ @RequiresApi(api = Build.VERSION_CODES.N) private long getFrameCpuDuration(final @NotNull FrameMetrics frameMetrics) { + // Inspired by JankStats + // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi24Impl.kt;l=74-79;drc=1de6215c6bd9e887e3d94556e9ac55cfb7b8c797 return frameMetrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION) + frameMetrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION) + frameMetrics.getMetric(FrameMetrics.ANIMATION_DURATION) @@ -207,12 +286,34 @@ public void onActivityDestroyed(@NotNull Activity activity) {} if (!isAvailable) { return null; } - final String uid = UUID.randomUUID().toString(); + 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; @@ -228,19 +329,20 @@ public void stopCollection(final @Nullable String listenerId) { @SuppressLint("NewApi") private void stopTrackingWindow(final @NotNull Window window) { - if (trackedWindows.contains(window)) { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N) { - try { - windowFrameMetricsManager.removeOnFrameMetricsAvailableListener( - window, frameMetricsAvailableListener); - } catch (Exception e) { - options - .getLogger() - .log(SentryLevel.ERROR, "Failed to remove frameMetricsAvailableListener", e); - } - } - trackedWindows.remove(window); - } + new Handler(Looper.getMainLooper()) + .post( + () -> { + try { + // Re-check if we should still remove the listener for this window + // in case trackCurrentWindow was called in the meantime + if (trackedWindows.remove(window)) { + windowFrameMetricsManager.removeOnFrameMetricsAvailableListener( + window, frameMetricsAvailableListener); + } + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Failed to remove frameMetricsAvailableListener", e); + } + }); } private void setCurrentWindow(final @NotNull Window window) { @@ -253,19 +355,132 @@ private void setCurrentWindow(final @NotNull Window window) { @SuppressLint("NewApi") private void trackCurrentWindow() { - Window window = currentWindow != null ? currentWindow.get() : null; + @Nullable Window window = currentWindow != null ? currentWindow.get() : null; if (window == null || !isAvailable) { return; } - if (!trackedWindows.contains(window) && !listenerMap.isEmpty()) { + if (listenerMap.isEmpty()) { + return; + } - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N && handler != null) { - trackedWindows.add(window); - windowFrameMetricsManager.addOnFrameMetricsAvailableListener( - window, frameMetricsAvailableListener, handler); + if (handler != null) { + // Ensure the addOnFrameMetricsAvailableListener is called on the main thread + new Handler(Looper.getMainLooper()) + .post( + () -> { + if (trackedWindows.add(window)) { + try { + windowFrameMetricsManager.addOnFrameMetricsAvailableListener( + window, frameMetricsAvailableListener, handler); + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Failed to add frameMetricsAvailableListener", e); + } + } + }); + } + } + + /** + * @return the last known time a frame was started, according to the Choreographer + */ + public long getLastKnownFrameStartTimeNanos() { + // Let's read the choreographer private field to get start timestamp of the frame, which + // uses System.nanoTime() under the hood + if (choreographer != null && choreographerLastFrameTimeField != null) { + try { + Long choreographerFrameStartTime = + (Long) choreographerLastFrameTimeField.get(choreographer); + if (choreographerFrameStartTime != null) { + return choreographerFrameStartTime; + } + } catch (IllegalAccessException ignored) { } } + 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 @@ -273,13 +488,26 @@ public interface FrameMetricsCollectorListener { /** * Called when a frame is collected. * + * @param frameStartNanos Start timestamp of a frame in nanoseconds relative to + * System.nanotime(). * @param frameEndNanos End timestamp of a frame in nanoseconds relative to System.nanotime(). * @param durationNanos Duration in nanoseconds of the time spent from the cpu on the main * thread to create the frame. - * @param refreshRate Refresh rate of the screen. + * @param delayNanos the frame delay, in nanoseconds. + * @param isSlow True if the frame is considered slow, rendering taking longer than the + * refresh-rate based budget, false otherwise. + * @param isFrozen True if the frame is considered frozen, rendering taking longer than 700ms, + * false otherwise. + * @param refreshRate the last known refresh rate when the frame was rendered. */ void onFrameMetricCollected( - final long frameEndNanos, final long durationNanos, final float refreshRate); + final long frameStartNanos, + final long frameEndNanos, + final long durationNanos, + final long delayNanos, + final boolean isSlow, + final boolean isFrozen, + final float refreshRate); } @ApiStatus.Internal @@ -289,6 +517,9 @@ default void addOnFrameMetricsAvailableListener( final @NotNull Window window, final @Nullable Window.OnFrameMetricsAvailableListener frameMetricsAvailableListener, final @Nullable Handler handler) { + if (frameMetricsAvailableListener == null) { + return; + } window.addOnFrameMetricsAvailableListener(frameMetricsAvailableListener, handler); } @@ -296,6 +527,9 @@ default void addOnFrameMetricsAvailableListener( default void removeOnFrameMetricsAvailableListener( final @NotNull Window window, final @Nullable Window.OnFrameMetricsAvailableListener frameMetricsAvailableListener) { + if (frameMetricsAvailableListener == null) { + return; + } window.removeOnFrameMetricsAvailableListener(frameMetricsAvailableListener); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter.java new file mode 100644 index 00000000000..c7a449d1ef8 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter.java @@ -0,0 +1,31 @@ +package io.sentry.android.core.performance; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class ActivityLifecycleCallbacksAdapter implements Application.ActivityLifecycleCallbacks { + + @Override + public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {} + + @Override + public void onActivityStarted(@NonNull Activity activity) {} + + @Override + public void onActivityResumed(@NonNull Activity activity) {} + + @Override + public void onActivityPaused(@NonNull Activity activity) {} + + @Override + public void onActivityStopped(@NonNull Activity activity) {} + + @Override + public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {} + + @Override + public void onActivityDestroyed(@NonNull Activity activity) {} +} 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 new file mode 100644 index 00000000000..accb56db0dc --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelper.java @@ -0,0 +1,140 @@ +package io.sentry.android.core.performance; + +import android.os.Looper; +import android.os.SystemClock; +import io.sentry.ISpan; +import io.sentry.Instrumenter; +import io.sentry.SentryDate; +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; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public class ActivityLifecycleSpanHelper { + private static final String APP_METRICS_ACTIVITIES_OP = "activity.load"; + + private final @NotNull String activityName; + + private @Nullable SentryDate onCreateStartTimestamp = null; + private @Nullable SentryDate onStartStartTimestamp = null; + private @Nullable ISpan onCreateSpan = null; + private @Nullable ISpan onStartSpan = null; + + public ActivityLifecycleSpanHelper(final @NotNull String activityName) { + this.activityName = activityName; + } + + public void setOnCreateStartTimestamp(final @NotNull SentryDate onCreateStartTimestamp) { + this.onCreateStartTimestamp = onCreateStartTimestamp; + } + + public void setOnStartStartTimestamp(final @NotNull SentryDate onStartStartTimestamp) { + this.onStartStartTimestamp = onStartStartTimestamp; + } + + public void createAndStopOnCreateSpan(final @Nullable ISpan parentSpan) { + if (onCreateStartTimestamp != null && parentSpan != null) { + onCreateSpan = + createLifecycleSpan(parentSpan, activityName + ".onCreate", onCreateStartTimestamp); + onCreateSpan.finish(); + } + } + + public void createAndStopOnStartSpan(final @Nullable ISpan parentSpan) { + if (onStartStartTimestamp != null && parentSpan != null) { + onStartSpan = + createLifecycleSpan(parentSpan, activityName + ".onStart", onStartStartTimestamp); + onStartSpan.finish(); + } + } + + public @Nullable ISpan getOnCreateSpan() { + return onCreateSpan; + } + + public @Nullable ISpan getOnStartSpan() { + return onStartSpan; + } + + public @Nullable SentryDate getOnCreateStartTimestamp() { + return onCreateStartTimestamp; + } + + public @Nullable SentryDate getOnStartStartTimestamp() { + return onStartStartTimestamp; + } + + public void saveSpanToAppStartMetrics() { + if (onCreateSpan == null || onStartSpan == null) { + return; + } + final @Nullable SentryDate onCreateFinishDate = onCreateSpan.getFinishDate(); + final @Nullable SentryDate onStartFinishDate = onStartSpan.getFinishDate(); + if (onCreateFinishDate == null || onStartFinishDate == null) { + return; + } + final long now = SystemClock.uptimeMillis(); + final @NotNull SentryDate nowDate = AndroidDateUtils.getCurrentSentryDateTime(); + final long onCreateShiftMs = + TimeUnit.NANOSECONDS.toMillis(nowDate.diff(onCreateSpan.getStartDate())); + final long onCreateStopShiftMs = + TimeUnit.NANOSECONDS.toMillis(nowDate.diff(onCreateFinishDate)); + final long onStartShiftMs = + TimeUnit.NANOSECONDS.toMillis(nowDate.diff(onStartSpan.getStartDate())); + final long onStartStopShiftMs = TimeUnit.NANOSECONDS.toMillis(nowDate.diff(onStartFinishDate)); + + ActivityLifecycleTimeSpan activityLifecycleTimeSpan = new ActivityLifecycleTimeSpan(); + activityLifecycleTimeSpan + .getOnCreate() + .setup( + onCreateSpan.getDescription(), + TimeUnit.NANOSECONDS.toMillis(onCreateSpan.getStartDate().nanoTimestamp()), + now - onCreateShiftMs, + now - onCreateStopShiftMs); + activityLifecycleTimeSpan + .getOnStart() + .setup( + onStartSpan.getDescription(), + TimeUnit.NANOSECONDS.toMillis(onStartSpan.getStartDate().nanoTimestamp()), + now - onStartShiftMs, + now - onStartStopShiftMs); + AppStartMetrics.getInstance().addActivityLifecycleTimeSpans(activityLifecycleTimeSpan); + } + + private @NotNull ISpan createLifecycleSpan( + final @NotNull ISpan parentSpan, + final @NotNull String description, + final @NotNull SentryDate startTimestamp) { + final @NotNull ISpan span = + parentSpan.startChild( + APP_METRICS_ACTIVITIES_OP, description, startTimestamp, Instrumenter.SENTRY); + setDefaultStartSpanData(span); + return span; + } + + public void clear() { + // in case the parentSpan isn't completed yet, we finish it as cancelled to avoid memory leak + if (onCreateSpan != null && !onCreateSpan.isFinished()) { + onCreateSpan.finish(SpanStatus.CANCELLED); + } + onCreateSpan = null; + if (onStartSpan != null && !onStartSpan.isFinished()) { + onStartSpan.finish(SpanStatus.CANCELLED); + } + onStartSpan = null; + } + + private void setDefaultStartSpanData(final @NotNull ISpan span) { + 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/ActivityLifecycleTimeSpan.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleTimeSpan.java new file mode 100644 index 00000000000..52688a28567 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleTimeSpan.java @@ -0,0 +1,29 @@ +package io.sentry.android.core.performance; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public class ActivityLifecycleTimeSpan implements Comparable { + private final @NotNull TimeSpan onCreate = new TimeSpan(); + private final @NotNull TimeSpan onStart = new TimeSpan(); + + public final @NotNull TimeSpan getOnCreate() { + return onCreate; + } + + public final @NotNull TimeSpan getOnStart() { + return onStart; + } + + @Override + public int compareTo(ActivityLifecycleTimeSpan o) { + final int onCreateDiff = + Long.compare(onCreate.getStartUptimeMs(), o.onCreate.getStartUptimeMs()); + if (onCreateDiff == 0) { + return Long.compare(onStart.getStartUptimeMs(), o.onStart.getStartUptimeMs()); + } else { + return onCreateDiff; + } + } +} 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 new file mode 100644 index 00000000000..eb1a10dd646 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -0,0 +1,786 @@ +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; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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; +import org.jetbrains.annotations.TestOnly; + +/** + * An in-memory representation for app-metrics during app start. As the SDK can't be initialized + * that early, we can't use transactions or spans directly. Thus simple TimeSpans are used and later + * transformed into SDK specific txn/span data structures. + * + *

This class is also responsible for - determining the app start type (cold, warm) - determining + * if the app was launched in foreground + */ +@ApiStatus.Internal +public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { + public interface HeadlessAppStartListener { + void onHeadlessAppStart(); + } + + public enum AppStartType { + UNKNOWN, + COLD, + WARM + } + + private static long CLASS_LOADED_UPTIME_MS = SystemClock.uptimeMillis(); + + private static volatile @Nullable AppStartMetrics instance; + public static final @NotNull AutoClosableReentrantLock staticLock = + new AutoClosableReentrantLock(); + + private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; + private @Nullable volatile Boolean appLaunchedInForeground; + private volatile long firstIdle = -1; + + private final @NotNull TimeSpan appStartSpan; + private final @NotNull TimeSpan sdkInitTimeSpan; + private final @NotNull TimeSpan applicationOnCreate; + private final @NotNull Map contentProviderOnCreates; + private final @NotNull List activityLifecycles; + private @Nullable ITransactionProfiler appStartProfiler = null; + private @Nullable IContinuousProfiler appStartContinuousProfiler = null; + private @Nullable TracesSamplingDecision appStartSamplingDecision = null; + private boolean isCallbackRegistered = false; + 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) { + try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { + if (instance == null) { + instance = new AppStartMetrics(); + } + } + } + //noinspection DataFlowIssue + return instance; + } + + public AppStartMetrics() { + appStartSpan = new TimeSpan(); + sdkInitTimeSpan = new TimeSpan(); + applicationOnCreate = new TimeSpan(); + contentProviderOnCreates = new HashMap<>(); + activityLifecycles = new ArrayList<>(); + } + + /** + * @return the app start span Uses Process.getStartUptimeMillis() as start timestamp, which + * requires API level 24+ + */ + public @NotNull TimeSpan getAppStartTimeSpan() { + return appStartSpan; + } + + /** + * @return the app start span Uses Process.getStartUptimeMillis() as start timestamp, which + * requires API level 24+ + */ + public @NotNull TimeSpan createProcessInitSpan() { + // AppStartSpan and CLASS_LOADED_UPTIME_MS can be modified at any time. + // So, we cannot cache the processInitSpan, but we need to create it when needed. + final @NotNull TimeSpan processInitSpan = new TimeSpan(); + processInitSpan.setup( + "Process Initialization", + appStartSpan.getStartTimestampMs(), + appStartSpan.getStartUptimeMs(), + CLASS_LOADED_UPTIME_MS); + return processInitSpan; + } + + /** + * @return the SDK init time span, as measured pre-performance-v2 Uses ContentProvider/Sdk init + * time as start timestamp + *

Data is filled by either {@link io.sentry.android.core.SentryPerformanceProvider} with a + * fallback to {@link io.sentry.android.core.SentryAndroid}. At least the start timestamp + * should always be set. + */ + public @NotNull TimeSpan getSdkInitTimeSpan() { + return sdkInitTimeSpan; + } + + public @NotNull TimeSpan getApplicationOnCreateTimeSpan() { + return applicationOnCreate; + } + + public void setAppStartType(final @NotNull AppStartType appStartType) { + this.appStartType = appStartType; + } + + public @NotNull AppStartType getAppStartType() { + 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 Boolean.TRUE.equals(appLaunchedInForeground); + } + + @VisibleForTesting + 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 + * + * @return A sorted list of all onCreate calls + */ + public @NotNull List getContentProviderOnCreateTimeSpans() { + final List spans = new ArrayList<>(contentProviderOnCreates.values()); + Collections.sort(spans); + return spans; + } + + public @NotNull List getActivityLifecycleTimeSpans() { + final List spans = new ArrayList<>(activityLifecycles); + Collections.sort(spans); + return spans; + } + + public void addActivityLifecycleTimeSpans(final @NotNull ActivityLifecycleTimeSpan timeSpan) { + activityLifecycles.add(timeSpan); + } + + 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(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 + */ + public @NotNull TimeSpan getAppStartTimeSpanWithFallback( + 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 && isAppLaunchedInForeground()) { + if (options.isEnablePerformanceV2()) { + // Only started when sdk version is >= N + final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); + if (appStartSpan.hasStarted() + && appStartSpan.getDurationMs() <= TimeUnit.MINUTES.toMillis(1)) { + return appStartSpan; + } + } + + // fallback: use sdk init time span, as it will always have a start time set + final @NotNull TimeSpan sdkInitTimeSpan = getSdkInitTimeSpan(); + if (sdkInitTimeSpan.hasStarted() + && sdkInitTimeSpan.getDurationMs() <= TimeUnit.MINUTES.toMillis(1)) { + return sdkInitTimeSpan; + } + } + + 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; + appStartSpan.reset(); + sdkInitTimeSpan.reset(); + applicationOnCreate.reset(); + contentProviderOnCreates.clear(); + activityLifecycles.clear(); + if (appStartProfiler != null) { + appStartProfiler.close(); + } + appStartProfiler = null; + if (appStartContinuousProfiler != null) { + appStartContinuousProfiler.close(true); + } + appStartContinuousProfiler = null; + appStartSamplingDecision = null; + 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() { + return appStartProfiler; + } + + public void setAppStartProfiler(final @Nullable ITransactionProfiler appStartProfiler) { + this.appStartProfiler = appStartProfiler; + } + + public @Nullable IContinuousProfiler getAppStartContinuousProfiler() { + return appStartContinuousProfiler; + } + + public void setAppStartContinuousProfiler( + final @Nullable IContinuousProfiler appStartContinuousProfiler) { + this.appStartContinuousProfiler = appStartContinuousProfiler; + } + + public void setAppStartSamplingDecision( + final @Nullable TracesSamplingDecision appStartSamplingDecision) { + this.appStartSamplingDecision = appStartSamplingDecision; + } + + public @Nullable TracesSamplingDecision getAppStartSamplingDecision() { + return appStartSamplingDecision; + } + + @TestOnly + @ApiStatus.Internal + 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 + * + * @param application The application object where onCreate was called on + * @noinspection unused + */ + public static void onApplicationCreate(final @NotNull Application application) { + final long now = SystemClock.uptimeMillis(); + + final @NotNull AppStartMetrics instance = getInstance(); + if (instance.applicationOnCreate.hasNotStarted()) { + instance.applicationOnCreate.setStartedAt(now); + instance.registerLifecycleCallbacks(application); + } + } + + /** + * Called by instrumentation + * + * @param application The application object where onCreate was called on + * @noinspection unused + */ + public static void onApplicationPostCreate(final @NotNull Application application) { + final long now = SystemClock.uptimeMillis(); + + final @NotNull AppStartMetrics instance = getInstance(); + if (instance.applicationOnCreate.hasNotStopped()) { + instance.applicationOnCreate.setDescription(application.getClass().getName() + ".onCreate"); + instance.applicationOnCreate.setStoppedAt(now); + } + } + + /** + * 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 + */ + public void registerLifecycleCallbacks(final @NotNull Application application) { + if (isCallbackRegistered) { + return; + } + isCallbackRegistered = true; + appLaunchedInForeground = null; + application.registerActivityLifecycleCallbacks(instance); + + 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, 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(); + // 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.setStartedAt(activityCreatedUptimeMillis); + CLASS_LOADED_UPTIME_MS = activityCreatedUptimeMillis; + contentProviderOnCreates.clear(); + applicationOnCreate.reset(); + } 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; + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + CurrentActivityHolder.getInstance().setActivity(activity); + + if (firstDrawDone.get()) { + return; + } + if (activity.getWindow() != null) { + FirstDrawDoneListener.registerForNextDraw( + activity, () -> onFirstFrameDrawn(), new BuildInfoProvider(NoOpLogger.getInstance())); + } else { + new Handler(Looper.getMainLooper()).post(() -> onFirstFrameDrawn()); + } + } + + @Override + public void onActivityResumed(@NonNull Activity activity) { + CurrentActivityHolder.getInstance().setActivity(activity); + } + + @Override + public void onActivityPaused(@NonNull Activity activity) { + CurrentActivityHolder.getInstance().clearActivity(activity); + } + + @Override + public void onActivityStopped(@NonNull Activity activity) { + CurrentActivityHolder.getInstance().clearActivity(activity); + } + + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + CurrentActivityHolder.getInstance().clearActivity(activity); + + int remainingActivities = activeActivitiesCounter.decrementAndGet(); + if (remainingActivities < 0) { + activeActivitiesCounter.set(0); + remainingActivities = 0; + } + // if the app is moving into background + // as the next onActivityCreated will treat it as a new warm app start + if (remainingActivities == 0 && !activity.isChangingConfigurations()) { + appStartType = AppStartType.WARM; + appLaunchedInForeground = true; + shouldSendStartMeasurements = true; + firstDrawDone.set(false); + } + } + + /** + * Called by instrumentation + * + * @param contentProvider The content provider where onCreate was called on + * @noinspection unused + */ + public static void onContentProviderCreate(final @NotNull ContentProvider contentProvider) { + final long now = SystemClock.uptimeMillis(); + + final TimeSpan measurement = new TimeSpan(); + measurement.setStartedAt(now); + getInstance().contentProviderOnCreates.put(contentProvider, measurement); + } + + /** + * Called by instrumentation + * + * @param contentProvider The content provider where onCreate was called on + * @noinspection unused + */ + public static void onContentProviderPostCreate(final @NotNull ContentProvider contentProvider) { + final long now = SystemClock.uptimeMillis(); + + final @Nullable TimeSpan measurement = + getInstance().contentProviderOnCreates.get(contentProvider); + if (measurement != null && measurement.hasNotStopped()) { + measurement.setDescription(contentProvider.getClass().getName() + ".onCreate"); + measurement.setStoppedAt(now); + } + } + + synchronized void onFirstFrameDrawn() { + if (!firstDrawDone.getAndSet(true)) { + final @NotNull AppStartMetrics appStartMetrics = getInstance(); + appStartMetrics.getSdkInitTimeSpan().stop(); + appStartMetrics.getAppStartTimeSpan().stop(); + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/TimeSpan.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/TimeSpan.java new file mode 100644 index 00000000000..eb631739728 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/TimeSpan.java @@ -0,0 +1,181 @@ +package io.sentry.android.core.performance; + +import android.os.SystemClock; +import io.sentry.DateUtils; +import io.sentry.SentryDate; +import io.sentry.SentryLongDate; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +/** + * A measurement for time critical components on a macro (ms) level. Based on {@link + * SystemClock#uptimeMillis()} to ensure linear time progression (as opposed to a syncable clock). + * To provide real world unix time information, the start uptime time is stored alongside the unix + * time. The stop unix time is artificial, it gets projected based on the start time + duration of + * the time span. + */ +@ApiStatus.Internal +public class TimeSpan implements Comparable { + + private @Nullable String description; + private long startUnixTimeMs; + private long startUptimeMs; + private long stopUptimeMs; + + public void setup( + final @Nullable String description, + final long startUnixTimeMs, + final long startUptimeMs, + final long stopUptimeMs) { + this.description = description; + this.startUnixTimeMs = startUnixTimeMs; + this.startUptimeMs = startUptimeMs; + this.stopUptimeMs = stopUptimeMs; + } + + /** Start the time span */ + public void start() { + startUptimeMs = SystemClock.uptimeMillis(); + startUnixTimeMs = System.currentTimeMillis(); + } + + /** + * @param uptimeMs the uptime in ms, provided by {@link SystemClock#uptimeMillis()} + */ + public void setStartedAt(final long uptimeMs) { + // TODO maybe sanity check? + this.startUptimeMs = uptimeMs; + + final long shiftMs = SystemClock.uptimeMillis() - startUptimeMs; + startUnixTimeMs = System.currentTimeMillis() - shiftMs; + } + + /** Stops the time span */ + public void stop() { + stopUptimeMs = SystemClock.uptimeMillis(); + } + + /** + * @param uptimeMs the uptime in ms, provided by {@link SystemClock#uptimeMillis()} + */ + public void setStoppedAt(final long uptimeMs) { + // TODO maybe sanity check? + stopUptimeMs = uptimeMs; + } + + public boolean hasStarted() { + return startUptimeMs != 0; + } + + public boolean hasNotStarted() { + return startUptimeMs == 0; + } + + public boolean hasStopped() { + return stopUptimeMs != 0; + } + + public boolean hasNotStopped() { + return stopUptimeMs == 0; + } + + /** + * @return the start timestamp of this measurement, as uptime, in ms + */ + public long getStartUptimeMs() { + return startUptimeMs; + } + + /** + * @return the start timestamp of this measurement, unix time, in ms + */ + public long getStartTimestampMs() { + return startUnixTimeMs; + } + + /** + * @return the start timestamp of this measurement, unix time + */ + public @Nullable SentryDate getStartTimestamp() { + if (hasStarted()) { + return new SentryLongDate(DateUtils.millisToNanos(getStartTimestampMs())); + } + return null; + } + + /** + * @return the start timestamp of this measurement, unix time, in seconds + */ + public double getStartTimestampSecs() { + return DateUtils.millisToSeconds(startUnixTimeMs); + } + + /** + * @return the projected stop timestamp of this measurement, based on the start timestamp and the + * duration. If the time span was not started 0 is returned, if the time span was not stopped + * the start timestamp is returned. + */ + public long getProjectedStopTimestampMs() { + if (hasStarted()) { + return startUnixTimeMs + getDurationMs(); + } + return 0; + } + + /** + * @return the projected stop timestamp + * @see #getProjectedStopTimestampMs() + */ + public double getProjectedStopTimestampSecs() { + return DateUtils.millisToSeconds(getProjectedStopTimestampMs()); + } + + /** + * @return the projected stop timestamp + * @see #getProjectedStopTimestampMs() + */ + public @Nullable SentryDate getProjectedStopTimestamp() { + if (hasStopped()) { + return new SentryLongDate(DateUtils.millisToNanos(getProjectedStopTimestampMs())); + } + return null; + } + + /** + * @return the duration of this measurement, in ms, or 0 if no end time is set + */ + public long getDurationMs() { + if (hasStopped()) { + return stopUptimeMs - startUptimeMs; + } else { + return 0; + } + } + + @TestOnly + public void setStartUnixTimeMs(long startUnixTimeMs) { + this.startUnixTimeMs = startUnixTimeMs; + } + + public @Nullable String getDescription() { + return description; + } + + public void setDescription(@Nullable final String description) { + this.description = description; + } + + public void reset() { + description = null; + startUptimeMs = 0; + stopUptimeMs = 0; + startUnixTimeMs = 0; + } + + @Override + public int compareTo(@NotNull final TimeSpan o) { + return Long.compare(startUnixTimeMs, o.startUnixTimeMs); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/WindowContentChangedCallback.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/WindowContentChangedCallback.java new file mode 100644 index 00000000000..79180a7bd5a --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/WindowContentChangedCallback.java @@ -0,0 +1,22 @@ +package io.sentry.android.core.performance; + +import android.view.Window; +import io.sentry.android.core.internal.gestures.WindowCallbackAdapter; +import org.jetbrains.annotations.NotNull; + +public class WindowContentChangedCallback extends WindowCallbackAdapter { + + private final @NotNull Runnable callback; + + public WindowContentChangedCallback( + final @NotNull Window.Callback delegate, final @NotNull Runnable callback) { + super(delegate); + this.callback = callback; + } + + @Override + public void onContentChanged() { + super.onContentChanged(); + callback.run(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java b/sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java new file mode 100644 index 00000000000..beb9ff8e8ed --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java @@ -0,0 +1,68 @@ +package io.sentry.android.core.util; + +import android.content.Context; +import io.sentry.util.LazyEvaluator; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Class that evaluates a function lazily. It means the evaluator function is called only when + * getValue is called, and it's cached. Same as {@link LazyEvaluator} but accepts Context as an + * argument for {@link AndroidLazyEvaluator#getValue}. + */ +@ApiStatus.Internal +public final class AndroidLazyEvaluator { + + private volatile @Nullable T value = null; + private final @NotNull AndroidEvaluator evaluator; + + /** + * Class that evaluates a function lazily. It means the evaluator function is called only when + * getValue is called, and it's cached. + * + * @param evaluator The function to evaluate. + */ + public AndroidLazyEvaluator(final @NotNull AndroidEvaluator evaluator) { + this.evaluator = evaluator; + } + + /** + * Executes the evaluator function and caches its result, so that it's called only once, unless + * resetValue is called. + * + * @return The result of the evaluator function. + */ + public @Nullable T getValue(final @NotNull Context context) { + if (value == null) { + synchronized (this) { + if (value == null) { + value = evaluator.evaluate(context); + } + } + } + + return value; + } + + public void setValue(final @Nullable T value) { + synchronized (this) { + this.value = value; + } + } + + /** + * Resets the internal value and forces the evaluator function to be called the next time + * getValue() is called. + */ + public void resetValue() { + synchronized (this) { + this.value = null; + } + } + + public interface AndroidEvaluator { + @Nullable + T evaluate(@NotNull Context context); + } +} diff --git a/sentry-android-core/src/main/res/drawable/sentry_edit_text_border.xml b/sentry-android-core/src/main/res/drawable/sentry_edit_text_border.xml new file mode 100644 index 00000000000..5615e318573 --- /dev/null +++ b/sentry-android-core/src/main/res/drawable/sentry_edit_text_border.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/sentry-android-core/src/main/res/drawable/sentry_logo_dark.xml b/sentry-android-core/src/main/res/drawable/sentry_logo_dark.xml new file mode 100644 index 00000000000..72bae31335f --- /dev/null +++ b/sentry-android-core/src/main/res/drawable/sentry_logo_dark.xml @@ -0,0 +1,9 @@ + + + diff --git a/sentry-android-core/src/main/res/drawable/sentry_oval_button_ripple_background.xml b/sentry-android-core/src/main/res/drawable/sentry_oval_button_ripple_background.xml new file mode 100644 index 00000000000..10e58ce27f6 --- /dev/null +++ b/sentry-android-core/src/main/res/drawable/sentry_oval_button_ripple_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/sentry-android-core/src/main/res/drawable/sentry_user_feedback_button_logo_24.xml b/sentry-android-core/src/main/res/drawable/sentry_user_feedback_button_logo_24.xml new file mode 100644 index 00000000000..4aab5d6c37b --- /dev/null +++ b/sentry-android-core/src/main/res/drawable/sentry_user_feedback_button_logo_24.xml @@ -0,0 +1,6 @@ + + + + + + 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 new file mode 100644 index 00000000000..370c37fa0e9 --- /dev/null +++ b/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + +