diff --git a/.claude/skills/create-java-pr/SKILL.md b/.claude/skills/create-java-pr/SKILL.md index e2a9b9bc785..17b5839d88a 100644 --- a/.claude/skills/create-java-pr/SKILL.md +++ b/.claude/skills/create-java-pr/SKILL.md @@ -7,7 +7,8 @@ description: Create a pull request in sentry-java. Use when asked to "create pr" Prepare local changes and create a pull request for the sentry-java repo. -**Required reading:** Before proceeding, read `.cursor/rules/pr.mdc` for the full PR and stacked PR workflow details. That file is the source of truth for PR conventions, stack comment format, branch naming, and merge strategy. +**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 @@ -66,7 +67,7 @@ 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 `.cursor/rules/pr.mdc` § "Creating the Collection Branch"), then branch the PR off it. For subsequent PRs, branch off the previous stack branch. Use the naming conventions from `.cursor/rules/pr.mdc` § "Branch Naming". +**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. @@ -88,7 +89,13 @@ Check for uncommitted changes: git status --porcelain ``` -If there are uncommitted changes, invoke the `sentry-skills:commit` skill to stage and commit them following Sentry conventions. +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: @@ -114,39 +121,28 @@ If the push fails due to diverged history, ask the user how to proceed rather th ## Step 5: Create PR -Invoke the `sentry-skills:create-pr` skill to create a draft PR. When providing the PR body, use the repo's PR template structure from `.github/pull_request_template.md`: +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): ``` -## :scroll: Description - - -## :bulb: Motivation and Context - - -## :green_heart: How did you test it? - - -## :pencil: Checklist -- [ ] 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. - -## :crystal_ball: Next steps +(): ``` -Fill in each section based on the changes being PR'd. Check any checklist items that apply. +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 `.cursor/rules/pr.mdc` § "PR Title Naming"). -- Include the stack list at the top of the PR body, before the `## :scroll: Description` section (see `.cursor/rules/pr.mdc` § "Stack List in PR Description" for the format). -- Add a merge method reminder at the very end of the PR body (see `.cursor/rules/pr.mdc` § "Stack List in PR Description" for the exact text). This only applies to stack PRs, not the collection branch PR. +- 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. @@ -154,13 +150,9 @@ Then continue to Step 5.5 (stacked PRs only) or Step 6. 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 `.cursor/rules/pr.mdc` § "Stack List in PR Description". +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". -**Important:** When updating PR bodies, never use shell redirects (`>`, `>>`) or pipes (`|`) or compound commands (`&&`). These create compound shell expressions that won't match permission patterns. Instead: -- Use `gh pr view --json body --jq '.body'` to get the body (output returned directly) -- Use the `Write` tool to save it to a temp file -- Use the `Edit` tool to modify the temp file -- Use `gh pr edit --body-file /tmp/pr-body.md` to update +Edit each body using the procedure in § "Editing PR Descriptions" below. ## Step 6: Update Changelog @@ -190,6 +182,8 @@ Add an entry to `CHANGELOG.md` under the `## Unreleased` section. 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 @@ -210,8 +204,14 @@ git push ### No changelog needed -If no changelog entry is needed, add `#skip-changelog` to the PR description to disable the changelog CI check: +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. Get the current body: `gh pr view --json body --jq '.body'` -2. Use the `Write` tool to save the output to `/tmp/pr-body.md`, appending `\n#skip-changelog\n` at the end -3. Update: `gh pr edit --body-file /tmp/pr-body.md` +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 index bdef12364f3..7e6ddd37294 100644 --- a/.claude/skills/test/SKILL.md +++ b/.claude/skills/test/SKILL.md @@ -40,9 +40,9 @@ Determine the Gradle test task: | Module Pattern | Test Task | |---------------|-----------| -| `sentry-android-*` | `testDebugUnitTest` | -| `sentry-compose*` | `testDebugUnitTest` | -| `*-android` | `testDebugUnitTest` | +| `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: diff --git a/.cursor/rules/coding.mdc b/.cursor/rules/coding.mdc deleted file mode 100644 index fbcda27b120..00000000000 --- a/.cursor/rules/coding.mdc +++ /dev/null @@ -1,53 +0,0 @@ ---- -alwaysApply: true -description: Cursor Coding Rules ---- - -# Contributing Rules for Agents - -## Overview - -sentry-java is the Java and Android SDK for Sentry. This repository contains the source code and examples for SDK usage. - -## Tech Stack - -- **Language**: Java and Kotlin -- **Build Framework**: Gradle - -## Key Commands - -```bash -# Format code and regenerate .api files -./gradlew spotlessApply apiDump - -# Run all tests and linter -./gradlew check - -# Run unit tests for a specific file -./gradlew '::testDebugUnitTest' --tests="**" --info -``` - -## Contributing Guidelines - -1. Follow existing code style and language -2. Do not modify the API files (e.g. sentry.api) manually, instead run `./gradlew apiDump` to regenerate them -3. Write comprehensive tests. For assertions in new unit tests, prefer Google Truth (`com.google.common.truth.Truth.assertThat`) over `kotlin.test`/JUnit assertions; keep `kotlin.test` for test structure like `@Test` and `assertFailsWith`. Add `testImplementation(libs.google.truth)` to a module's `build.gradle.kts` if it isn't already present. -4. New features should always be opt-in by default, extend `SentryOptions` or similar Option classes with getters and setters to enable/disable a new feature -5. Consider backwards compatibility - -## Coding rules - -1. First think through the problem, read the codebase for relevant files, and propose a plan -2. Before you begin working, check in with me and I will verify the plan -3. Then, begin working on the todo items, marking them as complete as you go -4. Please do not describe every step of the way and just give me a high level explanation of what changes you made -5. Make every task and code change you do as simple as possible. We want to avoid making any massive or complex changes. Every change should impact as little code as possible. Everything is about simplicity. -6. Once you're done, format the code and regenerate the .api files using the following command `./gradlew spotlessApply apiDump` -7. As a last step, git stage the relevant files and propose (but not execute) a single git commit command (e.g. `git commit -m ""`) - - -## Useful Resources - -- Main SDK documentation: https://develop.sentry.dev/sdk/overview/ -- Internal contributing guide: https://docs.sentry.io/internal/contributing/ -- Git commit messages conventions: https://develop.sentry.dev/engineering-practices/commit-messages/ diff --git a/.cursor/rules/overview_dev.mdc b/.cursor/rules/overview_dev.mdc deleted file mode 100644 index b837be34add..00000000000 --- a/.cursor/rules/overview_dev.mdc +++ /dev/null @@ -1,133 +0,0 @@ ---- -alwaysApply: true -description: Sentry Java SDK - Development Rules Overview ---- - -# Sentry Java SDK Development Rules - -## Always Applied Rules - -These rules are automatically included in every conversation: -- **coding.mdc**: General contributing guidelines, build commands, and workflow rules - -## Domain-Specific Rules (Fetch Only When Needed) - -Use the `fetch_rules` tool to include these rules when working on specific areas: - -### Core SDK Functionality -- **`api`**: Use when working with: - - Adding or modifying public API surface - - Binary compatibility, `.api` files, `apiDump` - - Understanding which classes to modify for new API (interfaces, implementations, no-ops, adapters) - - `IScope`, `IScopes`, `Sentry` static API - - Attributes, logging API, protocol classes - -- **`options`**: Use when working with: - - Adding or modifying SDK options (`SentryOptions`, namespaced options) - - External options (`ExternalOptions`, `sentry.properties`, environment variables) - - Android manifest metadata (`ManifestMetadataReader`) - - Spring Boot properties (`SentryProperties`) - -- **`scopes`**: Use when working with: - - Hub/Scope management, forking, or lifecycle - - `Sentry.getCurrentScopes()`, `pushScope()`, `withScope()` - - `ScopeType` (GLOBAL, ISOLATION, CURRENT) - - Thread-local storage, scope bleeding issues - - Migration from Hub API (v7 → v8) - -- **`deduplication`**: Use when working with: - - Duplicate event detection/prevention - - `DuplicateEventDetectionEventProcessor` - - `enableDeduplication` option - -- **`offline`**: Use when working with: - - Caching, envelope storage/retrieval - - Network failure handling, retry logic - - `AsyncHttpTransport`, `EnvelopeCache` - - Rate limiting, cache rotation - - Android vs JVM caching differences - -- **`feature_flags`**: Use when working with: - - Feature flag tracking and evaluation - - `addFeatureFlag()`, `getFeatureFlags()` methods - - `FeatureFlagBuffer`, `SpanFeatureFlagBuffer`, `FeatureFlag` protocol - - `maxFeatureFlags` option and buffer management - - Feature flag merging across scope types - - Scope-based vs span-based feature flag APIs - - Scope-based API: `Sentry`, `IScopes`, `IScope` APIs - - Span-based API: `ISpan`, `ITransaction` APIs - - Integrations: LaunchDarkly (Android/JVM), OpenFeature (JVM) - -- **`metrics`**: Use when working with: - - Metrics API (`Sentry.metrics()`, `IScopes.metrics()`) - - `IMetricsApi`, `MetricsApi` implementation - - Metrics types: `count`, `distribution`, `gauge` - - `MetricsBatchProcessor`, batching and queue management - - `SentryMetricsEvent`, `SentryMetricsEvents` - - `SentryOptions.getMetrics()`, `beforeSend` callback - -- **`queues`**: Use when working with: - - Sentry Queues product data or messaging span conventions - - Queue tracing spans/transactions (`queue.publish`, `queue.process`) - - `enableQueueTracing` option and `sentry.enable-queue-tracing` - - Kafka instrumentation (`sentry-kafka`, `SentryKafkaProducer`, `SentryKafkaConsumerTracing`) - - Spring Kafka queue auto-instrumentation and `SentryKafkaRecordInterceptor` - - Messaging span data (`messaging.system`, `messaging.destination.name`, receive latency, retry count) - - `sentry-task-enqueued-time` header and distributed trace propagation through queues - -- **`continuous_profiling_jvm`**: Use when working with: - - JVM continuous profiling (`sentry-async-profiler` module) - - `IContinuousProfiler`, `JavaContinuousProfiler` - - `ProfileChunk`, chunk rotation, JFR file handling - - `ProfileLifecycle` (MANUAL vs TRACE modes) - - async-profiler integration, ServiceLoader discovery - - Rate limiting, offline caching, scopes integration - -- **Android profiling**: There is currently no dedicated rule for this area yet. - - Inspect the relevant `sentry-android-core` profiling code directly - - Fetch other related rules as needed (for example `options`, `offline`, or `api`) - -### Integration & Infrastructure -- **`opentelemetry`**: Use when working with: - - OpenTelemetry modules (`sentry-opentelemetry-*`) - - Agent vs agentless configurations - - Span processing, sampling, context propagation - - `OtelSpanFactory`, `SentrySpanExporter` - - Tracing, distributed tracing - -- **`new_module`**: Use when adding a new integration or sample module - -### Workflow -- **`pr`**: Use when working with: - - Creating pull requests - - Stacked PRs, PR naming, stack comments - - PR changelog entries - - Merging or syncing stacked branches - -### Testing -- **`e2e_tests`**: Use when working with: - - System tests, sample applications - - `system-test-runner.py`, mock Sentry server - - End-to-end test infrastructure - - CI system test workflows - -## Usage Guidelines - -1. **Start minimal**: Only include `coding.mdc` (auto-applied) for general tasks -2. **Fetch on-demand**: Use `fetch_rules ["rule_name"]` when you identify specific domain work -3. **Multiple rules**: Fetch multiple rules if task spans domains (e.g., `["scopes", "opentelemetry"]` for tracing scope issues) -4. **Context clues**: Look for these keywords in requests to determine relevant rules: - - Public API/apiDump/.api files/binary compatibility/new method → `api` - - Options/SentryOptions/ExternalOptions/ManifestMetadataReader/sentry.properties → `options` - - Scope/Hub/forking → `scopes` - - Duplicate/dedup → `deduplication` - - OpenTelemetry/tracing/spans → `opentelemetry` - - new module/integration/sample → `new_module` - - Cache/offline/network → `offline` - - System test/e2e/sample → `e2e_tests` - - Feature flag/addFeatureFlag/flag evaluation → `feature_flags` - - Metrics/count/distribution/gauge → `metrics` - - Queues/queue tracing/Kafka/Spring Kafka/queue.publish/queue.process/enableQueueTracing/messaging spans → `queues` - - PR/pull request/stacked PR/stack → `pr` - - JVM continuous profiling/async-profiler/JFR/ProfileChunk → `continuous_profiling_jvm` - - Android continuous profiling/AndroidProfiler/frame metrics/method tracing → no dedicated rule yet; inspect the code directly diff --git a/.cursor/rules/pr.mdc b/.cursor/rules/pr.mdc deleted file mode 100644 index 3a37ecc15f8..00000000000 --- a/.cursor/rules/pr.mdc +++ /dev/null @@ -1,264 +0,0 @@ ---- -alwaysApply: false -description: Pull request creation, stacked PRs, and PR workflow ---- - -# Pull Request Rules - -## Creating a Pull Request - -### Step 1: Ensure Feature Branch - -If on `main`, create and switch to a new branch: - -```bash -git checkout -b / -``` - -Branch names use `feat/`, `fix/`, `ref/`, etc. matching the commit type. - -### Step 2: Format Code and Regenerate API Files - -```bash -./gradlew spotlessApply apiDump -``` - -This is **required** before every PR. Fix any failures before continuing. - -### Step 3: Commit Changes - -Use `git status --porcelain` to review changes. Ignore files only relevant for local testing (hardcoded debug toggles, sample app config, `.env` files). Restore those with `git checkout -- `. - -Follow [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` - -- Use imperative present tense ("add" not "added") -- Capitalize subject, no trailing period -- Keep under 100 characters - -### Step 4: Push - -```bash -git push -u origin HEAD -``` - -If push fails due to diverged history, ask the user — do not force-push. - -### Step 5: Create PR - -Create a draft PR using the repo's PR template: - -```markdown -## :scroll: Description - - -## :bulb: Motivation and Context - - -## :green_heart: How did you test it? - - -## :pencil: Checklist -- [ ] 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. - -## :crystal_ball: Next steps -``` - -### Step 6: Update Changelog - -Add an entry to `CHANGELOG.md` under `## Unreleased` in the appropriate subsection: - -| Change Type | Subsection | -|---|---| -| New feature | `### Features` | -| Bug fix | `### Fixes` | -| Refactoring, internal cleanup | `### Internal` | -| Dependency update | `### Dependencies` | - -Entry format: - -```markdown -- ([#](https://github.com/getsentry/sentry-java/pull/)) -``` - -**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. - -Commit changelog separately: - -```bash -git add CHANGELOG.md && git commit -m "changelog" && git push -``` - -### PR Title Format - -Follow the commit message format: - -``` -(): -``` - -Examples: -- `feat(core): Add structured logging support` -- `fix(android): Prevent crash on API 21 when registering receiver` - ---- - -## Stacked PRs - -Stacked PRs split a large feature into small, easy-to-review PRs where each builds on the previous one. This follows the same concept as the [Graphite](https://graphite.dev/) stacking workflow. - -### Structure - -``` -main ← collection-branch ← stack-pr-1 ← stack-pr-2 ← stack-pr-3 ← ... -``` - -- A **collection branch** is created from `main` and targets `main`. It serves as the base for the entire stack. -- The first PR in the stack targets the collection branch (not `main`). -- Each subsequent PR targets the previous stack PR's branch as its base. -- Each PR contains only incremental changes on top of the previous one. - -The collection branch exists so that individual stack PRs can be **merge-committed** (not squashed). PRs targeting `main` use squash merging, but that causes repeated merge conflicts when syncing the stack. Merge commits on non-`main` branches avoid this. The collection branch itself is squash-merged into `main` at the end. - -### Branch Naming - -Prefer a shared prefix for the feature, with descriptive suffixes per PR. The collection branch uses the shared prefix. The type prefix (`feat/`, `fix/`, etc.) may vary depending on the nature of each PR's changes: - -``` -feat/scope-attributes # collection branch → targets main -feat/scope-attributes-api # PR 1 → targets collection branch -feat/scope-attributes-logger # PR 2 → targets PR 1 -fix/attribute-type-detection # PR 3 (fix, different name — that's fine) → targets PR 2 -``` - -### 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` -- `feat(samples): [Global Attributes 3] Showcase scope attributes in Spring Boot 4 sample` - -### Finding All PRs in a Stack - -Do **not** rely on branch name patterns — later PRs in a stack may use different prefixes or naming. Instead: - -1. Find the PR for the current branch: - ```bash - gh pr list --head "$(git branch --show-current)" --json number,title,baseRefName --jq '.[0]' - ``` -2. Read the PR description — the stack list is at the top of the body. -3. If there is no stack list yet, walk the chain in both directions: - ```bash - # Find the PR whose head branch is the current PR's base (go up) - gh pr list --head --json number,title,baseRefName - - # Find PRs whose base branch is the current PR's head (go down) - gh pr list --base --json number,title,headRefName - ``` - Repeat until you reach the collection branch going up and find no more PRs going down. - -### Creating the Collection Branch - -Before the first stacked PR, create the collection branch with an empty commit (so GitHub allows opening a PR) and create the collection PR: - -```bash -git checkout main -git checkout -b feat/ -git commit --allow-empty -m "collection: " -git push -u origin HEAD -gh pr create --base main --draft --title "(): " --body "Collection PR for the stack. Squash-merge this once all stack PRs are merged." -``` - -**CRITICAL: Do NOT manually update the collection branch.** Never merge, fast-forward, or push stack branch commits into the collection branch. The collection branch stays at its initial position (the empty commit on `main`) until the user merges individual stack PRs into it one by one through GitHub. If you fast-forward the collection branch to include stack commits, GitHub will auto-merge and delete all stack PR branches, destroying the entire stack. - -### Creating a New Stacked PR - -1. Start from the tip of the previous stack branch (or the collection branch for the first PR). -2. Create a new branch, make changes, format, commit, and push. -3. Create the PR with `--base ` (the collection branch for the first PR): - ```bash - gh pr create --base feat/previous-branch --draft --title "(): [ ] " --body "..." - ``` -4. Add the stack list to the top of the new PR's description and update it on all existing PRs in the stack (see below). - -### 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 new PR is added, update the description on **all** PRs in the stack and on the collection branch PR. - -Format: - -```markdown -## PR Stack () - -- #5118 -- #5120 -- #5121 - ---- -``` - -No status column — GitHub already shows that. The `---` separates the stack list from the rest of the PR description. - -**Merge method reminder:** On stack PRs (not the collection branch PR), add the following line at the very end of the PR description: - -```markdown -> ⚠️ **Merge this PR using a merge commit** (not squash). Only the collection branch is squash-merged into main. -``` - -This does not apply to standalone PRs or the collection branch PR. - -To update the PR description, use `--body-file` to avoid shell quoting issues with special characters in the body. - -**Important:** Do not use shell redirects (`>`, `>>`, `|`) or compound commands (`&&`, `||`). These create compound shell expressions that won't match permission patterns. Instead, use the `Write` and `Edit` tools for file manipulation: - -1. Read the current body with `gh pr view --json body --jq '.body'` (the output is returned directly — use the `Write` tool to save it to `/tmp/pr-body.md`) -2. Use the `Edit` tool to prepend or replace the stack list section in `/tmp/pr-body.md` -3. Update the description: `gh pr edit --body-file /tmp/pr-body.md` - -### Merging Stacked PRs (done by the user, not the agent) - -Individual stack PRs are merged in order from bottom to top (PR 1 first, then PR 2, etc.) using **merge commits** (not squash). After each merge, the next PR's base automatically becomes the merged branch's target. GitHub handles rebasing onto the new base. - -Once all stack PRs are merged into the collection branch, the collection PR is **squash-merged** into `main`. This gives `main` a clean single commit for the entire feature. - -**Do not merge PRs.** Only the user merges PRs. - -### Syncing the Stack - -When a base PR changes (e.g. after addressing review feedback on PR 1), merge the changes forward through the stack **between adjacent stack PR branches only**: - -```bash -# On the branch for PR 2 -git checkout feat/scope-attributes-logger -git merge feat/scope-attributes-api -git push - -# On the branch for PR 3 -git checkout feat/scope-attributes-sample -git merge feat/scope-attributes-logger -git push -``` - -**Never merge into the collection branch.** Syncing only happens between stack PR branches. The collection branch is untouched until the user merges PRs through GitHub. - -Prefer merge over rebase — it preserves commit history, doesn't invalidate existing review comments, and avoids the need for force-pushing. Only rebase if explicitly requested. - -**Never amend or force-push stack branches.** Do not use `git commit --amend`, `--force`, or `--force-with-lease` on branches that are part of a stack. Amending a pushed commit requires a force-push, which can cause GitHub to auto-merge or auto-close other PRs in the stack. If a commit needs fixing, add a new fixup commit instead. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e4a12165077..baa2dad44a2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,5 @@ ## :scroll: Description - + ## :bulb: Motivation and Context @@ -12,6 +12,10 @@ --> ## :green_heart: How did you test it? + ## :pencil: Checklist diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 1d2a3bbb567..4dc779ce812 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - agp: [ '8.7.0','8.8.0','8.9.0' ] + agp: [ '9.0.0', '9.1.1', '9.2.1' ] integrations: [ true, false ] name: AGP Matrix Release - AGP ${{ matrix.agp }} - Integrations ${{ matrix.integrations }} @@ -28,18 +28,18 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e396ef97175..ef9aa7cfc36 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,13 +19,13 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' @@ -38,7 +38,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -46,13 +46,13 @@ jobs: run: make preMerge - name: Install Sentry CLI - run: curl -sL https://sentry.io/get-cli/ | bash + uses: getsentry/action-setup-cli@70d7e587b84c2e78cf4d37cd33d7b74fb3729c1b # v1 - name: Upload Snapshots to Sentry # Skip on PRs from forks, which don't have access to the upload secret if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} run: | - sentry-cli build snapshots ./sentry-android-core/build/test-snapshots \ + sentry-cli snapshots upload ./sentry-android-core/build/test-snapshots \ --app-id sentry-android-core env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml deleted file mode 100644 index 27f1d0006e4..00000000000 --- a/.github/workflows/changelog-preview.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Changelog Preview -on: - pull_request: - types: - - opened - - synchronize - - reopened - - edited - - labeled - - unlabeled -permissions: - contents: write - pull-requests: write - statuses: write - -jobs: - changelog-preview: - uses: getsentry/craft/.github/workflows/changelog-preview.yml@cdb657d4bbc70cd497876ad158984b4d345a48ae # v2 - secrets: inherit diff --git a/.github/workflows/changes-in-high-risk-code.yml b/.github/workflows/changes-in-high-risk-code.yml index 864be8140ad..44d65924209 100644 --- a/.github/workflows/changes-in-high-risk-code.yml +++ b/.github/workflows/changes-in-high-risk-code.yml @@ -16,7 +16,7 @@ jobs: high_risk_code: ${{ steps.changes.outputs.high_risk_code }} high_risk_code_files: ${{ steps.changes.outputs.high_risk_code_files }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get changed files id: changes uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 diff --git a/.github/workflows/check-tombstone-proto-schema.yml b/.github/workflows/check-tombstone-proto-schema.yml index 3e30f97e45e..0190865250e 100644 --- a/.github/workflows/check-tombstone-proto-schema.yml +++ b/.github/workflows/check-tombstone-proto-schema.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - 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 0c4fda8cfd3..57e2e4a1073 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,23 +20,23 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # pin@v2 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # pin@v2 with: languages: 'java' @@ -45,4 +45,4 @@ jobs: ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # pin@v2 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # pin@v2 diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 0604179edda..a7be2bdb001 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,16 +11,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - name: Set up Java - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + 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) diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index e860ef74f14..7f638963fc0 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -8,18 +8,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index f6de0912fd4..ad33bb93e7a 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -9,18 +9,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - name: Generate Aggregate Javadocs run: | diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index a2728bc9694..66e4498dcb5 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -27,18 +27,18 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -48,7 +48,7 @@ jobs: run: make assembleBenchmarks - name: Run All Tests in SauceLab - uses: saucelabs/saucectl-run-action@bc81720eb01738d9c664b07fe42621bd0014283f # pin@v3 + uses: saucelabs/saucectl-run-action@283660aa934c02723c497efa151d582a3acc5801 # pin@v3 if: github.event_name != 'pull_request' && env.SAUCE_USERNAME != null env: GITHUB_TOKEN: ${{ github.token }} @@ -58,7 +58,7 @@ jobs: config-file: .sauce/sentry-uitest-android-benchmark.yml - name: Run one test in SauceLab - uses: saucelabs/saucectl-run-action@bc81720eb01738d9c664b07fe42621bd0014283f # pin@v3 + uses: saucelabs/saucectl-run-action@283660aa934c02723c497efa151d582a3acc5801 # pin@v3 if: github.event_name == 'pull_request' && env.SAUCE_USERNAME != null env: GITHUB_TOKEN: ${{ github.token }} @@ -77,18 +77,18 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index e8e0df16285..3e76c951f86 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -20,10 +20,10 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Java Version - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: "temurin" java-version: "17" @@ -36,7 +36,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 6eeaf4e919d..dd99f8c6b7c 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -15,7 +15,7 @@ env: BUILD_PATH: "build/outputs/apk/release" APK_NAME: "sentry-uitest-android-critical-release.apk" APK_ARTIFACT_NAME: "sentry-uitest-android-critical-release" - MAESTRO_VERSION: "2.1.0" + MAESTRO_VERSION: "2.7.0" jobs: build: @@ -27,16 +27,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -63,21 +63,30 @@ jobs: 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Enable KVM run: | @@ -85,6 +94,22 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + # The runner ships an outdated avdmanager that writes target=android-0 into the + # AVD config for minor-versioned packages (android-37.x), so the emulator clamps + # to API 3 and boots misconfigured. Update cmdline-tools so avdmanager parses it. + # See https://github.com/ReactiveCircus/android-emulator-runner/issues/482 + - name: Update SDK cmdline-tools + id: cmdline-tools + run: | + SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" + yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --install "cmdline-tools;latest" > /dev/null + # sdkmanager won't overwrite the preinstalled dir, so it installs to latest-2. + if [ -d "$SDK/cmdline-tools/latest-2" ]; then + rm -rf "$SDK/cmdline-tools/latest" + mv "$SDK/cmdline-tools/latest-2" "$SDK/cmdline-tools/latest" + fi + echo "version=$("$SDK/cmdline-tools/latest/bin/sdkmanager" --version 2>/dev/null | grep -Eo '^[0-9][0-9.]*' | head -1)" >> "$GITHUB_OUTPUT" + - name: AVD cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: avd-cache @@ -92,7 +117,9 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-api-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }} + # Keyed on memory and the cmdline-tools version so incompatible snapshots + # and AVDs created by the old, broken avdmanager are invalidated automatically. + key: avd-api-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }}-memory${{ matrix.memory }}-tools${{ steps.cmdline-tools.outputs.version }} - name: Create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' @@ -105,7 +132,7 @@ jobs: force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -memory 4096 -no-window -gpu auto -noaudio -no-boot-anim -camera-back none + emulator-options: -memory ${{ matrix.memory }} -no-window -gpu auto -noaudio -no-boot-anim -camera-back none disk-size: 4096M script: echo "Generated AVD snapshot for caching." @@ -129,7 +156,7 @@ jobs: force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -memory 4096 -no-window -gpu auto -noaudio -no-boot-anim -camera-back none -no-snapshot-save + emulator-options: -memory ${{ matrix.memory }} -no-window -gpu auto -noaudio -no-boot-anim -camera-back none -no-snapshot-save script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index ed3a72ce7fc..043c4730f32 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -22,18 +22,18 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -43,7 +43,7 @@ jobs: run: make assembleUiTests - name: Install SauceLabs CLI - uses: saucelabs/saucectl-run-action@bc81720eb01738d9c664b07fe42621bd0014283f # pin@v4.4.0 + uses: saucelabs/saucectl-run-action@283660aa934c02723c497efa151d582a3acc5801 # pin@v4.5.0 env: GITHUB_TOKEN: ${{ github.token }} with: @@ -75,7 +75,7 @@ jobs: - name: Install Sentry CLI if: ${{ !cancelled() && env.SAUCE_USERNAME != null }} - run: curl -sL https://sentry.io/get-cli/ | bash + 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 @@ -86,7 +86,7 @@ jobs: if [ ${#pngs[@]} -gt 0 ]; then mkdir -p replay-snapshots cp "${pngs[@]}" replay-snapshots/ - sentry-cli build snapshots ./replay-snapshots \ + sentry-cli snapshots upload ./replay-snapshots \ --app-id sentry-android-replay else echo "No replay snapshot files found, skipping upload" diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index e1a334e48a2..a1f47577c5f 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -15,18 +15,18 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - name: Build artifacts run: make publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51e9987cc19..ce4bdb23b9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,14 +27,14 @@ jobs: with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ steps.token.outputs.token }} # Needs to be set, otherwise git describe --tags will fail with: No names found, cannot describe anything fetch-depth: 0 submodules: 'recursive' - name: Prepare release - uses: getsentry/craft@cdb657d4bbc70cd497876ad158984b4d345a48ae # v2 + uses: getsentry/craft@aeb16753a1764f3ef0768c03c499e3d2e4b7227c # v2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 2cee0e04441..66847c8c792 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -30,12 +30,12 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' @@ -45,7 +45,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' @@ -58,7 +58,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index e689bc4c0b7..3ccfba65c4c 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -30,12 +30,12 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' @@ -45,7 +45,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' @@ -58,7 +58,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index cf30cf0500f..f75f31e38ef 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -30,12 +30,12 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' @@ -45,7 +45,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' @@ -58,7 +58,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index b26632f7f65..12d84c0ef99 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -98,11 +98,11 @@ jobs: agent: "false" agent-auto-init: "true" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' @@ -112,13 +112,13 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/AGENTS.md b/AGENTS.md index ec2ef62974c..42bc6677d17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,15 +2,29 @@ This file provides guidance to AI coding agents when working with code in this repository. -## STOP — Required Reading (Do This First) - -Before doing ANYTHING else (including answering questions), you MUST use the Read tool to load these files: -1. `.cursor/rules/coding.mdc` -2. `.cursor/rules/overview_dev.mdc` - -Then identify and read any topically relevant `.cursor/rules/*.mdc` files for the area you're working on (e.g., `opentelemetry.mdc` for OTel work, `metrics.mdc` for metrics work). Use the Glob tool on `.cursor/rules/*.mdc` to discover available rule files. - -Do NOT skip this step. Do NOT proceed without reading these files first. +## 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 @@ -34,9 +48,6 @@ The project uses **Gradle** with Kotlin DSL. Key build files: # Run all tests and linter ./gradlew check -# Build entire project -./gradlew build - # Generate documentation ./gradlew aggregateJavadocs ``` @@ -44,13 +55,13 @@ The project uses **Gradle** with Kotlin DSL. Key build files: ### Testing ```bash # Run unit tests for a specific file -./gradlew '::testDebugUnitTest' --tests="**" --info +./gradlew '::testReleaseUnitTest' --tests="**" --info # Run system tests (requires Python virtual env) make systemTest # Run specific test suites -./gradlew :sentry-android-core:testDebugUnitTest +./gradlew :sentry-android-core:testReleaseUnitTest ./gradlew :sentry:test ``` @@ -87,7 +98,7 @@ make systemTest 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 +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 @@ -142,6 +153,33 @@ The repository is organized into multiple modules: - **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 @@ -186,7 +224,9 @@ gh pr view --json url -q '.url' ### Changelog -User-facing changes get an entry under the `## Unreleased` section of `CHANGELOG.md`. When rebasing onto `main`, a release may have renamed the `## Unreleased` heading your entry was under to a version number — if so, move your entry back into an `## Unreleased` section at the top of the file (create it if it no longer exists). See `.cursor/rules/pr.mdc` for the full changelog and PR workflow. +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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a3baff8d6b2..0a45ec3fe37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,153 @@ ## 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 @@ -30,7 +175,18 @@ ### 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 diff --git a/CLAUDE.md b/CLAUDE.md index 19507016af4..f59e5a152f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,5 +2,8 @@ ## 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, including reading the required `.cursor/rules/*.mdc` files it references. -Do NOT skip this step. Do NOT proceed without reading these files 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/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 381c39cfd68..a0040916145 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -136,7 +136,7 @@ limitations under the License. ## Square — Tape (Apache 2.0) -**Source:** https://github.com/square/tape (Commit: 445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8)
+**Source:** https://github.com/square/tape (Commit: 445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8, archived 2024-10-25)
**License:** Apache License 2.0
**Copyright:** Copyright (C) 2010 Square, Inc. @@ -144,6 +144,8 @@ limitations under the License. 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. diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 8abe9f55283..bba758f9b79 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -7,5 +7,19 @@ repositories { } dependencies { + implementation(libs.animalsniffer.gradle.plugin) implementation(libs.spotlessLib) } + +gradlePlugin { + plugins { + register("sentryAnimalSniffer") { + id = "io.sentry.animalsniffer" + implementationClass = "io.sentry.gradle.SentryAnimalSnifferPlugin" + } + register("sentryAnimalSnifferAndroid") { + id = "io.sentry.animalsniffer.android" + implementationClass = "io.sentry.gradle.SentryAnimalSnifferAndroidPlugin" + } + } +} diff --git a/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts b/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts index e06cb677319..8fde556d751 100644 --- a/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts +++ b/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts @@ -1,11 +1,9 @@ import io.sentry.gradle.AggregateJavadoc import org.gradle.api.attributes.Category import org.gradle.api.attributes.LibraryElements -import org.gradle.kotlin.dsl.creating -import org.gradle.kotlin.dsl.getValue import org.gradle.kotlin.dsl.named -val javadocPublisher by configurations.creating { +val javadocPublisher = configurations.create("javadocPublisher") { isCanBeConsumed = false isCanBeResolved = true attributes { @@ -15,7 +13,7 @@ val javadocPublisher by configurations.creating { } subprojects { - javadocPublisher.dependencies.add(dependencies.create(this)) + javadocPublisher.dependencies.add(rootProject.dependencies.project(path)) } val javadocCollection = javadocPublisher.incoming.artifactView { lenient(true) }.files diff --git a/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts b/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts index 7eb796a02ff..21f81fec36a 100644 --- a/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts +++ b/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts @@ -1,4 +1,4 @@ -val javadocConfig: Configuration by configurations.creating { +val javadocConfig: Configuration = configurations.create("javadocConfig") { isCanBeResolved = false isCanBeConsumed = true diff --git a/build-logic/src/main/kotlin/io/sentry/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.gradle.kts b/build.gradle.kts index 55b5a71a1e5..a663628b467 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -92,7 +92,7 @@ apiValidation { allprojects { group = Config.Sentry.group - version = properties[Config.Sentry.versionNameProp].toString() + version = providers.gradleProperty(Config.Sentry.versionNameProp).get() description = Config.Sentry.description tasks { withType().configureEach { @@ -168,6 +168,25 @@ subprojects { } } + // AGP 9 defaults Android modules to Java 11. Pin the published library modules back + // to Java 8 so their bytecode stays consumable by Java 8 projects, mirroring the + // java-library pin above. + plugins.withId("com.android.library") { + configure { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + // AGP 9 defaults the AAR metadata minCompileSdk to the library's compileSdk, + // which would force every consumer onto that compile SDK. Pin it to our minSdk + // so consumers remain free to compile against any SDK we support, as before. + defaultConfig { + aarMetadata { minCompileSdk = libs.versions.minSdk.get().toInt() } + } + } + } + apply() afterEvaluate { diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index f0e2e9baf86..09d2869988b 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -1,6 +1,6 @@ object Config { - val AGP = System.getenv("VERSION_AGP") ?: "8.13.1" + val AGP = System.getenv("VERSION_AGP") ?: "9.2.1" val kotlinStdLib = "stdlib-jdk8" val kotlinStdLibVersionAndroid = "1.9.24" val kotlinTestJunit = "test-junit" @@ -12,8 +12,10 @@ object Config { object Android { val abiFilters = listOf("x86", "armeabi-v7a", "x86_64", "arm64-v8a") + // Debug variants are disabled everywhere. Unit tests run against the release + // variant, so building the debug variant would only add overhead. fun shouldSkipDebugVariant(name: String?): Boolean { - return System.getenv("CI")?.toBoolean() ?: false && name == "debug" + return name == "debug" } } diff --git a/buildSrc/src/main/java/Publication.kt b/buildSrc/src/main/java/Publication.kt index 0aa717a5630..d545e6e32dc 100644 --- a/buildSrc/src/main/java/Publication.kt +++ b/buildSrc/src/main/java/Publication.kt @@ -7,10 +7,13 @@ private object Consts { val taskRegex = Regex("(.*)DistZip") } +private fun Project.versionName(): String = + providers.gradleProperty("versionName").get() + // configure distZip tasks for multiplatform fun DistributionContainer.configureForMultiplatform(project: Project) { val sep = File.separator - val version = project.properties["versionName"].toString() + val version = project.versionName() val name = project.name this.maybeCreate("android").contents { @@ -69,7 +72,7 @@ fun DistributionContainer.configureForMultiplatform(project: Project) { fun DistributionContainer.configureForJvm(project: Project) { val sep = File.separator - val version = project.properties["versionName"].toString() + val version = project.versionName() val name = project.name this.getByName("main").contents { diff --git a/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/gradle.properties b/gradle.properties index 91122c1141f..e9bfc0e8156 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,10 +10,13 @@ org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled # AndroidX required by AGP >= 3.6.x android.useAndroidX=true -android.experimental.lint.version=8.13.1 +# AGP 9+ migration opt-outs until we remove kotlin-android plugin and adopt built-in Kotlin. +android.builtInKotlin=false +android.newDsl=false +android.experimental.lint.version=9.2.1 # Release information -versionName=8.48.0 +versionName=8.53.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a409707f3f..bb4d18c7a0e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +animalsniffer = "2.0.1" apollo = "2.5.9" androidxLifecycle = "2.2.0" androidxNavigation = "2.4.2" @@ -11,6 +12,7 @@ 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" @@ -38,15 +40,15 @@ 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.6.0" +spotless = "8.8.0" springboot2 = "2.7.18" springboot3 = "3.5.0" springboot4 = "4.1.0" sqldelight = "2.3.2" # Android -targetSdk = "36" -compileSdk = "36" +targetSdk = "37" +compileSdk = "37" minSdk = "21" [plugins] @@ -70,11 +72,12 @@ 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 = "2.0.1" } +animalsniffer = { id = "ru.vyarus.animalsniffer", version.ref = "animalsniffer" } sentry = { id = "io.sentry.android.gradle", version.ref = "sagp"} shadow = { id = "com.gradleup.shadow", version = "9.4.1" } [libraries] +animalsniffer-gradle-plugin = { module = "ru.vyarus:gradle-animalsniffer-plugin", version.ref = "animalsniffer" } apache-httpclient = { module = "org.apache.httpcomponents.client5:httpclient5", version = "5.0.4" } apollo2-coroutines = { module = "com.apollographql.apollo:apollo-coroutines-support", version.ref = "apollo" } apollo2-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" } @@ -166,7 +169,7 @@ 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.15.3" } +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" } @@ -226,6 +229,7 @@ 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" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df6a6ad763d..a9db11550c6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139f790..249efbb032c 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index 24c62d56f2d..a51ec4f5886 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute Gradle +@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 diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index adebedf2700..65bf072f0a0 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -293,9 +293,12 @@ public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : i public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/FeedbackShakeIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable { +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 @@ -362,6 +365,24 @@ public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sen public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } +public class io/sentry/android/core/PerfettoContinuousProfiler : io/sentry/IContinuousProfiler, io/sentry/transport/RateLimiter$IRateLimitObserver { + public fun (Lio/sentry/ILogger;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/util/LazyEvaluator$Evaluator;Ljava/util/function/Supplier;)V + public fun close (Z)V + public fun getChunkId ()Lio/sentry/protocol/SentryId; + public fun getProfilerId ()Lio/sentry/protocol/SentryId; + public fun isRunning ()Z + public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V + public fun reevaluateSampling ()V + public fun startProfiler (Lio/sentry/ProfileLifecycle;Lio/sentry/TracesSampler;)V + public fun stopProfiler (Lio/sentry/ProfileLifecycle;)V +} + +public class io/sentry/android/core/PerfettoProfiler { + public fun (Landroid/content/Context;Lio/sentry/ILogger;Lio/sentry/ISentryExecutorService;)V + public fun endAndCollect (Ljava/util/function/Consumer;)V + public fun start (J)Z +} + public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor { public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;Z)V public fun getOrder ()Ljava/lang/Long; @@ -555,7 +576,9 @@ public abstract interface class io/sentry/android/core/SentryUserFeedbackDialog$ 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 diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 0388b7de486..0e3708a89bf 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -1,5 +1,6 @@ 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") @@ -33,7 +34,11 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + + kotlin { compilerOptions.jvmTarget = JVM_1_8 } testOptions { animationsDisabled = true @@ -78,7 +83,7 @@ tasks.withType().configureEach { // outputs so Gradle's build cache restores them on cache hits (otherwise the CLI upload step // finds an empty directory). tasks - .matching { it.name == "testDebugUnitTest" || it.name == "testReleaseUnitTest" } + .matching { it.name == "testReleaseUnitTest" } .configureEach { outputs.dir(layout.buildDirectory.dir("test-snapshots")) } dependencies { @@ -110,6 +115,7 @@ dependencies { testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.test.runner) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(projects.sentryTestSupport) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 4cd76f9a20d..a66e472b07c 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -29,6 +29,11 @@ # https://developer.android.com/studio/build/shrink-code#decode-stack-trace -keepattributes LineNumberTable,SourceFile +# Preserve distinct runtime identities for custom Throwables. R8 horizontal class merging can +# otherwise merge unrelated exception classes, causing the runtime type and retraced frames to +# disagree. Unused Throwables may still be removed, and retained Throwables may still be obfuscated. +-keep,allowshrinking,allowobfuscation class * extends java.lang.Throwable + # Keep Classnames for integrations -keepnames class * implements io.sentry.Integration diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java index 41362c9d93e..a1c0c097cb9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java @@ -38,6 +38,11 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.VisibleForTesting; +/** + * Legacy Android implementation of {@link IContinuousProfiler}, using Android's {@code + * Debug.startMethodTracingSampling} See {@link PerfettoContinuousProfiler} for the new + * implementation using {@code ProfilingManager}, available on API 35+. + */ @ApiStatus.Internal public class AndroidContinuousProfiler implements IContinuousProfiler, RateLimiter.IRateLimitObserver { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java index ea7a20deab1..cb8e148b318 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java @@ -1,56 +1,42 @@ package io.sentry.android.core; +import android.os.Process; import android.os.SystemClock; import android.system.Os; import android.system.OsConstants; import io.sentry.ILogger; import io.sentry.IPerformanceSnapshotCollector; import io.sentry.PerformanceCollectionData; -import io.sentry.SentryLevel; -import io.sentry.util.FileUtils; import io.sentry.util.Objects; -import java.io.File; -import java.io.IOException; -import java.util.regex.Pattern; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -// The approach to get the cpu usage info was taken from -// https://eng.lyft.com/monitoring-cpu-performance-of-lyfts-android-applications-4e36fafffe12 -// The content of the /proc/self/stat file is specified in -// https://man7.org/linux/man-pages/man5/proc.5.html +// The process cpu time comes from Process.getElapsedCpuTime(), a @CriticalNative wrapper around +// clock_gettime(CLOCK_PROCESS_CPUTIME_ID), rather than from parsing /proc/self/stat: reading and +// parsing that file allocated on every sample, and collect() runs 10 times per second for the whole +// duration of a transaction. It does not include the cpu time of reaped child processes, which an +// app process doesn't have. @ApiStatus.Internal public final class AndroidCpuCollector implements IPerformanceSnapshotCollector { + private static final long NANOSECONDS_PER_MILLISECOND = 1_000_000; + private long lastRealtimeNanos = 0; private long lastCpuNanos = 0; - /** Number of clock ticks per second. */ - private long clockSpeedHz = 1; - private long numCores = 1; - private final long NANOSECOND_PER_SECOND = 1_000_000_000; - - /** Number of nanoseconds per clock tick. */ - private double nanosecondsPerClockTick = NANOSECOND_PER_SECOND / (double) clockSpeedHz; - /** File containing stats about this process. */ - private final @NotNull File selfStat = new File("/proc/self/stat"); - - private final @NotNull ILogger logger; private boolean isEnabled = false; - private final @NotNull Pattern newLinePattern = Pattern.compile("[\n\t\r ]"); public AndroidCpuCollector(final @NotNull ILogger logger) { - this.logger = Objects.requireNonNull(logger, "Logger is required."); + Objects.requireNonNull(logger, "Logger is required."); } @Override public void setup() { isEnabled = true; - clockSpeedHz = Os.sysconf(OsConstants._SC_CLK_TCK); numCores = Os.sysconf(OsConstants._SC_NPROCESSORS_CONF); - nanosecondsPerClockTick = NANOSECOND_PER_SECOND / (double) clockSpeedHz; + lastRealtimeNanos = SystemClock.elapsedRealtimeNanos(); lastCpuNanos = readTotalCpuNanos(); } @@ -74,36 +60,7 @@ public void collect(final @NotNull PerformanceCollectionData performanceCollecti (cpuUsagePercentage / (double) numCores) * 100.0); } - /** Read the /proc/self/stat file and parses the result. */ private long readTotalCpuNanos() { - String stat = null; - try { - stat = FileUtils.readText(selfStat); - } catch (IOException e) { - // If an error occurs when reading the file, we avoid reading it again until the setup method - // is called again - isEnabled = false; - logger.log( - SentryLevel.WARNING, "Unable to read /proc/self/stat file. Disabling cpu collection.", e); - } - if (stat != null) { - stat = stat.trim(); - String[] stats = newLinePattern.split(stat); - try { - // Amount of clock ticks this process has been scheduled in user mode - long uTime = Long.parseLong(stats[13]); - // Amount of clock ticks this process has been scheduled in kernel mode - long sTime = Long.parseLong(stats[14]); - // Amount of clock ticks this process' waited-for children has been scheduled in user mode - long cuTime = Long.parseLong(stats[15]); - // Amount of clock ticks this process' waited-for children has been scheduled in kernel mode - long csTime = Long.parseLong(stats[16]); - return (long) ((uTime + sTime + cuTime + csTime) * nanosecondsPerClockTick); - } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - logger.log(SentryLevel.ERROR, "Error parsing /proc/self/stat file.", e); - return 0; - } - } - return 0; + return Process.getElapsedCpuTime() * NANOSECONDS_PER_MILLISECOND; } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 9cc5cb3df0f..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,6 +2,7 @@ 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; @@ -182,6 +183,11 @@ static void initializeIntegrationsAndProcessors( if (options.getCacheDirPath() != null) { options.addScopeObserver(new PersistingScopeObserver(options)); options.addOptionsObserver(new PersistingOptionsObserver(options)); + final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); + if (packageInfo != null && packageInfo.lastUpdateTime > 0) { + options.addOptionsObserver( + new PersistingOptionsCacheGenerationObserver(options, packageInfo.lastUpdateTime)); + } } options.addEventProcessor(new DeduplicateMultithreadedEventProcessor(options)); @@ -294,6 +300,7 @@ static void initializeIntegrationsAndProcessors( } /** Setup the correct profiler (transaction or continuous) based on the options. */ + @SuppressLint("NewApi") private static void setupProfiler( final @NotNull SentryAndroidOptions options, final @NotNull Context context, @@ -303,6 +310,28 @@ private static void setupProfiler( final @NotNull CompositePerformanceCollector performanceCollector) { if (options.isProfilingEnabled() || options.getProfilesSampleRate() != null) { options.setContinuousProfiler(NoOpContinuousProfiler.getInstance()); + // Transaction-based profiling always relies on the legacy Debug-based profiler, so it is + // disabled together with legacy profiling. Perfetto profiling only supports continuous + // profiling. + if (!options.isEnableLegacyProfiling()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Transaction-based profiling (profilesSampleRate/profilesSampler) is disabled " + + "because enableLegacyProfiling is false. Transaction-based profiling always " + + "uses the legacy profiler and is not supported by Perfetto. No profiling " + + "data will be collected. Use profileSessionSampleRate for continuous " + + "profiling instead."); + options.setTransactionProfiler(NoOpTransactionProfiler.getInstance()); + if (appStartTransactionProfiler != null) { + appStartTransactionProfiler.close(); + } + if (appStartContinuousProfiler != null) { + appStartContinuousProfiler.close(true); + } + return; + } // This is a safeguard, but it should never happen, as the app start profiler should be the // continuous one. if (appStartContinuousProfiler != null) { @@ -336,16 +365,36 @@ private static void setupProfiler( performanceCollector.start(chunkId.toString()); } } else { - options.setContinuousProfiler( - new AndroidContinuousProfiler( - buildInfoProvider, - Objects.requireNonNull( - options.getFrameMetricsCollector(), - "options.getFrameMetricsCollector is required"), - options.getLogger(), - options.getProfilingTracesDirPath(), - options.getProfilingTracesHz(), - () -> options.getExecutorService())); + final @NotNull SentryFrameMetricsCollector frameMetricsCollector = + Objects.requireNonNull( + options.getFrameMetricsCollector(), "options.getFrameMetricsCollector is required"); + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + final @NotNull Context appContext = ContextUtils.getApplicationContext(context); + options.setContinuousProfiler( + new PerfettoContinuousProfiler( + options.getLogger(), + frameMetricsCollector, + () -> options.getExecutorService(), + () -> + new PerfettoProfiler( + appContext, options.getLogger(), options.getExecutorService()))); + } else if (options.isEnableLegacyProfiling()) { + options.setContinuousProfiler( + new AndroidContinuousProfiler( + buildInfoProvider, + frameMetricsCollector, + options.getLogger(), + options.getProfilingTracesDirPath(), + options.getProfilingTracesHz(), + () -> options.getExecutorService())); + } else { + options + .getLogger() + .log( + SentryLevel.WARNING, + "enableLegacyProfiling is disabled and device is below API 35. " + + "No profiling data will be collected."); + } } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java index f4357b010f7..3f569df5378 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java @@ -322,21 +322,21 @@ private void putPerformanceCollectionDataInMeasurements( for (final @NotNull PerformanceCollectionData data : performanceCollectionData) { final long nanoTimestamp = data.getNanoTimestamp(); final long relativeStartNs = nanoTimestamp + timestampDiff; - final @Nullable Double cpuUsagePercentage = data.getCpuUsagePercentage(); - final @Nullable Long usedHeapMemory = data.getUsedHeapMemory(); - final @Nullable Long usedNativeMemory = data.getUsedNativeMemory(); - if (cpuUsagePercentage != null) { + if (data.hasCpuUsagePercentage()) { cpuUsageMeasurements.add( - new ProfileMeasurementValue(relativeStartNs, cpuUsagePercentage, nanoTimestamp)); + new ProfileMeasurementValue( + relativeStartNs, data.getCpuUsagePercentage(), nanoTimestamp)); } - if (usedHeapMemory != null) { + if (data.hasUsedHeapMemory()) { memoryUsageMeasurements.add( - new ProfileMeasurementValue(relativeStartNs, usedHeapMemory, nanoTimestamp)); + new ProfileMeasurementValue( + relativeStartNs, data.getUsedHeapMemory(), nanoTimestamp)); } - if (usedNativeMemory != null) { + if (data.hasUsedNativeMemory()) { nativeMemoryUsageMeasurements.add( - new ProfileMeasurementValue(relativeStartNs, usedNativeMemory, nanoTimestamp)); + new ProfileMeasurementValue( + relativeStartNs, data.getUsedNativeMemory(), nanoTimestamp)); } } } 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 index 2eca0e68b5b..3182828a024 100644 --- 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 @@ -51,6 +51,7 @@ 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; @@ -161,7 +162,13 @@ public ApplicationExitInfoEventProcessor( 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( @@ -170,21 +177,22 @@ public ApplicationExitInfoEventProcessor( return event; } - backfillScope(event); + backfillScope(event, optionsSource); - backfillOptions(event); + backfillOptions(event, optionsSource); setStaticValues(event); if (hintEnricher != null) { - hintEnricher.applyPostEnrichment(event, backfillable, unwrappedHint); + hintEnricher.applyPostEnrichment(event, backfillable, unwrappedHint, optionsSource); } return event; } // region scope persisted values - private void backfillScope(final @NotNull SentryEvent event) { + private void backfillScope( + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { setRequest(event); setUser(event); setScopeTags(event); @@ -195,19 +203,25 @@ private void backfillScope(final @NotNull SentryEvent event) { setFingerprints(event); setLevel(event); setTrace(event); - setReplayId(event); + setReplayId(event, optionsSource); } - private boolean sampleReplay(final @NotNull SentryEvent event) { + private boolean sampleReplay( + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { + final @Nullable Double currentSampleRate = options.getSessionReplay().getOnErrorSampleRate(); final @Nullable String replayErrorSampleRate = - PersistingOptionsObserver.read(options, REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class); + getLaunchOption( + REPLAY_ERROR_SAMPLE_RATE_FILENAME, + String.class, + currentSampleRate == null ? null : currentSampleRate.toString(), + optionsSource); if (replayErrorSampleRate == null) { return false; } try { - // we have to sample here with the old sample rate, because it may change between app launches + // 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 @@ -226,7 +240,8 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { return true; } - private void setReplayId(final @NotNull SentryEvent event) { + 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) { @@ -234,7 +249,7 @@ private void setReplayId(final @NotNull SentryEvent event) { } final @NotNull File replayFolder = new File(cacheDirPath, "replay_" + persistedReplayId); if (!replayFolder.exists()) { - if (!sampleReplay(event)) { + if (!sampleReplay(event, optionsSource)) { return; } // if the replay folder does not exist (e.g. running in buffer mode), we need to find the @@ -393,14 +408,15 @@ private void setRequest(final @NotNull SentryBaseEvent event) { // endregion // region options persisted values - private void backfillOptions(final @NotNull SentryEvent event) { - setRelease(event); - setEnvironment(event); - setDist(event); - setDebugMeta(event); - setSdk(event); + 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); + setOptionsTags(event, optionsSource); } private void setApp(final @NotNull SentryBaseEvent event) { @@ -415,25 +431,6 @@ private void setApp(final @NotNull SentryBaseEvent event) { app.setAppIdentifier(packageInfo.packageName); } - // backfill versionName and versionCode from the persisted release string - final String release = - event.getRelease() != null - ? event.getRelease() - : PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); - if (release != null) { - try { - final String versionName = - release.substring(release.indexOf('@') + 1, release.indexOf('+')); - final String versionCode = release.substring(release.indexOf('+') + 1); - app.setAppVersion(versionName); - app.setAppBuild(versionCode); - } catch (Throwable e) { - options - .getLogger() - .log(SentryLevel.WARNING, "Failed to parse release from scope cache: %s", release); - } - } - try { final ContextUtils.SplitApksInfo splitApksInfo = DeviceInfoUtil.getInstance(context, options).getSplitApksInfo(); @@ -448,25 +445,50 @@ private void setApp(final @NotNull SentryBaseEvent event) { } 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) { + private void setRelease( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getRelease() == null) { - final String release = - PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); - event.setRelease(release); + event.setRelease( + getLaunchOption(RELEASE_FILENAME, String.class, options.getRelease(), optionsSource)); } } - private void setEnvironment(final @NotNull SentryBaseEvent event) { + private void setEnvironment( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getEnvironment() == null) { - final String environment = - PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String.class); - event.setEnvironment(environment != null ? environment : options.getEnvironment()); + event.setEnvironment( + getLaunchOption( + ENVIRONMENT_FILENAME, String.class, options.getEnvironment(), optionsSource)); } } - private void setDebugMeta(final @NotNull SentryBaseEvent event) { + private void setDebugMeta( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { DebugMeta debugMeta = event.getDebugMeta(); if (debugMeta == null) { @@ -478,27 +500,24 @@ private void setDebugMeta(final @NotNull SentryBaseEvent event) { List images = debugMeta.getImages(); if (images != null) { final String proguardUuid = - PersistingOptionsObserver.read(options, PROGUARD_UUID_FILENAME, String.class); + getBuildOption( + PROGUARD_UUID_FILENAME, String.class, options.getProguardUuid(), optionsSource); if (proguardUuid != null) { - final DebugImage debugImage = new DebugImage(); - debugImage.setType(DebugImage.PROGUARD); - debugImage.setUuid(proguardUuid); - images.add(debugImage); + images.add(createProguardDebugImage(proguardUuid)); } event.setDebugMeta(debugMeta); } } - private void setDist(final @NotNull SentryBaseEvent event) { + private void setDist( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getDist() == null) { - final String dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String.class); - event.setDist(dist); + event.setDist(getLaunchOption(DIST_FILENAME, String.class, options.getDist(), optionsSource)); } - // if there's no user-set dist, fall back to versionCode from the persisted release string + // if there's no user-set dist, fall back to versionCode from the release string if (event.getDist() == null) { - final String release = - PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); + final String release = event.getRelease(); if (release != null) { try { final String versionCode = release.substring(release.indexOf('+') + 1); @@ -512,20 +531,101 @@ private void setDist(final @NotNull SentryBaseEvent event) { } } - private void setSdk(final @NotNull SentryBaseEvent event) { + /** + * 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 = - PersistingOptionsObserver.read(options, SDK_VERSION_FILENAME, SdkVersion.class); + getBuildOption( + SDK_VERSION_FILENAME, SdkVersion.class, options.getSdkVersion(), optionsSource); event.setSdk(sdkVersion); } } @SuppressWarnings("unchecked") - private void setOptionsTags(final @NotNull SentryBaseEvent event) { + private void setOptionsTags( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { final Map tags = (Map) - PersistingOptionsObserver.read( - options, PersistingOptionsObserver.TAGS_FILENAME, Map.class); + getLaunchOption( + PersistingOptionsObserver.TAGS_FILENAME, + Map.class, + options.getTags(), + optionsSource); if (tags == null) { return; } @@ -542,6 +642,13 @@ private void setOptionsTags(final @NotNull SentryBaseEvent event) { // endregion + private enum OptionsSource { + CURRENT, + PERSISTED, + PERSISTED_WITH_CURRENT_FALLBACK, + NONE + } + @Override public @Nullable Long getOrder() { return 12000L; @@ -680,7 +787,10 @@ void applyPreEnrichment( @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint); void applyPostEnrichment( - @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint); + @NotNull SentryEvent event, + @NotNull Backfillable hint, + @NotNull Object rawHint, + @NotNull OptionsSource optionsSource); } private final class AnrHintEnricher implements HintEnricher { @@ -712,11 +822,14 @@ public void applyPreEnrichment( @Override public void applyPostEnrichment( - @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint) { + @NotNull SentryEvent event, + @NotNull Backfillable hint, + @NotNull Object rawHint, + @NotNull OptionsSource optionsSource) { final boolean isBackgroundAnr = isBackgroundAnr(rawHint); if (options.isAnrProfilingEnabled()) { - applyAnrProfile(event, hint, isBackgroundAnr); + applyAnrProfile(event, hint, isBackgroundAnr, optionsSource); } setDefaultAnrFingerprint(event, isBackgroundAnr); @@ -810,7 +923,10 @@ private void setAnrExceptions( } private void applyAnrProfile( - @NotNull SentryEvent event, @NotNull Backfillable hint, boolean isBackgroundAnr) { + @NotNull SentryEvent event, + @NotNull Backfillable hint, + boolean isBackgroundAnr, + @NotNull OptionsSource optionsSource) { // Skip background ANRs (as profiling only runs in foreground) if (isBackgroundAnr) { @@ -871,7 +987,8 @@ private void applyAnrProfile( } // Capture profile chunk - final @Nullable SentryId profilerId = captureAnrProfile(anrTimestamp, anrProfile); + final @Nullable SentryId profilerId = + captureAnrProfile(anrTimestamp, anrProfile, optionsSource); final @NotNull StackTraceElement[] stack = culprit.getStack(); if (stack.length > 0) { @@ -902,7 +1019,10 @@ private void applyAnrProfile( } @Nullable - private SentryId captureAnrProfile(final long anrTimestampMs, @NotNull AnrProfile anrProfile) { + private SentryId captureAnrProfile( + final long anrTimestampMs, + @NotNull AnrProfile anrProfile, + final @NotNull OptionsSource optionsSource) { final SentryProfile profile = StackTraceConverter.convert(anrProfile); final ProfileChunk chunk = new ProfileChunk( @@ -911,9 +1031,10 @@ private SentryId captureAnrProfile(final long anrTimestampMs, @NotNull AnrProfil null, new HashMap<>(0), anrTimestampMs / 1000.0d, - ProfileChunk.PLATFORM_JAVA, + ProfileChunk.PLATFORM_ANDROID, options); chunk.setSentryProfile(profile); + chunk.setDebugMeta(createAnrProfileDebugMeta(optionsSource)); final SentryId profilerId = Sentry.getCurrentScopes().captureProfileChunk(chunk); if (SentryId.EMPTY_ID.equals(profilerId)) { @@ -948,5 +1069,37 @@ private boolean hasOnlySystemFrames(@NotNull SentryEvent event) { } 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/EnvelopeFileObserverIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java index 482d90c6e6c..ab95ae32daa 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java @@ -10,8 +10,10 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.FileUtils; import io.sentry.util.Objects; import java.io.Closeable; +import java.io.File; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -67,6 +69,12 @@ private void startOutboxSender( final @NotNull IScopes scopes, final @NotNull SentryOptions options, final @NotNull String path) { + // Create the outbox dir here (on the executor) so the observer can watch it for envelopes + // written by hybrid SDKs, instead of blocking Sentry.init on the mkdirs. + if (!FileUtils.createDirectory(new File(path))) { + options.getLogger().log(SentryLevel.ERROR, "Failed to create outbox dir %s", path); + } + final OutboxSender outboxSender = new OutboxSender( scopes, diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java index fc34f18152f..4405cd19309 100644 --- 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 @@ -4,31 +4,54 @@ 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. Only active - * when {@link io.sentry.SentryFeedbackOptions#isUseShakeGesture()} returns {@code true}. + * 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 { + 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; - private volatile boolean isDialogShowing = false; - private volatile @Nullable Runnable previousOnFormClose; + + /** + * 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"); @@ -44,17 +67,47 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions : null, "SentryAndroidOptions is required"); - if (!this.options.getFeedbackOptions().isUseShakeGesture()) { + 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(); - shakeDetector.init(application, options.getLogger()); + // 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, hook into any already-resumed activity + // 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); @@ -63,34 +116,111 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions } @Override - public void close() throws IOException { + public synchronized void disableOnShake() { + if (!enabled) { + return; + } + enabled = false; + application.unregisterActivityLifecycleCallbacks(this); shakeDetector.close(); - // Restore onFormClose if a dialog is still showing, since lifecycle callbacks - // are now unregistered and onActivityDestroyed cleanup won't fire. - if (isDialogShowing) { - isDialogShowing = false; - if (options != null) { - options.getFeedbackOptions().setOnFormClose(previousOnFormClose); - } - previousOnFormClose = null; - } currentActivityRef = null; } @Override - public void onActivityResumed(final @NotNull Activity activity) { - // If a dialog is showing on a different activity (e.g. user navigated via notification), - // clean up since the dialog's host activity is going away and onActivityDestroyed - // won't match currentActivity anymore. - final @Nullable Activity current = currentActivityRef != null ? currentActivityRef.get() : null; - if (isDialogShowing && current != null && current != activity) { - isDialogShowing = false; - if (options != null) { - options.getFeedbackOptions().setOnFormClose(previousOnFormClose); + 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(); } - previousOnFormClose = null; } + 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); } @@ -100,16 +230,11 @@ 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 Activity current = currentActivityRef != null ? currentActivityRef.get() : null; + final @Nullable WeakReference currentRef = currentActivityRef; + final @Nullable Activity current = currentRef != null ? currentRef.get() : null; if (activity == current) { stopShakeDetection(); - // Keep currentActivityRef set when a dialog is showing so onActivityDestroyed - // can still match and clean up. Otherwise the cleanup condition - // (activity == current) would always be false since onPause fires - // before onDestroy. - if (!isDialogShowing) { - currentActivityRef = null; - } + currentActivityRef = null; } } @@ -128,19 +253,7 @@ public void onActivitySaveInstanceState( final @NotNull Activity activity, final @NotNull Bundle outState) {} @Override - public void onActivityDestroyed(final @NotNull Activity activity) { - // Only reset if this is the activity that hosts the dialog — the dialog cannot - // outlive its host activity being destroyed. - final @Nullable Activity current = currentActivityRef != null ? currentActivityRef.get() : null; - if (isDialogShowing && activity == current) { - isDialogShowing = false; - currentActivityRef = null; - if (options != null) { - options.getFeedbackOptions().setOnFormClose(previousOnFormClose); - } - previousOnFormClose = null; - } - } + public void onActivityDestroyed(final @NotNull Activity activity) {} private void startShakeDetection(final @NotNull Activity activity) { if (options == null) { @@ -148,47 +261,50 @@ private void startShakeDetection(final @NotNull Activity activity) { } // 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 - && !isDialogShowing - && !Boolean.TRUE.equals(inBackground)) { - active.runOnUiThread( - () -> { - if (isDialogShowing || active.isFinishing() || active.isDestroyed()) { - return; - } - try { - isDialogShowing = true; - final Runnable captured = options.getFeedbackOptions().getOnFormClose(); - previousOnFormClose = captured; - options - .getFeedbackOptions() - .setOnFormClose( - () -> { - isDialogShowing = false; - options.getFeedbackOptions().setOnFormClose(captured); - if (captured != null) { - captured.run(); - } - previousOnFormClose = null; - }); - new SentryUserFeedbackForm.Builder(active).create().show(); - } catch (Throwable e) { - isDialogShowing = false; - options.getFeedbackOptions().setOnFormClose(previousOnFormClose); - previousOnFormClose = null; - options - .getLogger() - .log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e); - } - }); + 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); + } + }); }); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 3d4cedb1b53..de1c40c570c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -8,9 +8,7 @@ import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.AutoClosableReentrantLock; -import io.sentry.util.LazyEvaluator; -import java.util.Timer; -import java.util.TimerTask; +import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -22,9 +20,8 @@ final class LifecycleWatcher implements AppState.AppStateListener { private final long sessionIntervalMillis; - private @Nullable TimerTask timerTask; - private final @NotNull LazyEvaluator timer = new LazyEvaluator<>(() -> new Timer(true)); - private final @NotNull AutoClosableReentrantLock timerLock = new AutoClosableReentrantLock(); + private @Nullable Future endSessionFuture; + private final @NotNull AutoClosableReentrantLock endSessionLock = new AutoClosableReentrantLock(); private final @NotNull IScopes scopes; private final boolean enableSessionTracking; private final boolean enableAppLifecycleBreadcrumbs; @@ -104,29 +101,40 @@ public void onBackground() { } private void scheduleEndSession() { - try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { + try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) { cancelTask(); - timerTask = - new TimerTask() { - @Override - public void run() { - if (enableSessionTracking) { - scopes.endSession(); - } - scopes.getOptions().getReplayController().stop(); - scopes.getOptions().getContinuousProfiler().close(false); + final @NotNull Runnable endSession = + () -> { + if (enableSessionTracking) { + scopes.endSession(); } + scopes.getOptions().getReplayController().stop(); + scopes.getOptions().getContinuousProfiler().close(false); }; - timer.getValue().schedule(timerTask, sessionIntervalMillis); + try { + endSessionFuture = + scopes + .getOptions() + .getTimerExecutorService() + .schedule(endSession, sessionIntervalMillis); + } catch (Throwable e) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.WARNING, "Failed to schedule end of session. Ending it now.", e); + // if we cannot re-check after the session interval, end the session right away instead of + // leaving it open forever + endSession.run(); + } } } private void cancelTask() { - try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { - if (timerTask != null) { - timerTask.cancel(); - timerTask = null; + try (final @NotNull ISentryLifecycleToken ignored = endSessionLock.acquire()) { + if (endSessionFuture != null) { + endSessionFuture.cancel(false); + endSessionFuture = null; } } } @@ -144,13 +152,7 @@ private void addAppBreadcrumb(final @NotNull String state) { @TestOnly @Nullable - TimerTask getTimerTask() { - return timerTask; - } - - @TestOnly - @NotNull - Timer getTimer() { - return timer.getValue(); + Future getEndSessionFuture() { + return endSessionFuture; } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 7a9cd8a4d13..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 @@ -119,6 +119,8 @@ final class ManifestMetadataReader { 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"; @@ -542,6 +544,9 @@ static void applyMetadata( 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())); @@ -779,7 +784,9 @@ private static boolean readBool( final @NotNull String key, final boolean defaultValue) { final boolean value = metadata.getBoolean(key, defaultValue); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } @@ -789,7 +796,9 @@ private static boolean readBool( final @NotNull String key, final @Nullable String defaultValue) { final String value = metadata.getString(key, defaultValue); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } @@ -799,14 +808,18 @@ private static boolean readBool( final @NotNull String key, final @NotNull String defaultValue) { final String value = metadata.getString(key, defaultValue); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } private static @Nullable List readList( final @NotNull Bundle metadata, final @NotNull ILogger logger, final @NotNull String key) { final String value = metadata.getString(key); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } if (value != null) { return Arrays.asList(value.split(",", -1)); } else { @@ -821,7 +834,9 @@ private static double readDouble( if (value == -1) { value = ((Integer) metadata.getInt(key, -1)).doubleValue(); } - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } @@ -832,7 +847,9 @@ private static long readLong( final long defaultValue) { // manifest meta-data only reads int if the value is not big enough final long value = metadata.getInt(key, (int) defaultValue); - logger.log(SentryLevel.DEBUG, key + " read: " + value); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, key + " read: " + value); + } return value; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/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/PersistingOptionsCacheGenerationObserver.java b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java new file mode 100644 index 00000000000..9b4433e255e --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java @@ -0,0 +1,88 @@ +package io.sentry.android.core; + +import static io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE; + +import io.sentry.IOptionsObserver; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.cache.CacheUtils; +import io.sentry.cache.PersistingOptionsObserver; +import io.sentry.protocol.SdkVersion; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Persists the app generation that produced the options cache. + * + *

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

For example: + * + *

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

This observer must be registered after {@link PersistingOptionsObserver}. Options observers + * are notified one at a time, so the first callback to this observer writes the generation marker + * only after the preceding observer has persisted the complete options snapshot. + */ +final class PersistingOptionsCacheGenerationObserver implements IOptionsObserver { + static final String APP_LAST_UPDATE_TIME_FILENAME = "app-last-update-time.json"; + + private final @NotNull SentryOptions options; + private final long lastUpdateTime; + + PersistingOptionsCacheGenerationObserver( + final @NotNull SentryOptions options, final long lastUpdateTime) { + this.options = options; + this.lastUpdateTime = lastUpdateTime; + } + + @Override + public void setRelease(final @Nullable String release) { + CacheUtils.store( + options, Long.toString(lastUpdateTime), OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME); + } + + static @Nullable Long read(final @NotNull SentryOptions options) { + final String value = + CacheUtils.read(options, OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME, String.class, null); + if (value == null) { + return null; + } + try { + return Long.valueOf(value); + } catch (NumberFormatException e) { + options.getLogger().log(SentryLevel.ERROR, e, "Failed to read options cache generation."); + return null; + } + } + + @Override + public void setProguardUuid(final @Nullable String proguardUuid) {} + + @Override + public void setSdkVersion(final @Nullable SdkVersion sdkVersion) {} + + @Override + public void setEnvironment(final @Nullable String environment) {} + + @Override + public void setDist(final @Nullable String dist) {} + + @Override + public void setTags(final @NotNull Map tags) {} + + @Override + public void setReplayErrorSampleRate(final @Nullable Double replayErrorSampleRate) {} +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index bbef7846cd9..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 @@ -216,7 +216,7 @@ private boolean isMaskingEnabled() { try (final MaskRenderer maskRenderer = new MaskRenderer()) { // Make bitmap mutable if needed if (!screenshot.isMutable()) { - mutableBitmap = screenshot.copy(Bitmap.Config.ARGB_8888, true); + mutableBitmap = screenshot.copy(Bitmap.Config.RGB_565, true); if (mutableBitmap == null) { screenshot.recycle(); return null; 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 f27259fd635..ab18a5827b9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -5,6 +5,7 @@ import android.content.Context; import android.os.Process; import android.os.SystemClock; +import android.os.Trace; import io.sentry.ILogger; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; @@ -95,6 +96,9 @@ public static void init( @NotNull final Context context, @NotNull ILogger logger, @NotNull Sentry.OptionsConfiguration configuration) { + // Started before acquiring the lock so it stays balanced with the endSection() in the finally + // even if acquire() throws. + Trace.beginSection("SentryAndroid.init"); try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { Sentry.init( new SentryAndroidOptionsContainer(), @@ -219,6 +223,8 @@ public static void init( logger.log(SentryLevel.FATAL, "Fatal error during SentryAndroid.init(...)", e); throw new RuntimeException("Failed to initialize Sentry's SDK", e); + } finally { + Trace.endSection(); } } 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 7e43d626b34..9d1f2e13ec4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java @@ -13,7 +13,7 @@ import io.sentry.ILogger; import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; -import io.sentry.JsonSerializer; +import io.sentry.JsonObjectReader; import io.sentry.SentryAppStartProfilingOptions; import io.sentry.SentryExecutorService; import io.sentry.SentryLevel; @@ -23,7 +23,6 @@ import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; -import io.sentry.util.AutoClosableReentrantLock; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; @@ -46,7 +45,6 @@ public final class SentryPerformanceProvider extends EmptySecureContentProvider private final @NotNull ILogger logger; private final @NotNull BuildInfoProvider buildInfoProvider; - private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); @TestOnly SentryPerformanceProvider( @@ -119,8 +117,7 @@ private void launchAppStartProfiler(final @NotNull AppStartMetrics appStartMetri try (final @NotNull Reader reader = new BufferedReader(new InputStreamReader(new FileInputStream(configFile)))) { final @Nullable SentryAppStartProfilingOptions profilingOptions = - new JsonSerializer(SentryOptions.empty()) - .deserialize(reader, SentryAppStartProfilingOptions.class); + deserializeProfilingConfig(reader); if (profilingOptions == null) { logger.log( @@ -129,6 +126,23 @@ private void launchAppStartProfiler(final @NotNull AppStartMetrics appStartMetri return; } + if (buildInfoProvider.getSdkInfoVersion() + >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) { + logger.log( + SentryLevel.DEBUG, + "Device is API 35+. Skipping legacy app-start profiling — " + + "Perfetto ProfilingManager will be initialized after Sentry.init()."); + return; + } + + if (!profilingOptions.isEnableLegacyProfiling()) { + logger.log( + SentryLevel.WARNING, + "enableLegacyProfiling is disabled and device is below API 35. " + + "App start profiling will not start."); + return; + } + if (profilingOptions.isContinuousProfilingEnabled() && profilingOptions.isStartProfilerOnAppStart()) { createAndStartContinuousProfiler(context, profilingOptions, appStartMetrics); @@ -151,6 +165,25 @@ private void launchAppStartProfiler(final @NotNull AppStartMetrics appStartMetri } } + /** + * Parses the app start profiling config with only the deserializer it needs. Going through {@link + * io.sentry.JsonSerializer} would allocate a full {@link SentryOptions} plus every registered + * deserializer on the main thread before {@code Application.onCreate}, to use exactly one of + * them. + * + *

Returns null on malformed input, matching what {@code JsonSerializer.deserialize} did, so + * callers keep reporting it as a deserialization failure rather than a read error. + */ + private @Nullable SentryAppStartProfilingOptions deserializeProfilingConfig( + final @NotNull Reader reader) { + try (final @NotNull JsonObjectReader jsonReader = new JsonObjectReader(reader)) { + return new SentryAppStartProfilingOptions.Deserializer().deserialize(jsonReader, logger); + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Error when deserializing", e); + return null; + } + } + private void createAndStartContinuousProfiler( final @NotNull Context context, final @NotNull SentryAppStartProfilingOptions profilingOptions, 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 index a4c4ae0c4f5..9f4f73d10f4 100644 --- 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 @@ -40,6 +40,7 @@ public final class SentryShakeDetector implements SensorEventListener { private @Nullable Handler handler; private volatile @Nullable Listener listener; private @NotNull ILogger logger; + private boolean closed; private final @NotNull SampleQueue queue = new SampleQueue(); @@ -51,16 +52,29 @@ 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. */ - void init(final @NotNull Context context, final @NotNull ILogger logger) { + synchronized void init(final @NotNull Context context, final @NotNull ILogger logger) { this.logger = logger; init(context); } - private void init(final @NotNull Context 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); } @@ -74,7 +88,11 @@ private void init(final @NotNull Context context) { } } - public void start(final @NotNull Context context, final @NotNull Listener shakeListener) { + public synchronized void start( + final @NotNull Context context, final @NotNull Listener shakeListener) { + if (closed) { + return; + } this.listener = shakeListener; init(context); if (sensorManager == null) { @@ -89,7 +107,7 @@ public void start(final @NotNull Context context, final @NotNull Listener shakeL sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL, handler); } - public void stop() { + public synchronized void stop() { listener = null; if (sensorManager != null) { sensorManager.unregisterListener(this); @@ -105,7 +123,8 @@ public void stop() { } /** Stops detection and releases the background thread. */ - public void close() { + public synchronized void close() { + closed = true; stop(); if (handlerThread != null) { // quitSafely drains pending messages (including the clear posted by stop) before exiting 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 index 43500d50ebc..01d4546d877 100644 --- 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 @@ -60,9 +60,12 @@ public class SentryUserFeedbackForm extends AlertDialog { } 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.isUseShakeGesture()) { + if (!resolvedFeedbackOptions.isUseShakeGesture() + || globalFeedbackOptions.getShakeController().isOnShakeEnabled()) { return; } final @Nullable Activity activity = getActivity(context); @@ -95,6 +98,15 @@ private void stopShakeDetection() { 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( @@ -284,13 +296,27 @@ protected void onCreate(Bundle savedInstanceState) { final @Nullable SentryFeedbackOptions.SentryFeedbackCallback onSubmitSuccess = feedbackOptions.getOnSubmitSuccess(); if (onSubmitSuccess != null) { - onSubmitSuccess.call(feedback); + 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) { - onSubmitError.call(feedback); + try { + onSubmitError.call(feedback); + } catch (Exception e) { + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "onSubmitError callback threw an exception.", e); + } } } cancel(); @@ -310,7 +336,15 @@ public void setOnDismissListener(final @Nullable OnDismissListener listener) { if (onFormClose != null) { super.setOnDismissListener( dialog -> { - onFormClose.run(); + // 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); @@ -324,7 +358,7 @@ public void setOnDismissListener(final @Nullable OnDismissListener listener) { @Override protected void onStart() { super.onStart(); - // Clear the message field so subsequent show() calls start with a fresh form + // 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(); @@ -332,14 +366,58 @@ protected void onStart() { 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) { - onFormOpen.run(); + 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 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 index f6f29689f72..8f1254e08ca 100644 --- 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 @@ -33,6 +33,14 @@ 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}. * @@ -80,6 +88,15 @@ public static SentryProfile convert(final @NotNull AnrProfile anrProfile) { 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); @@ -147,4 +164,18 @@ private static SentryStackFrame createSentryStackFrame(@NotNull StackTraceElemen } 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 e1590e47943..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 @@ -93,7 +93,7 @@ private boolean storeInternalAndroid(@NotNull SentryEnvelope envelope, @NotNull @TestOnly public @NotNull File getDirectory() { - return directory; + return directory.getFile(); } private void writeStartupCrashMarkerFile() { @@ -106,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) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java index db2b12122a5..092caa040db 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java @@ -18,7 +18,7 @@ import java.io.ByteArrayOutputStream; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -97,9 +97,8 @@ public class ScreenshotUtils { } try { - // ARGB_8888 -> This configuration is very flexible and offers the best quality final Bitmap bitmap = - Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888); + Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.RGB_565); final @NotNull CountDownLatch latch = new CountDownLatch(1); @@ -110,21 +109,21 @@ public class ScreenshotUtils { thread.start(); boolean success = false; + final AtomicInteger copyResultCode = new AtomicInteger(-1); try { final Handler handler = new Handler(thread.getLooper()); - final AtomicBoolean copyResultSuccess = new AtomicBoolean(false); PixelCopy.request( window, bitmap, copyResult -> { - copyResultSuccess.set(copyResult == PixelCopy.SUCCESS); + copyResultCode.set(copyResult); latch.countDown(); }, handler); - success = - latch.await(CAPTURE_TIMEOUT_MS, TimeUnit.MILLISECONDS) && copyResultSuccess.get(); + final boolean completed = latch.await(CAPTURE_TIMEOUT_MS, TimeUnit.MILLISECONDS); + success = completed && copyResultCode.get() == PixelCopy.SUCCESS; } catch (Throwable e) { // ignored logger.log(SentryLevel.ERROR, "Taking screenshot using PixelCopy failed.", e); @@ -133,6 +132,10 @@ public class ScreenshotUtils { } if (!success) { + logger.log( + SentryLevel.WARNING, + "PixelCopy failed for screenshot capture (result=%d).", + copyResultCode.get()); return null; } } else { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java index 4f76a51e86f..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 @@ -56,8 +56,8 @@ 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; @@ -91,7 +91,7 @@ public SentryFrameMetricsCollector( } @SuppressWarnings("deprecation") - @SuppressLint({"NewApi", "PrivateApi"}) + @SuppressLint({"NewApi", "PrivateApi", "DiscouragedPrivateApi"}) public SentryFrameMetricsCollector( final @NotNull Context context, final @NotNull ILogger logger, @@ -126,7 +126,8 @@ public SentryFrameMetricsCollector( // Most considerations regarding timestamps of frames are inspired from JankStats library: // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi24Impl.kt - // The Choreographer instance must be accessed on the main thread + // The Choreographer instance should be initialized asynchronously on the main thread to avoid + // reflection during SDK init. new Handler(Looper.getMainLooper()) .post( () -> { @@ -138,15 +139,19 @@ public SentryFrameMetricsCollector( "Error retrieving Choreographer instance. Slow and frozen frames will not be reported.", e); } + + // Let's get the last frame timestamp from the choreographer private field + try { + choreographerLastFrameTimeField = + Choreographer.class.getDeclaredField("mLastFrameTimeNanos"); + choreographerLastFrameTimeField.setAccessible(true); + } catch (NoSuchFieldException e) { + logger.log( + SentryLevel.ERROR, + "Unable to get the frame timestamp from the choreographer: ", + e); + } }); - // Let's get the last frame timestamp from the choreographer private field - try { - choreographerLastFrameTimeField = Choreographer.class.getDeclaredField("mLastFrameTimeNanos"); - choreographerLastFrameTimeField.setAccessible(true); - } catch (NoSuchFieldException e) { - logger.log( - SentryLevel.ERROR, "Unable to get the frame timestamp from the choreographer: ", e); - } frameMetricsAvailableListener = (window, frameMetrics, dropCountSinceLastInvocation) -> { @@ -165,7 +170,8 @@ public SentryFrameMetricsCollector( final long delayNanos = Math.max(0, cpuDuration - expectedFrameDuration); long startTime = getFrameStartTimestamp(frameMetrics); - // If we couldn't get the timestamp through reflection, we use current time + // If we couldn't get the timestamp through FrameMetrics or reflection, we use the current + // time. if (startTime < 0) { startTime = now - cpuDuration; } @@ -217,8 +223,8 @@ public static boolean isSlow(long frameDuration, final long expectedFrameDuratio } /** - * Return the internal timestamp in the choreographer of the last frame start timestamp through - * reflection. On Android O the value is read from the frameMetrics itself. + * Return the frame start timestamp. On API 26+, this value is read directly from {@link + * FrameMetrics}; older APIs use the reflected Choreographer timestamp. */ @SuppressLint("NewApi") private long getFrameStartTimestamp(final @NotNull FrameMetrics frameMetrics) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 0f4b646856a..eb1a10dd646 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -14,6 +14,7 @@ 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; @@ -29,7 +30,6 @@ import io.sentry.android.core.internal.util.FirstDrawDoneListener; import io.sentry.protocol.SentryId; import io.sentry.util.AutoClosableReentrantLock; -import io.sentry.util.LazyEvaluator; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -69,14 +69,7 @@ public enum AppStartType { new AutoClosableReentrantLock(); private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; - private final LazyEvaluator appLaunchedInForeground = - new LazyEvaluator<>( - new LazyEvaluator.Evaluator() { - @Override - public @NotNull Boolean evaluate() { - return ContextUtils.isForegroundImportance(); - } - }); + private @Nullable volatile Boolean appLaunchedInForeground; private volatile long firstIdle = -1; private final @NotNull TimeSpan appStartSpan; @@ -208,13 +201,42 @@ public void setAppStartType(final @NotNull AppStartType appStartType) { } } + /** + * Whether {@link ApplicationStartInfo#getReason()} indicates the OS spawned the app process + * because of an intentional user interaction. + * + * @return true if the user actively launched the app, false if the app was launched in + * background, and null if unknown. + */ + @RequiresApi(api = Build.VERSION_CODES.VANILLA_ICE_CREAM) + private static @Nullable Boolean isForegroundStartReason(final int reason) { + switch (reason) { + case ApplicationStartInfo.START_REASON_LAUNCHER: + case ApplicationStartInfo.START_REASON_LAUNCHER_RECENTS: + case ApplicationStartInfo.START_REASON_START_ACTIVITY: + return true; + case ApplicationStartInfo.START_REASON_ALARM: + case ApplicationStartInfo.START_REASON_BACKUP: + case ApplicationStartInfo.START_REASON_BOOT_COMPLETE: + case ApplicationStartInfo.START_REASON_BROADCAST: + case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: + case ApplicationStartInfo.START_REASON_JOB: + case ApplicationStartInfo.START_REASON_PUSH: + case ApplicationStartInfo.START_REASON_SERVICE: + return false; + case ApplicationStartInfo.START_REASON_OTHER: + default: + return null; + } + } + public boolean isAppLaunchedInForeground() { - return appLaunchedInForeground.getValue(); + return Boolean.TRUE.equals(appLaunchedInForeground); } @VisibleForTesting public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { - this.appLaunchedInForeground.setValue(appLaunchedInForeground); + this.appLaunchedInForeground = appLaunchedInForeground; } public void setHeadlessAppStartListener(final @Nullable HeadlessAppStartListener listener) { @@ -288,8 +310,7 @@ public void onAppStartSpansSent() { } public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) { - return shouldSendStartMeasurements - && (ignoreForegroundCheck || appLaunchedInForeground.getValue()); + return shouldSendStartMeasurements && (ignoreForegroundCheck || isAppLaunchedInForeground()); } public boolean shouldSendStartMeasurements() { @@ -319,7 +340,7 @@ public long getClassLoadedUptimeMs() { final @NotNull SentryAndroidOptions options) { // If the app start type was never determined or app wasn't launched in foreground, // the app start is considered invalid - if (appStartType != AppStartType.UNKNOWN && appLaunchedInForeground.getValue()) { + if (appStartType != AppStartType.UNKNOWN && isAppLaunchedInForeground()) { if (options.isEnablePerformanceV2()) { // Only started when sdk version is >= N final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); @@ -382,7 +403,7 @@ public void clear() { } appStartContinuousProfiler = null; appStartSamplingDecision = null; - appLaunchedInForeground.resetValue(); + appLaunchedInForeground = null; isCallbackRegistered = false; shouldSendStartMeasurements = true; firstDrawDone.set(false); @@ -480,7 +501,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { return; } isCallbackRegistered = true; - appLaunchedInForeground.resetValue(); + appLaunchedInForeground = null; application.registerActivityLifecycleCallbacks(instance); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { @@ -499,6 +520,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } else { appStartType = AppStartType.WARM; } + appLaunchedInForeground = isForegroundStartReason(info.getReason()); } } } catch (RuntimeException ignored) { @@ -512,6 +534,10 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } } } + // Fallback, if no matching ApplicationStartInfo is available + if (appLaunchedInForeground == null) { + appLaunchedInForeground = ContextUtils.isForegroundImportance(); + } if (appStartType == AppStartType.UNKNOWN || headlessAppStartListener != null) { scheduleHeadlessAppStartCheckOnMain(); @@ -560,7 +586,7 @@ private void handleHeadlessAppStartIfNeededOnMain() { return; } - appLaunchedInForeground.setValue(false); + 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. @@ -646,7 +672,7 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved // 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 ((!appLaunchedInForeground.getValue() + if ((!isAppLaunchedInForeground() || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) && !appStartExtension.isActive()) { appStartType = AppStartType.WARM; @@ -667,7 +693,7 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved } } } - appLaunchedInForeground.setValue(true); + appLaunchedInForeground = true; } @Override @@ -713,7 +739,7 @@ public void onActivityDestroyed(@NonNull Activity activity) { // as the next onActivityCreated will treat it as a new warm app start if (remainingActivities == 0 && !activity.isChangingConfigurations()) { appStartType = AppStartType.WARM; - appLaunchedInForeground.setValue(true); + appLaunchedInForeground = true; shouldSendStartMeasurements = true; firstDrawDone.set(false); } diff --git a/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml b/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml index 722a0d5cf3d..370c37fa0e9 100644 --- a/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml +++ b/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml @@ -47,6 +47,7 @@ android:layout_height="wrap_content" android:hint="Your Name" android:inputType="textPersonName" + android:autofillHints="name" android:background="@drawable/sentry_edit_text_border" android:paddingHorizontal="8dp" android:layout_below="@id/sentry_dialog_user_feedback_txt_name" /> @@ -66,6 +67,7 @@ android:layout_height="wrap_content" android:hint="your.email@example.org" android:inputType="textEmailAddress" + android:autofillHints="emailAddress" android:background="@drawable/sentry_edit_text_border" android:paddingHorizontal="8dp" android:layout_below="@id/sentry_dialog_user_feedback_txt_email" /> @@ -85,6 +87,7 @@ android:layout_height="wrap_content" android:lines="6" android:inputType="textMultiLine" + android:importantForAutofill="no" android:gravity="top|left" android:hint="What's the bug? What did you expect?" android:background="@drawable/sentry_edit_text_border" diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index d198c8d975e..c79d418efe5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -23,6 +23,7 @@ import io.sentry.Scopes import io.sentry.Sentry import io.sentry.SentryDate import io.sentry.SentryDateProvider +import io.sentry.SentryExecutorService import io.sentry.SentryNanotimeDate import io.sentry.SentryTraceHeader import io.sentry.SentryTracer @@ -242,11 +243,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `Standalone app start transaction op is app start`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -257,8 +257,9 @@ class ActivityLifecycleIntegrationTest { verify(fixture.scopes, times(2)).startTransaction(any(), any()) val contexts = fixture.capturedContexts - val appStartContext = - contexts.single { it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP } + val appStartContext = contexts.single { + it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } assertEquals("App Start", appStartContext.name) assertEquals(TransactionNameSource.COMPONENT, appStartContext.transactionNameSource) val appStartTransaction = @@ -278,11 +279,10 @@ class ActivityLifecycleIntegrationTest { @Test @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) fun `Standalone app start transaction carries app start reason when available`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -304,11 +304,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `Standalone app start transaction has no app start reason when unavailable`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -325,11 +324,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extendAppStart eagerly creates a standalone app start transaction with the extended span`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -350,11 +348,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended app start continues the trace into ui load without a second app start transaction`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -381,11 +378,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended app start trace is not reused by a later activity`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -411,11 +407,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended app start screen is not overwritten by a later activity`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -436,11 +431,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended standalone app start transaction stays open until finishExtendedAppStart`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -463,11 +457,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended headless app start transaction stays open until finishExtendedAppStart`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.COLD) @@ -489,11 +482,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended headless app start persists the app start end time`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.COLD) @@ -506,11 +498,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `finished eager extended app start persists the app start end time`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -525,11 +516,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `activity long after the eager extended app start finished starts a fresh trace`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) // the eager extension starts at launch and finishes before any activity exists @@ -557,11 +547,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended headless app start does not create a duplicate when the extension already finished`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.COLD) @@ -590,11 +579,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `extended app start transaction is owned by the extension and survives activity destroy`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -611,11 +599,10 @@ class ActivityLifecycleIntegrationTest { @Test @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) fun `Headless standalone app start transaction carries app start reason when available`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.COLD) val startInfo = @@ -632,11 +619,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.UNKNOWN) @@ -674,11 +660,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `close clears HeadlessAppStartListener`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) sut.close() prepareHeadlessAppStart() @@ -690,11 +675,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `onHeadlessAppStart creates standalone App Start transaction and stashes trace id`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.COLD) @@ -721,11 +705,10 @@ class ActivityLifecycleIntegrationTest { @Test @Config(sdk = [Build.VERSION_CODES.M]) fun `onHeadlessAppStart creates standalone App Start transaction on API 23`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessSdkInitAppStart() @@ -748,11 +731,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `onHeadlessAppStart creates standalone App Start transaction when appStartType is WARM`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.WARM) @@ -767,11 +749,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `onHeadlessAppStart does nothing when appStartTimeSpan is incomplete`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) AppStartMetrics.getInstance().appStartTimeSpan.reset() AppStartMetrics.getInstance().sdkInitTimeSpan.reset() @@ -939,6 +920,8 @@ class ActivityLifecycleIntegrationTest { it.idleTimeout = 100 } ) + // the transaction idle timeout is scheduled on the dedicated timer executor + fixture.options.timerExecutorService = SentryExecutorService() sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, fixture.bundle) @@ -1086,11 +1069,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `When Activity is destroyed, sets standalone appStartTransaction status to cancelled and finish it`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -1462,11 +1444,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `launcher activity emits ui load and standalone App Start sharing trace id`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) val firstFrameDate = SentryNanotimeDate(1499, 0) fixture.options.dateProvider = SentryDateProvider { firstFrameDate } @@ -1517,11 +1498,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `launcher activity attaches lifecycle spans before finishing stopped standalone App Start`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) val appStartEndDate = SentryNanotimeDate(499, 0) setAppStartTime(SentryNanotimeDate(1, 0), appStartEndDate) @@ -1550,11 +1530,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `activity following a headless start reuses trace id and does not emit second standalone`() { val storedTraceId = SentryId() - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) // headless start always stores the trace header alongside the trace id; the ui.load txn // continues that trace via continueTrace, sharing the trace id. @@ -1576,11 +1555,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `activity within a minute of the headless start continues the same trace`() { val storedTraceId = SentryId() - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) AppStartMetrics.getInstance().appStartSentryTraceHeader = SentryTraceHeader(storedTraceId, SpanId(), true).value @@ -1600,11 +1578,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `activity more than a minute after the headless start starts a fresh trace`() { val storedTraceId = SentryId() - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) AppStartMetrics.getInstance().appStartSentryTraceHeader = SentryTraceHeader(storedTraceId, SpanId(), true).value @@ -1626,11 +1603,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `onHeadlessAppStart stores sentry-trace and baggage headers for continuation`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) prepareHeadlessAppStart(appStartType = AppStartType.COLD) @@ -1648,11 +1624,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `launcher activity shares standalone App Start trace and sampleRand as a sibling`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) setAppStartTime() // the app-start sampling decision carries the sampleRand the whole trace should share @@ -1681,11 +1656,10 @@ class ActivityLifecycleIntegrationTest { @Test fun `activity following a headless start shares stored trace and sampleRand as a sibling and clears headers`() { - val sut = - fixture.getSut { - it.tracesSampleRate = 1.0 - it.isEnableStandaloneAppStartTracing = true - } + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } sut.register(fixture.scopes, fixture.options) // 1) a headless start emits the standalone app.start and stores its trace headers @@ -2001,6 +1975,8 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true + // the timeout has to be really scheduled for cancelling it to be observable + fixture.options.executorService = DeferredExecutorService() sut.register(fixture.scopes, fixture.options) val activity = mock() val activity2 = mock() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt index 162e56c36e3..caaa30152a4 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt @@ -5,7 +5,6 @@ import android.os.Build import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.CompositePerformanceCollector -import io.sentry.DataCategory import io.sentry.IConnectionStatusProvider import io.sentry.ILogger import io.sentry.IScopes @@ -18,10 +17,8 @@ import io.sentry.TracesSampler import io.sentry.TransactionContext import io.sentry.android.core.internal.util.SentryFrameMetricsCollector import io.sentry.profilemeasurements.ProfileMeasurement -import io.sentry.protocol.SentryId import io.sentry.test.DeferredExecutorService import io.sentry.test.getProperty -import io.sentry.transport.RateLimiter import java.io.File import java.util.concurrent.Future import kotlin.test.AfterTest @@ -30,12 +27,10 @@ import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import org.junit.runner.RunWith -import org.mockito.Mockito import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.check @@ -51,6 +46,7 @@ import org.mockito.kotlin.whenever class AndroidContinuousProfilerTest { private lateinit var context: Context private val fixture = Fixture() + private lateinit var mocks: ProfilerMocks private class Fixture { private val mockDsn = "http://key@localhost/proj" @@ -143,6 +139,8 @@ class AndroidContinuousProfilerTest { Sentry.setCurrentScopes(fixture.scopes) fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + mocks = + ProfilerMocks(fixture.executor, fixture.mockTracesSampler, fixture.mockLogger, fixture.scopes) } @AfterTest @@ -151,110 +149,148 @@ class AndroidContinuousProfilerTest { fixture.mockedSentry.close() } + // -- TODO: Could be shared with PerfettoContinuousProfiler with some refactoring -- + @Test - fun `isRunning reflects profiler status`() { - val profiler = fixture.getSut() + fun `profiler ignores profilesSampleRate`() { + val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertTrue(profiler.isRunning) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - fixture.executor.runAll() - assertFalse(profiler.isRunning) } @Test - fun `stopProfiler stops the profiler after chunk is finished`() { + fun `profiler starts performance collector on start`() { + val performanceCollector = mock() + fixture.options.compositePerformanceCollector = performanceCollector val profiler = fixture.getSut() + verify(performanceCollector, never()).start(any()) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We are scheduling the profiler to stop at the end of the chunk, so it should still be running + verify(performanceCollector).start(any()) + } + + @Test + fun `profiler stops performance collector on stop`() { + val performanceCollector = mock() + fixture.options.compositePerformanceCollector = performanceCollector + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + verify(performanceCollector, never()).stop(any()) profiler.stopProfiler(ProfileLifecycle.MANUAL) - assertTrue(profiler.isRunning) - assertNotEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertNotEquals(SentryId.EMPTY_ID, profiler.chunkId) - // We run the executor service to trigger the chunk finish, and the profiler shouldn't restart fixture.executor.runAll() - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) + verify(performanceCollector).stop(any()) } @Test - fun `profiler multiple starts are ignored in manual mode`() { + fun `profiler stops collecting frame metrics when it stops`() { val profiler = fixture.getSut() + val frameMetricsCollectorId = "id" + whenever(fixture.frameMetricsCollector.startCollection(any())) + .thenReturn(frameMetricsCollectorId) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - verify(fixture.mockLogger, never()) - .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockLogger).log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) - assertTrue(profiler.isRunning) - assertEquals(0, profiler.rootSpanCounter) + verify(fixture.frameMetricsCollector, never()).stopCollection(frameMetricsCollectorId) + profiler.stopProfiler(ProfileLifecycle.MANUAL) + fixture.executor.runAll() + verify(fixture.frameMetricsCollector).stopCollection(frameMetricsCollectorId) } @Test - fun `profiler multiple starts are accepted in trace mode`() { - val profiler = fixture.getSut() + fun `profiler sends chunk with measurements`() { + val performanceCollector = mock() + val collectionData = PerformanceCollectionData(10) - // rootSpanCounter is incremented when the profiler starts in trace mode - assertEquals(0, profiler.rootSpanCounter) - profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) - assertEquals(1, profiler.rootSpanCounter) - assertTrue(profiler.isRunning) - profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) - verify(fixture.mockLogger, never()) - .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) - assertTrue(profiler.isRunning) - assertEquals(2, profiler.rootSpanCounter) + collectionData.usedHeapMemory = 2 + collectionData.usedNativeMemory = 3 + collectionData.cpuUsagePercentage = 3.0 + whenever(performanceCollector.stop(any())).thenReturn(listOf(collectionData)) - // rootSpanCounter is decremented when the profiler stops in trace mode, and keeps running until - // rootSpanCounter is 0 - profiler.stopProfiler(ProfileLifecycle.TRACE) + fixture.options.compositePerformanceCollector = performanceCollector + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + profiler.stopProfiler(ProfileLifecycle.MANUAL) fixture.executor.runAll() - assertEquals(1, profiler.rootSpanCounter) - assertTrue(profiler.isRunning) - - // only when rootSpanCounter is 0 the profiler stops - profiler.stopProfiler(ProfileLifecycle.TRACE) fixture.executor.runAll() - assertEquals(0, profiler.rootSpanCounter) - assertFalse(profiler.isRunning) + verify(fixture.scopes) + .captureProfileChunk( + check { + assertContains(it.measurements, ProfileMeasurement.ID_CPU_USAGE) + assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_FOOTPRINT) + assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT) + } + ) } + // -- Shared tests (see ContinuousProfilerTestCases.kt) -- + @Test - fun `profiler logs a warning on start if not sampled`() { - val profiler = fixture.getSut() - whenever(fixture.mockTracesSampler.sampleSessionProfile(any())).thenReturn(false) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertFalse(profiler.isRunning) - verify(fixture.mockLogger) - .log(eq(SentryLevel.DEBUG), eq("Profiler was not started due to sampling decision.")) - } + fun `isRunning reflects profiler status`() = fixture.getSut().testIsRunningReflectsStatus(mocks) @Test - fun `profiler evaluates sessionSampleRate only the first time`() { - val profiler = fixture.getSut() - verify(fixture.mockTracesSampler, never()).sampleSessionProfile(any()) - // The first time the profiler is started, the sessionSampleRate is evaluated - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - // Then, the sessionSampleRate is not evaluated again - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - } + fun `stopProfiler stops the profiler after chunk is finished`() = + fixture.getSut().testStopProfilerStopsAfterChunkFinished(mocks) + + @Test + fun `profiler multiple starts are accepted in trace mode`() = + fixture.getSut().testMultipleStartsAcceptedInTraceMode(mocks) + + @Test + fun `profiler logs a warning on start if not sampled`() = + fixture.getSut().testLogsWarningIfNotSampled(mocks) + + @Test + fun `profiler evaluates sessionSampleRate only the first time`() = + fixture.getSut().testEvaluatesSessionSampleRateOnlyOnce(mocks) + + @Test + fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() = + fixture.getSut().testReevaluateSamplingOnNextStart(mocks) + + @Test + fun `profiler stops and restart for each chunk`() = + fixture.getSut().testStopsAndRestartsForEachChunk(mocks) + + @Test + fun `profiler sends chunk on each restart`() = fixture.getSut().testSendsChunkOnRestart(mocks) + + @Test fun `profiler sends another chunk on stop`() = fixture.getSut().testSendsChunkOnStop(mocks) + + @Test + fun `close without terminating stops all profiles after chunk is finished`() = + fixture.getSut().testCloseWithoutTerminatingStopsAfterChunk(mocks) + + @Test + fun `profiler does not send chunks after close`() = + fixture.getSut().testDoesNotSendChunksAfterClose(mocks) + + @Test fun `profiler stops when rate limited`() = fixture.getSut().testStopsWhenRateLimited(mocks) + + @Test + fun `profiler does not start when rate limited`() = + fixture.getSut().testDoesNotStartWhenRateLimited(mocks) @Test - fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() { + fun `profiler does not start when offline`() = + fixture + .getSut { + it.connectionStatusProvider = mock { provider -> + whenever(provider.connectionStatus) + .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) + } + } + .testDoesNotStartWhenOffline(mocks) + + // -- Legacy-specific tests (AndroidContinuousProfiler only) -- + + @Test + fun `profiler multiple starts are ignored in manual mode`() { val profiler = fixture.getSut() - verify(fixture.mockTracesSampler, never()).sampleSessionProfile(any()) - // The first time the profiler is started, the sessionSampleRate is evaluated profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - // When reevaluateSampling is called, the sessionSampleRate is not evaluated immediately - profiler.reevaluateSampling() - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - // Then, when the profiler starts again, the sessionSampleRate is reevaluated + assertTrue(profiler.isRunning) + verify(fixture.mockLogger, never()) + .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(2)).sampleSessionProfile(any()) + verify(fixture.mockLogger).log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) + assertTrue(profiler.isRunning) + assertEquals(0, profiler.rootSpanCounter) } @Test @@ -268,25 +304,14 @@ class AndroidContinuousProfilerTest { assertFalse(profiler.isRunning) } - @Test - fun `profiler ignores profilesSampleRate`() { - val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - } - @Test fun `profiler evaluates profilingTracesDirPath options only on first start`() { - // We create the profiler, and nothing goes wrong val profiler = fixture.getSut { it.cacheDirPath = null } verify(fixture.mockLogger, never()) .log( SentryLevel.WARNING, "Disabling profiling because no profiling traces dir path is defined in options.", ) - - // Regardless of how many times the profiler is started, the option is evaluated and logged only - // once profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) verify(fixture.mockLogger, times(1)) @@ -298,13 +323,9 @@ class AndroidContinuousProfilerTest { @Test fun `profiler evaluates profilingTracesHz options only on first start`() { - // We create the profiler, and nothing goes wrong val profiler = fixture.getSut { it.profilingTracesHz = 0 } verify(fixture.mockLogger, never()) .log(SentryLevel.WARNING, "Disabling profiling because trace rate is set to %d", 0) - - // Regardless of how many times the profiler is started, the option is evaluated and logged only - // once profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) verify(fixture.mockLogger, times(1)) @@ -338,47 +359,11 @@ class AndroidContinuousProfilerTest { profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.stopProfiler(ProfileLifecycle.MANUAL) fixture.executor.runAll() - // We assert that no trace files are written assertTrue(File(fixture.options.profilingTracesDirPath!!).list()!!.isEmpty()) verify(fixture.mockLogger) .log(eq(SentryLevel.ERROR), eq("Error while stopping profiling: "), any()) } - @Test - fun `profiler starts performance collector on start`() { - val performanceCollector = mock() - fixture.options.compositePerformanceCollector = performanceCollector - val profiler = fixture.getSut() - verify(performanceCollector, never()).start(any()) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(performanceCollector).start(any()) - } - - @Test - fun `profiler stops performance collector on stop`() { - val performanceCollector = mock() - fixture.options.compositePerformanceCollector = performanceCollector - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(performanceCollector, never()).stop(any()) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - fixture.executor.runAll() - verify(performanceCollector).stop(any()) - } - - @Test - fun `profiler stops collecting frame metrics when it stops`() { - val profiler = fixture.getSut() - val frameMetricsCollectorId = "id" - whenever(fixture.frameMetricsCollector.startCollection(any())) - .thenReturn(frameMetricsCollectorId) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.frameMetricsCollector, never()).stopCollection(frameMetricsCollectorId) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - fixture.executor.runAll() - verify(fixture.frameMetricsCollector).stopCollection(frameMetricsCollectorId) - } - @Test fun `profiler stops profiling and clear scheduled job on close`() { val profiler = fixture.getSut() @@ -388,7 +373,6 @@ class AndroidContinuousProfilerTest { profiler.close(true) assertFalse(profiler.isRunning) - // The timeout scheduled job should be cleared val androidProfiler = profiler.getProperty("profiler") val scheduledJob = androidProfiler?.getProperty?>("scheduledFinish") assertNull(scheduledJob) @@ -398,166 +382,8 @@ class AndroidContinuousProfilerTest { assertTrue(stopFuture.isCancelled || stopFuture.isDone) } - @Test - fun `profiler stops and restart for each chunk`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - val oldChunkId = profiler.chunkId - - fixture.executor.runAll() - verify(fixture.mockLogger) - .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) - assertTrue(profiler.isRunning) - - fixture.executor.runAll() - verify(fixture.mockLogger, times(2)) - .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) - assertTrue(profiler.isRunning) - assertNotEquals(oldChunkId, profiler.chunkId) - } - - @Test - fun `profiler sends chunk on each restart`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We run the executor service to trigger the profiler restart (chunk finish) - fixture.executor.runAll() - verify(fixture.scopes, never()).captureProfileChunk(any()) - // Now the executor is used to send the chunk - fixture.executor.runAll() - verify(fixture.scopes).captureProfileChunk(any()) - } - - @Test - fun `profiler sends chunk with measurements`() { - val performanceCollector = mock() - val collectionData = PerformanceCollectionData(10) - - collectionData.usedHeapMemory = 2 - collectionData.usedNativeMemory = 3 - collectionData.cpuUsagePercentage = 3.0 - whenever(performanceCollector.stop(any())).thenReturn(listOf(collectionData)) - - fixture.options.compositePerformanceCollector = performanceCollector - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - // We run the executor service to stop the profiler - fixture.executor.runAll() - // Then we run it again to send the profile chunk - fixture.executor.runAll() - verify(fixture.scopes) - .captureProfileChunk( - check { - assertContains(it.measurements, ProfileMeasurement.ID_CPU_USAGE) - assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_FOOTPRINT) - assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT) - } - ) - } - - @Test - fun `profiler sends another chunk on stop`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We run the executor service to trigger the profiler restart (chunk finish) - fixture.executor.runAll() - verify(fixture.scopes, never()).captureProfileChunk(any()) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - // We stop the profiler, which should send a chunk - fixture.executor.runAll() - verify(fixture.scopes).captureProfileChunk(any()) - } - - @Test - fun `close without terminating stops all profiles after chunk is finished`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We are scheduling the profiler to stop at the end of the chunk, so it should still be running - profiler.close(false) - assertTrue(profiler.isRunning) - // However, close() already resets the rootSpanCounter - assertEquals(0, profiler.rootSpanCounter) - - // We run the executor service to trigger the chunk finish, and the profiler shouldn't restart - fixture.executor.runAll() - assertFalse(profiler.isRunning) - } - - @Test - fun `profiler does not send chunks after close`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - - // We close the profiler, which should prevent sending additional chunks - profiler.close(true) - - // The executor used to send the chunk doesn't do anything - fixture.executor.runAll() - verify(fixture.scopes, never()).captureProfileChunk(any()) - } - - @Test - fun `profiler stops when rate limited`() { - val profiler = fixture.getSut() - val rateLimiter = mock() - whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) - - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - - // If the SDK is rate limited, the profiler should stop - profiler.onRateLimitChanged(rateLimiter) - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) - verify(fixture.mockLogger) - .log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) - } - - @Test - fun `profiler does not start when rate limited`() { - val profiler = fixture.getSut() - val rateLimiter = mock() - whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) - whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) - - // If the SDK is rate limited, the profiler should never start - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) - verify(fixture.mockLogger) - .log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) - } - - @Test - fun `profiler does not start when offline`() { - val profiler = - fixture.getSut { - it.connectionStatusProvider = mock { provider -> - whenever(provider.connectionStatus) - .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) - } - } - - // If the device is offline, the profiler should never start - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) - verify(fixture.mockLogger) - .log(eq(SentryLevel.WARNING), eq("Device is offline. Stopping profiler.")) - } - fun withMockScopes(closure: () -> Unit) = - Mockito.mockStatic(Sentry::class.java).use { + mockStatic(Sentry::class.java).use { it.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) closure.invoke() } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidCpuCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidCpuCollectorTest.kt index 1b855aabc6c..422eb1ca804 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidCpuCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidCpuCollectorTest.kt @@ -1,13 +1,11 @@ package io.sentry.android.core +import com.google.common.truth.Truth.assertThat import io.sentry.ILogger import io.sentry.PerformanceCollectionData import io.sentry.test.getCtor import kotlin.test.Test import kotlin.test.assertFailsWith -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull import org.mockito.kotlin.mock class AndroidCpuCollectorTest { @@ -30,7 +28,7 @@ class AndroidCpuCollectorTest { fun `collect works only after setup`() { val data = PerformanceCollectionData(10) fixture.getSut().collect(data) - assertNull(data.cpuUsagePercentage) + assertThat(data.hasCpuUsagePercentage()).isFalse() } @Test @@ -39,8 +37,7 @@ class AndroidCpuCollectorTest { val collector = fixture.getSut() collector.setup() collector.collect(data) - val cpuData = data.cpuUsagePercentage - assertNotNull(cpuData) - assertNotEquals(0.0, cpuData) + assertThat(data.hasCpuUsagePercentage()).isTrue() + assertThat(data.cpuUsagePercentage).isNotEqualTo(0.0) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorTest.kt index ab83671fa0e..369f7f6a148 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorTest.kt @@ -68,12 +68,11 @@ class AndroidLoggerBatchProcessorTest { @Test fun `onBackground handles executor exception gracefully`() { - val sut = - fixture.getSut { options -> - val rejectingExecutor = mock() - whenever(rejectingExecutor.submit(any())).thenThrow(RuntimeException("Rejected")) - options.executorService = rejectingExecutor - } + val sut = fixture.getSut { options -> + val rejectingExecutor = mock() + whenever(rejectingExecutor.submit(any())).thenThrow(RuntimeException("Rejected")) + options.executorService = rejectingExecutor + } // Should not throw sut.onBackground() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMemoryCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMemoryCollectorTest.kt index 23214c040c8..4a7a621ede2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMemoryCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMemoryCollectorTest.kt @@ -1,11 +1,9 @@ package io.sentry.android.core import android.os.Debug +import com.google.common.truth.Truth.assertThat import io.sentry.PerformanceCollectionData import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull class AndroidMemoryCollectorTest { private val fixture = Fixture() @@ -21,10 +19,9 @@ class AndroidMemoryCollectorTest { val usedNativeMemory = Debug.getNativeHeapSize() - Debug.getNativeHeapFreeSize() val usedMemory = fixture.runtime.totalMemory() - fixture.runtime.freeMemory() fixture.collector.collect(data) - assertNotNull(data.usedHeapMemory) - assertNotNull(data.usedNativeMemory) - assertNotEquals(-1, data.usedNativeMemory) - assertEquals(usedNativeMemory, data.usedNativeMemory) - assertEquals(usedMemory, data.usedHeapMemory) + assertThat(data.hasUsedHeapMemory()).isTrue() + assertThat(data.hasUsedNativeMemory()).isTrue() + assertThat(data.usedNativeMemory).isEqualTo(usedNativeMemory) + assertThat(data.usedHeapMemory).isEqualTo(usedMemory) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt index fceb9ed3f4d..7d85502d149 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt @@ -67,12 +67,11 @@ class AndroidMetricsBatchProcessorTest { @Test fun `onBackground handles executor exception gracefully`() { - val sut = - fixture.getSut { options -> - val rejectingExecutor = mock() - whenever(rejectingExecutor.submit(any())).thenThrow(RuntimeException("Rejected")) - options.executorService = rejectingExecutor - } + val sut = fixture.getSut { options -> + val rejectingExecutor = mock() + whenever(rejectingExecutor.submit(any())).thenThrow(RuntimeException("Rejected")) + options.executorService = rejectingExecutor + } // Should not throw sut.onBackground() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index f8724d286f8..6df1ed7167e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -376,6 +376,27 @@ class AndroidOptionsInitializerTest { assertTrue(fixture.sentryOptions.continuousProfiler is AndroidContinuousProfiler) } + @Config(sdk = [35]) + @Test + fun `init on API 35+ always sets PerfettoContinuousProfiler`() { + fixture.initSut() + assertTrue(fixture.sentryOptions.continuousProfiler is PerfettoContinuousProfiler) + } + + @Config(sdk = [34]) + @Test + fun `init below API 35 with enableLegacyProfiling true sets AndroidContinuousProfiler`() { + fixture.initSut(configureOptions = { isEnableLegacyProfiling = true }) + assertTrue(fixture.sentryOptions.continuousProfiler is AndroidContinuousProfiler) + } + + @Config(sdk = [34]) + @Test + fun `init below API 35 with enableLegacyProfiling false noops profiler`() { + fixture.initSut(configureOptions = { isEnableLegacyProfiling = false }) + assertTrue(fixture.sentryOptions.continuousProfiler is NoOpContinuousProfiler) + } + @Test fun `init with profilesSampleRate should set Android transaction profiler`() { fixture.initSut(configureOptions = { profilesSampleRate = 1.0 }) @@ -403,6 +424,51 @@ class AndroidOptionsInitializerTest { assertEquals(fixture.sentryOptions.continuousProfiler, NoOpContinuousProfiler.getInstance()) } + @Test + fun `init with profilesSampleRate and enableLegacyProfiling false noops both profilers`() { + fixture.initSut( + configureOptions = { + profilesSampleRate = 1.0 + isEnableLegacyProfiling = false + } + ) + + assertEquals(NoOpTransactionProfiler.getInstance(), fixture.sentryOptions.transactionProfiler) + assertEquals(NoOpContinuousProfiler.getInstance(), fixture.sentryOptions.continuousProfiler) + } + + @Test + fun `init with profilesSampler and enableLegacyProfiling false noops both profilers`() { + fixture.initSut( + configureOptions = { + profilesSampler = mock() + isEnableLegacyProfiling = false + } + ) + + assertEquals(NoOpTransactionProfiler.getInstance(), fixture.sentryOptions.transactionProfiler) + assertEquals(NoOpContinuousProfiler.getInstance(), fixture.sentryOptions.continuousProfiler) + } + + @Test + fun `init with profilesSampleRate and enableLegacyProfiling false closes app start profiler`() { + val appStartProfiler = mock() + AppStartMetrics.getInstance().appStartProfiler = appStartProfiler + fixture.initSut( + configureOptions = { + profilesSampleRate = 1.0 + isEnableLegacyProfiling = false + } + ) + + assertEquals(NoOpTransactionProfiler.getInstance(), fixture.sentryOptions.transactionProfiler) + verify(appStartProfiler).close() + + // AppStartMetrics should be cleared + assertNull(AppStartMetrics.getInstance().appStartProfiler) + assertNull(AppStartMetrics.getInstance().appStartContinuousProfiler) + } + @Test fun `init reuses transaction profiler of appStartMetrics, if exists`() { val appStartProfiler = mock() @@ -843,6 +909,21 @@ class AndroidOptionsInitializerTest { assertTrue { fixture.sentryOptions.optionsObservers.any { it is PersistingOptionsObserver } } } + @Test + fun `options cache generation observer is set when app update time is valid`() { + val buildInfo = mock() + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.LOLLIPOP) + ContextUtils.getPackageInfo(fixture.context, buildInfo)!!.lastUpdateTime = 1_000L + + fixture.initSut(useRealContext = true) + + assertTrue { + fixture.sentryOptions.optionsObservers.any { + it is PersistingOptionsCacheGenerationObserver + } + } + } + @Test fun `when cacheDir is not set, persisting observers are not set to options`() { fixture.initSut(configureOptions = { cacheDirPath = null }) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index e7583429910..176ca460eb1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -11,6 +11,7 @@ import io.sentry.Hint import io.sentry.IScopes import io.sentry.IpAddressUtils import io.sentry.NoOpLogger +import io.sentry.ProfileChunk import io.sentry.Sentry import io.sentry.SentryBaseEvent import io.sentry.SentryEvent @@ -27,6 +28,7 @@ import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME +import io.sentry.cache.PersistingOptionsObserver.TAGS_FILENAME as OPTIONS_TAGS_FILENAME import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME @@ -74,7 +76,9 @@ import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow @@ -152,7 +156,7 @@ class ApplicationExitInfoEventProcessorTest { persistOptions(SDK_VERSION_FILENAME, SdkVersion("sentry.java.android", "6.15.0")) persistOptions(DIST_FILENAME, "232") persistOptions(ENVIRONMENT_FILENAME, "debug") - persistOptions(TAGS_FILENAME, mapOf("option" to "tag")) + persistOptions(OPTIONS_TAGS_FILENAME, mapOf("option" to "tag")) replayErrorSampleRate?.let { persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, it.toString()) } @@ -198,6 +202,7 @@ class ApplicationExitInfoEventProcessorTest { @BeforeTest fun `set up`() { DeviceInfoUtil.resetInstance() + ContextUtils.resetInstance() fixture.context = ApplicationProvider.getApplicationContext() } @@ -390,14 +395,199 @@ class ApplicationExitInfoEventProcessorTest { } @Test - fun `if environment is not persisted, uses environment from options`() { - val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + fun `if environment is not persisted and app was not updated, uses environment from options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + setLastUpdateTime(1_000) val processed = processEvent(hint) assertEquals("release", processed.environment) } + @Test + fun `if release is not persisted and app was not updated, uses release from options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.2.0+232", processed.release) + } + + @Test + fun `if release is not persisted and app was updated, leaves release empty`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 1_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(2_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + } + + @Test + fun `if exit timestamp is unknown, leaves release empty`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + } + + @Test + fun `if last update time is invalid, leaves release empty`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 1_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(-1) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + } + + @Test + fun `if dist is not persisted and app was not updated, uses version code from options release`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("232", processed.dist) + } + + @Test + fun `if app version is not persisted and app was not updated, uses options release`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("1.2.0", processed.contexts.app!!.appVersion) + assertEquals("232", processed.contexts.app!!.appBuild) + } + + @Test + fun `historical event uses current options when app was not updated`() { + val hint = + HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false, timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + fixture.options.environment = "production" + fixture.options.dist = "custom-dist" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.2.0+232", processed.release) + assertEquals("production", processed.environment) + assertEquals("custom-dist", processed.dist) + val app = processed.contexts.app!! + assertEquals("1.2.0", app.appVersion) + assertEquals("232", app.appBuild) + assertNull(app.appName) + assertNull(app.appIdentifier) + } + + @Test + fun `if options cache is from an older app update, uses current options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 3_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@2.0.0+300" + fixture.options.environment = "current-user" + fixture.options.dist = "current-dist" + fixture.options.proguardUuid = "current-uuid" + fixture.options.sdkVersion = SdkVersion("current-sdk", "2.0.0") + fixture.options.setTag("account", "current-tag") + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100") + fixture.persistOptions(ENVIRONMENT_FILENAME, "previous-user") + fixture.persistOptions(DIST_FILENAME, "previous-dist") + fixture.persistOptions(PROGUARD_UUID_FILENAME, "previous-uuid") + fixture.persistOptions(SDK_VERSION_FILENAME, SdkVersion("previous-sdk", "1.0.0")) + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "previous-tag")) + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(2_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@2.0.0+300", processed.release) + assertEquals("current-user", processed.environment) + assertEquals("current-dist", processed.dist) + assertEquals("current-uuid", processed.debugMeta!!.images!![0].uuid) + assertEquals("current-sdk", processed.sdk!!.name) + assertEquals("current-tag", processed.tags!!["account"]) + } + + @Test + fun `if options cache is from current app update, uses persisted options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.0.0+100" + fixture.options.environment = "current-user" + fixture.options.dist = "current-dist" + fixture.options.setTag("account", "current-tag") + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100") + fixture.persistOptions(ENVIRONMENT_FILENAME, "crashed-user") + fixture.persistOptions(DIST_FILENAME, "crashed-dist") + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "crashed-tag")) + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.0.0+100", processed.release) + assertEquals("crashed-user", processed.environment) + assertEquals("crashed-dist", processed.dist) + assertEquals("crashed-tag", processed.tags!!["account"]) + } + + @Test + fun `if options cache was written after the exit, ignores persisted options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@2.0.0+200") + fixture.persistOptions(ENVIRONMENT_FILENAME, "newer-user") + fixture.persistOptions(DIST_FILENAME, "newer-dist") + fixture.persistOptions(PROGUARD_UUID_FILENAME, "newer-uuid") + fixture.persistOptions(SDK_VERSION_FILENAME, SdkVersion("newer-sdk", "2.0.0")) + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "newer-tag")) + PersistingOptionsCacheGenerationObserver(fixture.options, 2_500L).setRelease(null) + setLastUpdateTime(3_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + assertNull(processed.environment) + assertNull(processed.dist) + assertTrue(processed.debugMeta!!.images!!.isEmpty()) + assertNull(processed.sdk) + assertNull(processed.tags?.get("account")) + } + + @Test + fun `historical event leaves release empty when app was updated`() { + val hint = + HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false, timestamp = 1_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(2_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + assertNull(processed.contexts.app) + } + @Test fun `if dist is not persisted, backfills it from release`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -822,9 +1012,69 @@ class ApplicationExitInfoEventProcessorTest { mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) val processed = processor.process(SentryEvent(), hint) + val chunkCaptor = argumentCaptor() + verify(scopes).captureProfileChunk(chunkCaptor.capture()) + val sentryProfile = chunkCaptor.firstValue.sentryProfile assertNotNull(processed?.contexts?.profile) assertNotNull(processed.contexts.profile?.profilerId) + assertNotNull(sentryProfile) + // Two samples are present b/c the converter adds a synthetic one to keep Relay happy. + assertEquals(2, sentryProfile.samples.size) + } + } + + @Test + fun `uses persisted proguard uuid for ANR profile chunk after app update`() { + fixture.options.anrProfilingSampleRate = 1.0 + fixture.options.proguardUuid = "current-uuid" + val processor = + fixture.getSut( + tmpDir, + populateScopeCache = false, + populateOptionsCache = false, + isSendDefaultPii = false, + ) + fixture.persistOptions(PROGUARD_UUID_FILENAME, "previous-uuid") + setLastUpdateTime(2_000) + + val hint = + HintUtils.createWithTypeCheckHint( + AbnormalExitHint(mechanism = "anr_foreground", timestamp = 1_000) + ) + + AnrProfileManager( + fixture.options, + AnrProfileRotationHelper.getFileForRecording(File(fixture.options.cacheDirPath!!)), + ) + .apply { + add( + AnrStackTrace( + 1_000, + arrayOf( + StackTraceElement("com.example.MyApp", "blocked", "MyApp.java", 42), + StackTraceElement("android.os.Handler", "dispatchMessage", "Handler.java", 5678), + ), + ) + ) + close() + } + AnrProfileRotationHelper.rotate() + + val scopes = mock() + whenever(scopes.captureProfileChunk(any())).thenReturn(SentryId()) + + mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + + processor.process(SentryEvent(), hint) + + val chunkCaptor = argumentCaptor() + verify(scopes).captureProfileChunk(chunkCaptor.capture()) + val images = chunkCaptor.firstValue.debugMeta!!.images!! + assertEquals(1, images.size) + assertEquals(DebugImage.PROGUARD, images[0].type) + assertEquals("previous-uuid", images[0].uuid) } } @@ -890,6 +1140,39 @@ class ApplicationExitInfoEventProcessorTest { assertNull(processed.contexts[Contexts.REPLAY_ID]) } + @Test + fun `if options cache is current, uses persisted replay error sample rate`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + fixture.options.sessionReplay.onErrorSampleRate = 1.0 + fixture.persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, "0.0") + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.contexts[Contexts.REPLAY_ID]) + } + + @Test + fun `if options cache is stale, uses current replay error sample rate`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 3_000)) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + fixture.options.sessionReplay.onErrorSampleRate = 1.0 + fixture.persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, "0.0") + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(2_000) + val replayId = SentryId() + File(fixture.options.cacheDirPath, "replay_$replayId").also { + it.mkdirs() + it.setLastModified(1_000) + } + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals(replayId.toString(), processed.contexts[Contexts.REPLAY_ID].toString()) + } + @Test fun `set replayId of the last modified folder`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -938,15 +1221,21 @@ class ApplicationExitInfoEventProcessorTest { return processor.process(original, hint)!! } + private fun setLastUpdateTime(lastUpdateTime: Long) { + ContextUtils.getPackageInfo(fixture.context, fixture.buildInfo)!!.lastUpdateTime = + lastUpdateTime + } + internal class AbnormalExitHint( val mechanism: String? = null, private val shouldEnrich: Boolean = true, + private val timestamp: Long? = null, ) : AbnormalExit, Backfillable { override fun mechanism(): String? = mechanism override fun ignoreCurrentThread(): Boolean = false - override fun timestamp(): Long? = null + override fun timestamp(): Long? = timestamp override fun shouldEnrich(): Boolean = shouldEnrich } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt index 649e14e413b..edb2ce1df24 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt @@ -369,7 +369,11 @@ abstract class ApplicationExitIntegrationTestBase { val hintAccessors: HintAccessors, val addExitInfo: ApplicationExitTestFixture.( - reason: Int?, timestamp: Long?, importance: Int?, addTrace: Boolean, addBadTrace: Boolean, + reason: Int?, + timestamp: Long?, + importance: Int?, + addTrace: Boolean, + addBadTrace: Boolean, ) -> Unit, val flushLogPrefix: String, ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ChunkMeasurementCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ChunkMeasurementCollectorTest.kt new file mode 100644 index 00000000000..0841f974961 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ChunkMeasurementCollectorTest.kt @@ -0,0 +1,147 @@ +package io.sentry.android.core + +import io.sentry.CompositePerformanceCollector +import io.sentry.PerformanceCollectionData +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector +import io.sentry.profilemeasurements.ProfileMeasurement +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class ChunkMeasurementCollectorTest { + + /** + * Drives [PerfettoContinuousProfiler.ChunkMeasurementCollector] through two full `start -> + * collect -> stop` cycles to assert that the metrics collected are correct. + */ + @Test + fun `each start-stop cycle returns its own independent measurements`() { + val frameMetricsCollector: SentryFrameMetricsCollector = mock() + val performanceCollector: CompositePerformanceCollector = mock() + val collector = PerfettoContinuousProfiler.ChunkMeasurementCollector(frameMetricsCollector) + val listenerCaptor = argumentCaptor() + + // Return distinct performance data for each stop() call. + whenever(performanceCollector.stop(any())) + .thenReturn( + // Cycle 1: 2 samples, both with cpu + heap, only first with native. + listOf( + perfData(nanos = 100L, cpu = 10.0, heap = 1_000L, native = 500L), + perfData(nanos = 200L, cpu = 20.0, heap = 2_000L, native = null), + ), + // Cycle 2: 3 samples, all with heap, only some with cpu/native. + listOf( + perfData(nanos = 1_000L, cpu = 30.0, heap = 3_000L, native = null), + perfData(nanos = 1_100L, cpu = null, heap = 4_000L, native = 800L), + perfData(nanos = 1_200L, cpu = 50.0, heap = 5_000L, native = 900L), + ), + ) + + // --- Cycle 1 --- + collector.start(performanceCollector, "chunk-1") + verify(frameMetricsCollector).startCollection(listenerCaptor.capture()) + // frameEndNanos comes from System.nanoTime(), so it must be based on the current reading for + // the resulting chunk-relative timestamp to be non-negative. + var frameEnd = futureFrameEndNanos() + // onFrameMetricCollected(frameStart, frameEnd, duration, delay, isSlow, isFrozen, refreshRate) + listenerCaptor.lastValue.apply { + onFrameMetricCollected(0L, frameEnd, 100L, 0L, true, false, 60.0f) // slow + onFrameMetricCollected(0L, frameEnd, 800L, 0L, false, true, 60.0f) // frozen + onFrameMetricCollected(0L, frameEnd, 50L, 0L, false, false, 90.0f) // refresh change + } + val chunk1 = collector.stop() + + // --- Cycle 2 --- + collector.start(performanceCollector, "chunk-2") + verify(frameMetricsCollector, times(2)).startCollection(listenerCaptor.capture()) + frameEnd = futureFrameEndNanos() + listenerCaptor.lastValue.apply { + onFrameMetricCollected(0L, frameEnd, 150L, 0L, true, false, 60.0f) // slow + onFrameMetricCollected(0L, frameEnd, 200L, 0L, true, false, 60.0f) // slow + onFrameMetricCollected(0L, frameEnd, 900L, 0L, false, true, 60.0f) // frozen + } + val chunk2 = collector.stop() + + // Cycle 1: 1 slow, 1 frozen; refresh rate goes 0 -> 60 -> 90 (2 changes recorded); + // 2 cpu samples, 2 heap samples, 1 native sample. + assertChunkCounts(chunk1, slow = 1, frozen = 1, refreshRate = 2, cpu = 2, heap = 2, native = 1) + // Cycle 2: 2 slow, 1 frozen; refresh rate goes 0 -> 60 (1 change recorded); + // 2 cpu samples (one was null), 3 heap samples, 2 native samples. + assertChunkCounts(chunk2, slow = 2, frozen = 1, refreshRate = 1, cpu = 2, heap = 3, native = 2) + } + + @Test + fun `frames ending before the chunk started are dropped`() { + val frameMetricsCollector: SentryFrameMetricsCollector = mock() + val collector = PerfettoContinuousProfiler.ChunkMeasurementCollector(frameMetricsCollector) + val listenerCaptor = argumentCaptor() + + collector.start(null, "chunk-1") + verify(frameMetricsCollector).startCollection(listenerCaptor.capture()) + + val staleFrameEnd = System.nanoTime() - TimeUnit.HOURS.toNanos(1) + listenerCaptor.lastValue.apply { + onFrameMetricCollected(0L, staleFrameEnd, 100L, 0L, true, false, 60.0f) + onFrameMetricCollected(0L, staleFrameEnd, 800L, 0L, false, true, 60.0f) + onFrameMetricCollected(0L, futureFrameEndNanos(), 150L, 0L, true, false, 60.0f) + } + + val measurements = collector.stop() + + assertChunkCounts( + measurements, + slow = 1, + frozen = 0, + refreshRate = 1, + cpu = 0, + heap = 0, + native = 0, + ) + } + + /** + * A frameEndNanos far enough ahead of the collector's own `System.nanoTime()` reading that the + * chunk-relative timestamp stays positive regardless of test execution timing. + */ + private fun futureFrameEndNanos() = System.nanoTime() + TimeUnit.MINUTES.toNanos(1) + + /** A null measurement is left unset, as it would be by a collector that did not report it. */ + private fun perfData(nanos: Long, cpu: Double?, heap: Long?, native: Long?) = + PerformanceCollectionData(nanos).apply { + cpu?.let { cpuUsagePercentage = it } + heap?.let { usedHeapMemory = it } + native?.let { usedNativeMemory = it } + } + + private fun assertChunkCounts( + measurements: Map, + slow: Int, + frozen: Int, + refreshRate: Int, + cpu: Int, + heap: Int, + native: Int, + ) { + assertEquals(slow, measurements[ProfileMeasurement.ID_SLOW_FRAME_RENDERS]?.values?.size ?: 0) + assertEquals( + frozen, + measurements[ProfileMeasurement.ID_FROZEN_FRAME_RENDERS]?.values?.size ?: 0, + ) + assertEquals( + refreshRate, + measurements[ProfileMeasurement.ID_SCREEN_FRAME_RATES]?.values?.size ?: 0, + ) + assertEquals(cpu, measurements[ProfileMeasurement.ID_CPU_USAGE]?.values?.size ?: 0) + assertEquals(heap, measurements[ProfileMeasurement.ID_MEMORY_FOOTPRINT]?.values?.size ?: 0) + assertEquals( + native, + measurements[ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT]?.values?.size ?: 0, + ) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt new file mode 100644 index 00000000000..5e4e0504ddf --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt @@ -0,0 +1,194 @@ +package io.sentry.android.core + +import io.sentry.DataCategory +import io.sentry.IContinuousProfiler +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ProfileLifecycle +import io.sentry.SentryLevel +import io.sentry.TracesSampler +import io.sentry.protocol.SentryId +import io.sentry.test.DeferredExecutorService +import io.sentry.transport.RateLimiter +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * Shared dependencies for profiler test cases. Each test class creates one from its own fixture. + */ +class ProfilerMocks( + val executor: DeferredExecutorService, + val tracesSampler: TracesSampler, + val logger: ILogger, + val scopes: IScopes, +) + +// -- Shared test cases as extension functions on IContinuousProfiler -- + +fun IContinuousProfiler.testIsRunningReflectsStatus(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + stopProfiler(ProfileLifecycle.MANUAL) + mocks.executor.runAll() + assertFalse(isRunning) +} + +fun IContinuousProfiler.testStopProfilerStopsAfterChunkFinished(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + stopProfiler(ProfileLifecycle.MANUAL) + assertTrue(isRunning) + assertNotEquals(SentryId.EMPTY_ID, profilerId) + assertNotEquals(SentryId.EMPTY_ID, chunkId) + mocks.executor.runAll() + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) +} + +fun IContinuousProfiler.testMultipleStartsAcceptedInTraceMode(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + assertTrue(isRunning) + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + assertTrue(isRunning) + + stopProfiler(ProfileLifecycle.TRACE) + mocks.executor.runAll() + assertTrue(isRunning) + + stopProfiler(ProfileLifecycle.TRACE) + mocks.executor.runAll() + assertFalse(isRunning) +} + +fun IContinuousProfiler.testLogsWarningIfNotSampled(mocks: ProfilerMocks) { + whenever(mocks.tracesSampler.sampleSessionProfile(any())).thenReturn(false) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertFalse(isRunning) + verify(mocks.logger) + .log(eq(SentryLevel.DEBUG), eq("Profiler was not started due to sampling decision.")) +} + +fun IContinuousProfiler.testEvaluatesSessionSampleRateOnlyOnce(mocks: ProfilerMocks) { + verify(mocks.tracesSampler, never()).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) +} + +fun IContinuousProfiler.testReevaluateSamplingOnNextStart(mocks: ProfilerMocks) { + verify(mocks.tracesSampler, never()).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) + reevaluateSampling() + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(2)).sampleSessionProfile(any()) +} + +fun IContinuousProfiler.testStopsAndRestartsForEachChunk(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + val oldChunkId = chunkId + + mocks.executor.runAll() + verify(mocks.logger).log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) + assertTrue(isRunning) + + mocks.executor.runAll() + verify(mocks.logger, times(2)) + .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) + assertTrue(isRunning) + assertNotEquals(oldChunkId, chunkId) +} + +fun IContinuousProfiler.testSendsChunkOnRestart(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + mocks.executor.runAll() + verify(mocks.scopes, never()).captureProfileChunk(any()) + mocks.executor.runAll() + verify(mocks.scopes).captureProfileChunk(any()) +} + +fun IContinuousProfiler.testSendsChunkOnStop(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + mocks.executor.runAll() + verify(mocks.scopes, never()).captureProfileChunk(any()) + stopProfiler(ProfileLifecycle.MANUAL) + mocks.executor.runAll() + verify(mocks.scopes).captureProfileChunk(any()) +} + +fun IContinuousProfiler.testCloseWithoutTerminatingStopsAfterChunk(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + assertTrue(isRunning) + close(false) + assertTrue(isRunning) + mocks.executor.runAll() + assertFalse(isRunning) +} + +fun IContinuousProfiler.testDoesNotSendChunksAfterClose(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + close(true) + mocks.executor.runAll() + verify(mocks.scopes, never()).captureProfileChunk(any()) +} + +fun IContinuousProfiler.testStopsWhenRateLimited(mocks: ProfilerMocks) { + val rateLimiter = mock() + whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + (this as RateLimiter.IRateLimitObserver).onRateLimitChanged(rateLimiter) + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) + verify(mocks.logger).log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) +} + +fun IContinuousProfiler.testDoesNotStartWhenRateLimited(mocks: ProfilerMocks) { + val rateLimiter = mock() + whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) + whenever(mocks.scopes.rateLimiter).thenReturn(rateLimiter) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) + verify(mocks.logger).log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) +} + +fun IContinuousProfiler.testDoesNotStartWhenOffline(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) + verify(mocks.logger).log(eq(SentryLevel.WARNING), eq("Device is offline. Stopping profiler.")) +} + +fun IContinuousProfiler.testCanBeStartedAgainAfterStopCycle(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + stopProfiler(ProfileLifecycle.MANUAL) + mocks.executor.runAll() + assertFalse(isRunning) + + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + mocks.executor.runAll() + assertTrue(isRunning, "shouldStop must be reset on start") +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt index 97276d67566..0b13f4ca4d8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt @@ -14,6 +14,8 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -122,4 +124,19 @@ class EnvelopeFileObserverIntegrationTest { verify(fixture.logger) .log(eq(SentryLevel.DEBUG), eq("EnvelopeFileObserverIntegration installed.")) } + + @Test + fun `register creates the outbox dir when it does not exist yet`() { + val outboxDir = File(file, "outbox") + assertFalse(outboxDir.exists()) + + fixture.getSut { it.executorService = ImmediateExecutorService() } + val integration = + object : EnvelopeFileObserverIntegration() { + override fun getPath(options: SentryOptions): String = outboxDir.absolutePath + } + integration.register(fixture.scopes, fixture.scopes.options) + + assertTrue(outboxDir.isDirectory) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt index bddc9395c0d..f2118fb76de 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt @@ -2,15 +2,32 @@ package io.sentry.android.core import android.app.Activity import android.app.Application +import android.app.Dialog +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Handler +import android.view.WindowManager import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.Scopes import io.sentry.SentryFeedbackOptions +import io.sentry.test.DeferredExecutorService +import io.sentry.test.ImmediateExecutorService import kotlin.test.BeforeTest import kotlin.test.Test import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.isA import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -20,7 +37,11 @@ class FeedbackShakeIntegrationTest { private class Fixture { val application = mock() val scopes = mock() - val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } + val options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + executorService = ImmediateExecutorService() + } val activity = mock() val formHandler = mock() @@ -49,6 +70,59 @@ class FeedbackShakeIntegrationTest { verify(fixture.application).registerActivityLifecycleCallbacks(any()) } + @Test + fun `resolves the accelerometer sensor off the main thread`() { + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + // Callback registration stays synchronous, but the expensive SensorManager lookup is deferred. + verify(fixture.application).registerActivityLifecycleCallbacks(any()) + verify(fixture.application, never()).getSystemService(eq(Context.SENSOR_SERVICE)) + + deferredExecutor.runAll() + + verify(fixture.application).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `warm-up drained after close does not resolve the sensor`() { + // Integrations are closed before the executor drains, so a queued warm-up can run after + // close(). It must be a no-op rather than resolving the sensor and spinning up a HandlerThread. + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + sut.close() + + deferredExecutor.runAll() + + verify(fixture.application, never()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `re-registering after close re-arms shake detection`() { + // A second Sentry.init reusing the same integration must revive shake detection rather than + // stay off because of the closed latch. + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + sut.close() + sut.register(fixture.scopes, fixture.options) + + deferredExecutor.runAll() + + verify(fixture.application, atLeastOnce()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + @Test fun `when useShakeGesture is disabled does not register activity lifecycle callbacks`() { val sut = fixture.getSut(useShakeGesture = false) @@ -103,4 +177,316 @@ class FeedbackShakeIntegrationTest { val sut = fixture.getSut() sut.close() } + + @Test + fun `register sets itself as shake controller even when useShakeGesture is disabled`() { + val sut = fixture.getSut(useShakeGesture = false) + sut.register(fixture.scopes, fixture.options) + + assertThat(fixture.options.feedbackOptions.shakeController).isSameInstanceAs(sut) + assertThat(sut.isOnShakeEnabled).isFalse() + } + + @Test + fun `enable after register starts shake detection at runtime`() { + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = false) + sut.register(fixture.scopes, fixture.options) + verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) + + sut.enableOnShake() + + assertThat(sut.isOnShakeEnabled).isTrue() + verify(fixture.application).registerActivityLifecycleCallbacks(any()) + // Hooks into the already-resumed activity + verify(fixture.activity).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `enable is idempotent`() { + val sut = fixture.getSut(useShakeGesture = false) + sut.register(fixture.scopes, fixture.options) + + sut.enableOnShake() + sut.enableOnShake() + + verify(fixture.application, times(1)).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `disable stops shake detection at runtime`() { + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + sut.disableOnShake() + + assertThat(sut.isOnShakeEnabled).isFalse() + verify(fixture.application).unregisterActivityLifecycleCallbacks(any()) + } + + @Test + fun `disable is idempotent`() { + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + sut.disableOnShake() + sut.disableOnShake() + + verify(fixture.application, times(1)).unregisterActivityLifecycleCallbacks(any()) + } + + @Test + fun `disable when never enabled does not unregister callbacks`() { + val sut = fixture.getSut(useShakeGesture = false) + sut.register(fixture.scopes, fixture.options) + + sut.disableOnShake() + + verify(fixture.application, never()).unregisterActivityLifecycleCallbacks(any()) + } + + @Test + fun `enable before register is a no-op`() { + val sut = fixture.getSut(useShakeGesture = false) + + sut.enableOnShake() + + assertThat(sut.isOnShakeEnabled).isFalse() + verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `re-enable after disable re-arms shake detection`() { + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + sut.disableOnShake() + sut.enableOnShake() + + deferredExecutor.runAll() + + assertThat(sut.isOnShakeEnabled).isTrue() + verify(fixture.application, atLeastOnce()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `close disables shake detection`() { + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + sut.close() + + assertThat(sut.isOnShakeEnabled).isFalse() + } + + @Test + fun `a visible dialog does not tear down the detection machinery`() { + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + val dialog = mock

() + sut.onDialogVisible(fixture.activity, dialog) + sut.onDialogGone(dialog) + + verify(fixture.application, never()).unregisterActivityLifecycleCallbacks(any()) + assertThat(sut.isOnShakeEnabled).isTrue() + } + + @Test + fun `a dialog suppresses detection on the activity it belongs to`() { + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + sut.onDialogVisible(fixture.activity, mock()) + assertThat(sut.dialogActivity).isSameInstanceAs(fixture.activity) + + // Coming back to the activity the dialog is on (e.g. screen off/on) must not re-arm detection, + // otherwise a shake would stack a second dialog on top of the visible one. + sut.onActivityResumed(fixture.activity) + + verify(fixture.activity, never()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `a dialog on a backgrounded activity does not suppress detection on the next one`() { + // A dialog lives in the window of the activity that created it, so once that activity is no + // longer resumed the dialog cannot be seen - it must not keep detection off on the activity + // now in front. Android's order is A.onPause() -> B.onResume(), so exercise exactly that. + val otherActivity = mock() + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + whenever(otherActivity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + sut.onActivityResumed(fixture.activity) + sut.onDialogVisible(fixture.activity, mock()) + + sut.onActivityPaused(fixture.activity) + sut.onActivityResumed(otherActivity) + + verify(otherActivity).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `a dialog reports the activity it is showing on, not the current one`() { + // The dialog's host activity is what a stacked dialog would land on, so a mid-transition + // CurrentActivityHolder must not decide which activity detection is suppressed for. + val otherActivity = mock() + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + CurrentActivityHolder.getInstance().setActivity(otherActivity) + sut.onDialogVisible(fixture.activity, mock()) + + assertThat(sut.dialogActivity).isSameInstanceAs(fixture.activity) + + sut.onActivityResumed(fixture.activity) + + verify(fixture.activity, never()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `dismissing a dialog re-arms detection on the current activity`() { + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + sut.onActivityResumed(fixture.activity) + val dialog = mock() + sut.onDialogVisible(fixture.activity, dialog) + sut.onDialogGone(dialog) + + assertThat(sut.dialogActivity).isNull() + verify(fixture.activity, atLeastOnce()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `dismissing one of two visible dialogs keeps detection suppressed`() { + // Two dialogs can be visible at once, e.g. when the app calls showForm() while a dialog is + // already up. The first one going away must not re-arm detection under the second. + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + sut.onActivityResumed(fixture.activity) + val first = mock() + val second = mock() + sut.onDialogVisible(fixture.activity, first) + sut.onDialogVisible(fixture.activity, second) + + sut.onDialogGone(first) + assertThat(sut.dialogActivity).isSameInstanceAs(fixture.activity) + verify(fixture.activity, times(1)).getSystemService(eq(Context.SENSOR_SERVICE)) + + sut.onDialogGone(second) + assertThat(sut.dialogActivity).isNull() + verify(fixture.activity, times(2)).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `reporting the same dialog gone twice re-arms detection only once`() { + // A dismissed dialog reports back from both onStop() and onDetachedFromWindow(). + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + sut.onActivityResumed(fixture.activity) + val dialog = mock() + sut.onDialogVisible(fixture.activity, dialog) + sut.onDialogGone(dialog) + sut.onDialogGone(dialog) + + // Once for the resume, once for the single re-arm - the second report is a no-op. + verify(fixture.activity, times(2)).getSystemService(eq(Context.SENSOR_SERVICE)) + } + + @Test + fun `a dialog that fails to show does not leave detection suppressed`() { + // Dialog.show() runs onStart() - which reports the dialog as visible and stops detection - + // before the window is added, so an addView() failure hits with the dialog already tracked + // and no lifecycle callback left to report it gone. + val sensorManager = mock() + val accelerometer = mock() + whenever(fixture.activity.getSystemService(Context.SENSOR_SERVICE)).thenReturn(sensorManager) + whenever(sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER, false)) + .thenReturn(accelerometer) + whenever(fixture.activity.runOnUiThread(any())).thenAnswer { + (it.arguments[0] as Runnable).run() + null + } + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + sut.onActivityResumed(fixture.activity) + + val dialog = mock() + doAnswer { + sut.onDialogVisible(fixture.activity, dialog) + throw WindowManager.BadTokenException("Unable to add window") + } + .whenever(dialog) + .show() + sut.setDialogFactory { dialog } + + val listener = argumentCaptor() + verify(sensorManager) + .registerListener( + listener.capture(), + eq(accelerometer), + eq(SensorManager.SENSOR_DELAY_NORMAL), + isA(), + ) + shake(listener.lastValue) + + verify(dialog).show() + assertThat(sut.dialogActivity).isNull() + verify(sensorManager, times(2)) + .registerListener( + any(), + eq(accelerometer), + eq(SensorManager.SENSOR_DELAY_NORMAL), + isA(), + ) + } + + private fun shake(listener: SensorEventListener) { + val baseTimestamp = 1_000_000_000L + val intervalNs = 20_000_000L + for (i in 0 until 20) { + listener.onSensorChanged( + createSensorEvent(floatArrayOf(20f, 0f, 0f), baseTimestamp + i * intervalNs) + ) + } + } + + private fun createSensorEvent(values: FloatArray, timestamp: Long): SensorEvent { + val sensor = mock() + whenever(sensor.type).thenReturn(Sensor.TYPE_ACCELEROMETER) + + val constructor = SensorEvent::class.java.getDeclaredConstructor(Int::class.javaPrimitiveType) + constructor.isAccessible = true + val event = constructor.newInstance(values.size) + values.copyInto(event.values) + SensorEvent::class.java.getField("sensor").set(event, sensor) + SensorEvent::class.java.getField("timestamp").set(event, timestamp) + return event + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 09c4fae8dc4..ce518eabb05 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -7,6 +7,7 @@ import io.sentry.IScope import io.sentry.IScopes import io.sentry.ReplayController import io.sentry.ScopeCallback +import io.sentry.SentryExecutorService import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.Session @@ -32,7 +33,8 @@ class LifecycleWatcherTest { private class Fixture { val scopes = mock() val dateProvider = mock() - val options = SentryOptions() + // a real executor so scheduled end-session tasks actually run + val options = SentryOptions().apply { setTimerExecutorService(SentryExecutorService(this)) } val replayController = mock() val continuousProfiler = mock() @@ -115,10 +117,10 @@ class LifecycleWatcherTest { watcher.onForeground() watcher.onBackground() - assertNotNull(watcher.timerTask) + assertNotNull(watcher.endSessionFuture) watcher.onForeground() - assertNull(watcher.timerTask) + assertNull(watcher.endSessionFuture) verify(fixture.scopes, never()).endSession() verify(fixture.replayController, never()).stop() @@ -186,13 +188,6 @@ class LifecycleWatcherTest { verify(fixture.scopes, never()).addBreadcrumb(any()) } - @Test - fun `timer is created if session tracking is enabled`() { - val watcher = - fixture.getSUT(enableAutoSessionTracking = true, enableAppLifecycleBreadcrumbs = false) - assertNotNull(watcher.timer) - } - @Test fun `if the scopes has already a fresh session running, don't start new one`() { val watcher = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index d0dbd1deb50..d67a869eff0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1649,6 +1649,31 @@ class ManifestMetadataReaderTest { assertFalse(fixture.options.isEnableAppStartProfiling) } + @Test + fun `applyMetadata reads enableLegacyProfiling flag to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ENABLE_LEGACY_PROFILING to false) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.isEnableLegacyProfiling) + } + + @Test + fun `applyMetadata reads enableLegacyProfiling flag to options and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnableLegacyProfiling) + } + @Test fun `applyMetadata reads enableScopePersistence flag to options`() { // Arrange diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt new file mode 100644 index 00000000000..2f76e73108f --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt @@ -0,0 +1,223 @@ +package io.sentry.android.core + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IConnectionStatusProvider +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ProfileLifecycle +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.TracesSampler +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector +import io.sentry.test.DeferredExecutorService +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class PerfettoContinuousProfilerTest { + private lateinit var context: Context + private val fixture = Fixture() + private lateinit var mocks: ProfilerMocks + + private class Fixture { + private val mockDsn = "http://key@localhost/proj" + val executor = DeferredExecutorService() + val mockedSentry = mockStatic(Sentry::class.java) + val mockLogger = mock() + val mockTracesSampler = mock() + val mockPerfettoProfiler = mock() + val frameMetricsCollector: SentryFrameMetricsCollector = mock() + + val scopes: IScopes = mock() + + val options = + spy(SentryAndroidOptions()).apply { + dsn = mockDsn + profilesSampleRate = 1.0 + isDebug = true + setLogger(mockLogger) + } + + val mockTraceFile = + java.io.File.createTempFile("test-trace", ".pftrace").apply { + writeBytes(byteArrayOf(0x50, 0x65, 0x72, 0x66)) + deleteOnExit() + } + + init { + whenever(mockTracesSampler.sampleSessionProfile(any())).thenReturn(true) + whenever(mockPerfettoProfiler.start(any())).thenReturn(true) + doAnswer { invocation -> + val listener = invocation.getArgument>(0) + listener.accept(mockTraceFile) + null + } + .whenever(mockPerfettoProfiler) + .endAndCollect(any()) + } + + fun getSut( + optionConfig: ((options: SentryAndroidOptions) -> Unit) = {} + ): PerfettoContinuousProfiler { + options.executorService = executor + optionConfig(options) + whenever(scopes.options).thenReturn(options) + return PerfettoContinuousProfiler( + mockLogger, + frameMetricsCollector, + { options.executorService }, + { mockPerfettoProfiler }, + ) + } + } + + @BeforeTest + fun `set up`() { + context = ApplicationProvider.getApplicationContext() + Sentry.setCurrentScopes(fixture.scopes) + fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + mocks = + ProfilerMocks(fixture.executor, fixture.mockTracesSampler, fixture.mockLogger, fixture.scopes) + } + + @AfterTest + fun clear() { + fixture.mockedSentry.close() + } + + // -- Shared tests (see ContinuousProfilerTestCases.kt) -- + + @Test + fun `isRunning reflects profiler status`() = fixture.getSut().testIsRunningReflectsStatus(mocks) + + @Test + fun `stopProfiler stops the profiler after chunk is finished`() = + fixture.getSut().testStopProfilerStopsAfterChunkFinished(mocks) + + @Test + fun `profiler multiple starts are accepted in trace mode`() = + fixture.getSut().testMultipleStartsAcceptedInTraceMode(mocks) + + @Test + fun `profiler logs a warning on start if not sampled`() = + fixture.getSut().testLogsWarningIfNotSampled(mocks) + + @Test + fun `profiler evaluates sessionSampleRate only the first time`() = + fixture.getSut().testEvaluatesSessionSampleRateOnlyOnce(mocks) + + @Test + fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() = + fixture.getSut().testReevaluateSamplingOnNextStart(mocks) + + @Test + fun `profiler ignores profilesSampleRate`() { + val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + } + + @Test + fun `profiler stops and restart for each chunk`() = + fixture.getSut().testStopsAndRestartsForEachChunk(mocks) + + @Test + fun `profiler sends chunk on each restart`() = fixture.getSut().testSendsChunkOnRestart(mocks) + + @Test fun `profiler sends another chunk on stop`() = fixture.getSut().testSendsChunkOnStop(mocks) + + @Test + fun `close without terminating stops all profiles after chunk is finished`() = + fixture.getSut().testCloseWithoutTerminatingStopsAfterChunk(mocks) + + @Test + fun `profiler does not send chunks after close`() = + fixture.getSut().testDoesNotSendChunksAfterClose(mocks) + + @Test fun `profiler stops when rate limited`() = fixture.getSut().testStopsWhenRateLimited(mocks) + + @Test + fun `profiler does not start when rate limited`() = + fixture.getSut().testDoesNotStartWhenRateLimited(mocks) + + @Test + fun `profiler does not start when offline`() = + fixture + .getSut { + it.connectionStatusProvider = mock { provider -> + whenever(provider.connectionStatus) + .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) + } + } + .testDoesNotStartWhenOffline(mocks) + + @Test + fun `manual profiler can be started again after a full start-stop cycle`() = + fixture.getSut().testCanBeStartedAgainAfterStopCycle(mocks) + + // -- Perfetto-specific tests -- + + @Test + fun `async chunk callback does not restart when stop requested while pending`() { + val profiler = fixture.getSut() + + // Defer the endAndCollect listener to simulate the OS delivering the trace asynchronously, + // after the chunk timer already captured the (then-true) restart decision. + var pendingListener: java.util.function.Consumer? = null + doAnswer { invocation -> + pendingListener = invocation.getArgument(0) + null + } + .whenever(fixture.mockPerfettoProfiler) + .endAndCollect(any()) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + + // Chunk timer fires: stopInternal(true) captures shouldRestart=true and calls endAndCollect, + // but the listener is held pending instead of firing inline. + fixture.executor.runAll() + assertFalse(profiler.isRunning) + assertNotNull(pendingListener) + + // A stop is requested while the async callback is still pending. + profiler.stopProfiler(ProfileLifecycle.MANUAL) + + // The OS now delivers the trace. The callback must honor the late stop and not restart. + pendingListener!!.accept(fixture.mockTraceFile) + fixture.executor.runAll() + assertFalse( + profiler.isRunning, + "profiler must not restart when a stop was requested while the callback was pending", + ) + } + + @Test + fun `profiler multiple starts are ignored in manual mode`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + verify(fixture.mockLogger) + .log( + eq(SentryLevel.WARNING), + eq("Unexpected call to startProfiler(MANUAL) while profiler already running. Skipping."), + ) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt new file mode 100644 index 00000000000..0746d36dfff --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt @@ -0,0 +1,268 @@ +package io.sentry.android.core + +import android.content.Context +import android.os.ProfilingManager +import android.os.ProfilingResult +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ILogger +import io.sentry.test.DeferredExecutorService +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import java.util.function.Consumer +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class PerfettoProfilerTest { + + private lateinit var context: Context + private val mockLogger = mock() + private val executor = DeferredExecutorService() + + private lateinit var capturedCallback: Consumer + + private val mockProfilingManager = + mock().also { manager -> + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + capturedCallback = invocation.getArgument(5) as Consumer + null + } + .whenever(manager) + .requestProfiling(any(), any(), any(), any(), any(), any()) + } + + @BeforeTest + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + private fun getSut(profilingManager: ProfilingManager? = mockProfilingManager): PerfettoProfiler { + return PerfettoProfiler(mockLogger, executor, profilingManager) + } + + private fun createTraceFile(): File { + return File.createTempFile("test-trace", ".pftrace").apply { + writeBytes(byteArrayOf(0x50, 0x65, 0x72, 0x66)) + deleteOnExit() + } + } + + private fun mockResult( + errorCode: Int = ProfilingResult.ERROR_NONE, + filePath: String? = null, + errorMessage: String? = null, + ): ProfilingResult { + return mock().also { + whenever(it.errorCode).thenReturn(errorCode) + whenever(it.resultFilePath).thenReturn(filePath) + whenever(it.errorMessage).thenReturn(errorMessage) + } + } + + @Test + fun `start returns true on first call`() { + val profiler = getSut() + assertTrue(profiler.start(60000)) + } + + @Test + fun `start returns false when already started`() { + val profiler = getSut() + assertTrue(profiler.start(60000)) + assertFalse(profiler.start(60000)) + } + + @Test + fun `start returns false when ProfilingManager is null`() { + val profiler = getSut(profilingManager = null) + assertFalse(profiler.start(60000)) + } + + @Test + fun `endAndCollect calls listener with null when never started`() { + val profiler = getSut() + val result = AtomicReference(File("sentinel")) + profiler.endAndCollect { result.set(it) } + assertNull(result.get()) + } + + @Test + fun `endAndCollect calls listener synchronously when result already available`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + val result = AtomicReference() + profiler.endAndCollect { result.set(it) } + + assertEquals(traceFile.absolutePath, result.get()?.absolutePath) + } + + @Test + fun `endAndCollect calls listener when result arrives later`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference() + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + assertEquals(traceFile.absolutePath, result.get()?.absolutePath) + } + + @Test + fun `endAndCollect calls listener with null on error result`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept( + mockResult(errorCode = ProfilingResult.ERROR_UNKNOWN, errorMessage = "unknown error") + ) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } + + @Test + fun `endAndCollect calls listener with null on rate limit error`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept(mockResult(errorCode = ProfilingResult.ERROR_FAILED_RATE_LIMIT_PROCESS)) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } + + @Test + fun `timeout fires listener with null when OS never responds`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + profiler.endAndCollect { result.set(it) } + + assertEquals("sentinel", result.get()?.name) + + executor.runAll() + + assertNull(result.get()) + } + + @Test + fun `timeout is no-op when result already arrived`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val callCount = AtomicInteger(0) + val result = AtomicReference() + profiler.endAndCollect { + callCount.incrementAndGet() + result.set(it) + } + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + assertEquals(1, callCount.get()) + assertEquals(traceFile.absolutePath, result.get()?.absolutePath) + + executor.runAll() + + assertEquals(1, callCount.get()) + } + + @Test + fun `listener is called exactly once when result and endAndCollect race`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val callCount = AtomicInteger(0) + val latch = CountDownLatch(1) + + val resultThread = Thread { + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + latch.countDown() + } + + profiler.endAndCollect { callCount.incrementAndGet() } + resultThread.start() + + assertTrue(latch.await(5, TimeUnit.SECONDS)) + + executor.runAll() + + assertEquals(1, callCount.get()) + } + + @Test + fun `trace file is deleted when result arrives after the timeout`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val callCount = AtomicInteger(0) + profiler.endAndCollect { callCount.incrementAndGet() } + + executor.runAll() + assertEquals(1, callCount.get()) + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + assertEquals(1, callCount.get()) + assertFalse(traceFile.exists()) + } + + @Test + fun `endAndCollect calls listener with null when result file path is null`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept(mockResult(filePath = null)) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } + + @Test + fun `endAndCollect calls listener with null when trace file does not exist`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept(mockResult(filePath = "/non/existent/path.pftrace")) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 8524a1cc807..2bd26051c07 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -440,7 +440,9 @@ class SentryAndroidTest { // clean state for a new process. assertEquals( emptyList(), - options.findPersistingScopeObserver()?.read(options, BREADCRUMBS_FILENAME, List::class.java), + options + .findPersistingScopeObserver() + ?.read(options, BREADCRUMBS_FILENAME, List::class.java), ) assertEquals( SentryId.EMPTY_ID.toString(), @@ -463,7 +465,9 @@ class SentryAndroidTest { // assert that persisted values have changed assertEquals( "TestActivity", - options.findPersistingScopeObserver()?.read(options, TRANSACTION_FILENAME, String::class.java), + options + .findPersistingScopeObserver() + ?.read(options, TRANSACTION_FILENAME, String::class.java), ) assertEquals( "io.sentry.sample@1.1.0+220", diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt index 1a84a1282da..0c0c03d71d2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt @@ -31,11 +31,10 @@ class SentryLogcatAdapterTest { Bundle().apply { putString(ManifestMetadataReader.DSN, "https://key@sentry.io/123") } val mockContext = ContextUtilsTestHelper.mockMetaData(metaData = metadata) initForTest(mockContext) { - it.beforeBreadcrumb = - SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> - breadcrumbs.add(breadcrumb) - breadcrumb - } + it.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + breadcrumbs.add(breadcrumb) + breadcrumb + } it.logs.isEnabled = true it.logs.beforeSend = SentryOptions.Logs.BeforeSendLogCallback { logEvent -> diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt index 58dc56d1493..bff6cdfad37 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt @@ -153,6 +153,21 @@ class SentryPerformanceProviderTest { ) } + @Test + fun `when config file is malformed, profiler is not started`() { + fixture.getSut { config -> config.writeText("{\"profile_sampled\": tru") } + assertNull(AppStartMetrics.getInstance().appStartProfiler) + assertNull(AppStartMetrics.getInstance().appStartContinuousProfiler) + verify(fixture.logger).log(eq(SentryLevel.ERROR), eq("Error when deserializing"), any()) + verify(fixture.logger) + .log( + eq(SentryLevel.WARNING), + eq( + "Unable to deserialize the SentryAppStartProfilingOptions. App start profiling will not start." + ), + ) + } + @Test fun `when profiling is disabled, profiler is not started`() { fixture.getSut { config -> @@ -257,8 +272,9 @@ class SentryPerformanceProviderTest { @Test fun `when provider is closed, profiler is stopped`() { - val provider = - fixture.getSut { config -> writeConfig(config, continuousProfilingEnabled = false) } + val provider = fixture.getSut { config -> + writeConfig(config, continuousProfilingEnabled = false) + } provider.shutdown() assertNotNull(AppStartMetrics.getInstance().appStartProfiler) assertFalse(AppStartMetrics.getInstance().appStartProfiler!!.isRunning) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt index 9df2a16d72e..852b4e7e8f0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt @@ -1,6 +1,9 @@ package io.sentry.android.core +import android.app.Activity +import android.app.Application import android.content.Context +import android.os.Looper import android.view.WindowManager import android.widget.TextView import androidx.test.core.app.ApplicationProvider @@ -19,13 +22,18 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf @RunWith(AndroidJUnit4::class) class SentryUserFeedbackFormTest { @@ -143,4 +151,66 @@ class SentryUserFeedbackFormTest { val flags = window.attributes.flags assertEquals(0, flags and WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) } + + @Test + fun `a crashing onFormClose callback does not crash the app when the dialog is closed`() { + fixture.options.isEnabled = true + fixture.options.feedbackOptions.onFormClose = Runnable { throw RuntimeException("user bug") } + val sut = fixture.getSut() + sut.show() + + sut.dismiss() + // The dismiss listener is dispatched via a Handler message + shadowOf(Looper.getMainLooper()).idle() + + verify(fixture.mockLogger) + .log(eq(SentryLevel.ERROR), eq("onFormClose callback threw an exception."), any()) + } + + @Test + fun `a crashing onFormClose callback still runs the user's dismiss listener`() { + fixture.options.isEnabled = true + fixture.options.feedbackOptions.onFormClose = Runnable { throw RuntimeException("user bug") } + val sut = fixture.getSut() + var dismissed = false + sut.setOnDismissListener { dismissed = true } + sut.show() + + sut.dismiss() + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(dismissed) + } + + @Test + fun `a crashing onFormOpen callback does not crash the app when the dialog is shown`() { + fixture.options.isEnabled = true + fixture.options.feedbackOptions.onFormOpen = Runnable { throw RuntimeException("user bug") } + val sut = fixture.getSut() + + sut.show() + + verify(fixture.mockLogger) + .log(eq(SentryLevel.ERROR), eq("onFormOpen callback threw an exception."), any()) + // The form open must still complete its own work after the callback crash + verify(fixture.mockReplayController).captureReplay(eq(false)) + } + + @Test + fun `dialog reports its own host activity to the shake integration while visible`() { + fixture.options.isEnabled = true + val integration = FeedbackShakeIntegration(fixture.application as Application) + fixture.options.feedbackOptions.setShakeController(integration) + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + + val sut = SentryUserFeedbackForm(activity, 0, null, null, null) + sut.show() + + assertEquals(activity, integration.dialogActivity) + + sut.dismiss() + shadowOf(Looper.getMainLooper()).idle() + + assertNull(integration.dialogActivity) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt index f48f1674166..9dcde8eb4a6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt @@ -19,7 +19,8 @@ class AnrStackTraceConverterTest { val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) Assert.assertNotNull(profile) - Assert.assertEquals(1, profile.samples.size) + // Two samples are present b/c the converter adds a synthetic one to keep Relay happy. + Assert.assertEquals(2, profile.samples.size) Assert.assertEquals(2, profile.frames.size) Assert.assertEquals(1, profile.stacks.size) @@ -46,6 +47,57 @@ class AnrStackTraceConverterTest { Assert.assertEquals(1.0, sample.timestamp, 0.001) // 1000ms = 1s } + @Test + fun testAddSyntheticSampleIfOnlyOneSamplePresent() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + val originalSample = profile.samples[0] + val syntheticSample = profile.samples[1] + val expectedOffsetSeconds = AnrProfilingIntegration.POLLING_INTERVAL_MS / 2.0 / 1000.0 + + Assert.assertEquals( + originalSample.timestamp + expectedOffsetSeconds, + syntheticSample.timestamp, + 0.001, + ) + + Assert.assertTrue(profile.stacks[syntheticSample.stackId].isNotEmpty()) + Assert.assertEquals(2, profile.samples.size) + Assert.assertEquals(1, profile.stacks.size) + Assert.assertEquals(1, profile.frames.size) + } + + @Test + fun testDoNotAddSyntheticSampleIfMultipleSamplesPresent() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements)) + anrStackTraces.add(AnrStackTrace(2000, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + Assert.assertEquals(2, profile.samples.size) + Assert.assertEquals(1.0, profile.samples[0].timestamp, 0.001) + Assert.assertEquals(2.0, profile.samples[1].timestamp, 0.001) + Assert.assertEquals(1, profile.stacks.size) + Assert.assertEquals(1, profile.frames.size) + } + + @Test + fun testDoNotAddSyntheticSampleIfNoSamplesPresent() { + val profile = StackTraceConverter.convert(AnrProfile(ArrayList())) + + Assert.assertEquals(0, profile.samples.size) + Assert.assertEquals(0, profile.stacks.size) + Assert.assertEquals(0, profile.frames.size) + } + @Test fun testFrameDeduplication() { // Create two stack traces with duplicate frames diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt index 09d3a779df0..a4063ccb148 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt @@ -125,6 +125,20 @@ class AndroidEnvelopeCacheTest { assertTrue(fixture.startupCrashMarkerFile.exists()) } + @Test + fun `creates outbox dir when writing startup crash file and dir does not exist yet`() { + val cache = fixture.getSut(dir = tmpDir, appStartMillis = 1000L, currentTimeMillis = 2000L) + + val outboxDir = File(fixture.options.outboxPath!!) + assertTrue(outboxDir.deleteRecursively()) + assertFalse(outboxDir.exists()) + + val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) + cache.storeEnvelope(fixture.envelope, hints) + + assertTrue(fixture.startupCrashMarkerFile.exists()) + } + @Test fun `when no AnrV2 hint exists, does not write last anr report file`() { val cache = fixture.getSut(tmpDir) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt index ec5dbd58902..c5798be2111 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt @@ -52,8 +52,9 @@ class ThreadDumpParserTest { assertEquals(SentryLockReason.SLEEPING, blockingThread.heldLocks!!["0x09228c2d"]!!.type) assertEquals(null, blockingThread.heldLocks!!["0x09228c2d"]!!.threadId) - val randomThread = - threads.find { it.name == "io.sentry.android.core.internal.util.SentryFrameMetricsCollector" } + val randomThread = threads.find { + it.name == "io.sentry.android.core.internal.util.SentryFrameMetricsCollector" + } assertEquals(19, randomThread!!.id) assertEquals("Native", randomThread.state) assertEquals(false, randomThread.isCrashed) @@ -155,8 +156,9 @@ class ThreadDumpParserTest { assertNull(deletedFrame.addrMode) val debugImages = parser.debugImages - val image = - debugImages.first { image -> image.debugId == "499d48ba-c085-17cf-3209-da67405662f9" } + val image = debugImages.first { image -> + image.debugId == "499d48ba-c085-17cf-3209-da67405662f9" + } assertNotNull(image) assertEquals("499d48ba-c085-17cf-3209-da67405662f9", image.debugId) assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", image.codeFile) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt index a6611e17e9a..c3993b94efd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt @@ -14,14 +14,13 @@ class CpuInfoUtilsTest { private lateinit var cpuDirs: File private lateinit var ciu: CpuInfoUtils - private fun populateCpuFiles(values: List) = - values.mapIndexed { i, v -> - val cpuMaxFreqFile = - File(cpuDirs, "cpu$i${File.separator}${CpuInfoUtils.CPUINFO_MAX_FREQ_PATH}") - cpuMaxFreqFile.parentFile?.mkdirs() - cpuMaxFreqFile.writeText(v) - cpuMaxFreqFile - } + private fun populateCpuFiles(values: List) = values.mapIndexed { i, v -> + val cpuMaxFreqFile = + File(cpuDirs, "cpu$i${File.separator}${CpuInfoUtils.CPUINFO_MAX_FREQ_PATH}") + cpuMaxFreqFile.parentFile?.mkdirs() + cpuMaxFreqFile.writeText(v) + cpuMaxFreqFile + } @BeforeTest fun `set up`() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt index f90c07b70e6..334ce229066 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt @@ -19,7 +19,6 @@ import io.sentry.test.getCtor import io.sentry.test.getProperty import io.sentry.test.injectForField import java.lang.ref.WeakReference -import java.lang.reflect.Field import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test @@ -302,18 +301,41 @@ class SentryFrameMetricsCollectorTest { } @Test - fun `collector accesses choreographer instance on creation on main thread`() { + fun `collector accesses choreographer instance and field asynchronously on main thread`() { val collector = fixture.getSut(context) - val field: Field? = collector.getProperty("choreographerLastFrameTimeField") + + val field: Any? = collector.getProperty("choreographerLastFrameTimeField") var choreographer: Choreographer? = collector.getProperty("choreographer") - // Choreographer instance is accessed on main thread, but the field accessor happens in whatever - // thread created the collector - assertNotNull(field) + assertNull(choreographer) + assertNull(field) + // Execute all posted tasks Shadows.shadowOf(Looper.getMainLooper()).idle() choreographer = collector.getProperty("choreographer") assertNotNull(choreographer) + assertNotNull(collector.getProperty("choreographerLastFrameTimeField")) + } + + // Frame callbacks on API 26+ read their per-frame start timestamp directly from FrameMetrics, + // which can make the Choreographer fallback look like it should be specific to APIs < 26. + // But SpanFrameMetricsCollector separately calls getLastKnownFrameStartTimeNanos() on every + // API level for pending-frame interpolation, so API 26+ still needs the Choreographer + // fallback to be initialized. + @Test + fun `collector keeps choreographer fallback available on version O+`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + + Shadows.shadowOf(Looper.getMainLooper()).idle() + + val choreographer = collector.getProperty("choreographer") + assertNotNull(collector.getProperty("choreographerLastFrameTimeField")) + + choreographer.injectForField("mLastFrameTimeNanos", 100) + + assertEquals(100, collector.getLastKnownFrameStartTimeNanos()) } @Test @@ -621,10 +643,6 @@ class SentryFrameMetricsCollectorTest { // emit a fast frame (21ns cpu time — well under 16ms budget) listener.onFrameMetricsAvailable(createMockWindow(), createMockFrameMetrics(), 0) - // choreographer is at end of range so no pending delay - val choreographer = collector.getProperty("choreographer") - choreographer.injectForField("mLastFrameTimeNanos", TimeUnit.SECONDS.toNanos(1)) - val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(1)) assertEquals(0.0, result.delaySeconds) assertEquals(0, result.framesContributingToDelayCount) @@ -643,22 +661,23 @@ class SentryFrameMetricsCollectorTest { // emit a slow frame (~100ms extra = ~116ms total, well over 16ms budget) listener.onFrameMetricsAvailable( createMockWindow(), - createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100)), + createMockFrameMetrics( + extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100), + intendedVsyncTimestampNanos = TimeUnit.SECONDS.toNanos(1), + ), 0, ) // emit a frozen frame (~1000ms extra = ~1016ms total, well over 700ms) listener.onFrameMetricsAvailable( createMockWindow(), - createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(1000)), + createMockFrameMetrics( + extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(1000), + intendedVsyncTimestampNanos = TimeUnit.SECONDS.toNanos(2), + ), 0, ) - // choreographer is at end of range so no pending delay - Shadows.shadowOf(Looper.getMainLooper()).idle() - val choreographer = collector.getProperty("choreographer") - choreographer.injectForField("mLastFrameTimeNanos", TimeUnit.SECONDS.toNanos(5)) - val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(5)) assertTrue(result.delaySeconds > 0) assertEquals(2, result.framesContributingToDelayCount) @@ -681,11 +700,6 @@ class SentryFrameMetricsCollectorTest { 0, ) - // choreographer is at end of range - Shadows.shadowOf(Looper.getMainLooper()).idle() - val choreographer = collector.getProperty("choreographer") - choreographer.injectForField("mLastFrameTimeNanos", TimeUnit.SECONDS.toNanos(5)) - // The frame's delay interval is roughly [~16ms, ~1000ms]. // Query from 500ms so the range clips the delay interval in half. val queryStart = TimeUnit.MILLISECONDS.toNanos(500) @@ -708,7 +722,6 @@ class SentryFrameMetricsCollectorTest { Shadows.shadowOf(Looper.getMainLooper()).idle() val listener = collector.getProperty("frameMetricsAvailableListener") - val choreographer = collector.getProperty("choreographer") collector.startCollection(mock()) @@ -720,8 +733,6 @@ class SentryFrameMetricsCollectorTest { whenever(frameMetrics1.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)).thenReturn(t0) listener.onFrameMetricsAvailable(createMockWindow(), frameMetrics1, 0) - choreographer.injectForField("mLastFrameTimeNanos", t0 + TimeUnit.SECONDS.toNanos(1)) - // verify frame exists val resultBefore = collector.getFramesDelay(t0, t0 + TimeUnit.SECONDS.toNanos(1)) assertEquals(1, resultBefore.framesContributingToDelayCount) @@ -734,7 +745,6 @@ class SentryFrameMetricsCollectorTest { listener.onFrameMetricsAvailable(createMockWindow(), frameMetrics2, 0) // the first frame should have been pruned (>5min old) - choreographer.injectForField("mLastFrameTimeNanos", t1 + TimeUnit.SECONDS.toNanos(1)) val resultAfter = collector.getFramesDelay(t0, t0 + TimeUnit.SECONDS.toNanos(1)) assertEquals(0, resultAfter.framesContributingToDelayCount) } @@ -762,6 +772,7 @@ class SentryFrameMetricsCollectorTest { syncNanos: Long = 6, extraCpuDurationNanos: Long = 0, totalDurationNanos: Long = 60, + intendedVsyncTimestampNanos: Long = 50, ): FrameMetrics { val frameMetrics = mock() whenever(frameMetrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)) @@ -774,7 +785,8 @@ class SentryFrameMetricsCollectorTest { whenever(frameMetrics.getMetric(FrameMetrics.DRAW_DURATION)).thenReturn(drawNanos) whenever(frameMetrics.getMetric(FrameMetrics.SYNC_DURATION)).thenReturn(syncNanos) whenever(frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION)).thenReturn(totalDurationNanos) - whenever(frameMetrics.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)).thenReturn(50) + whenever(frameMetrics.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)) + .thenReturn(intendedVsyncTimestampNanos) return frameMetrics } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index edf72655740..6a32e7d453b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -241,15 +241,16 @@ class AppStartMetricsTest { } @Test - fun `headless app start fires HeadlessAppStartListener`() = headlessProcess { - val listenerCalls = AtomicInteger() + fun `headless app start fires HeadlessAppStartListener`() = + withProcessImportance(false) { + val listenerCalls = AtomicInteger() - AppStartMetrics.getInstance().setHeadlessAppStartListener { listenerCalls.incrementAndGet() } - AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) - waitForMainLooperIdle() + AppStartMetrics.getInstance().setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() - assertEquals(1, listenerCalls.get()) - } + assertEquals(1, listenerCalls.get()) + } @Test fun `foreground process does not fire HeadlessAppStartListener`() { @@ -295,7 +296,7 @@ class AppStartMetricsTest { @Test fun `resolveHeadlessAppStartEndTime uses applicationOnCreate stop when Gradle plugin instrumented`() = - headlessProcess { + withProcessImportance(false) { val metrics = AppStartMetrics.getInstance() metrics.appStartTimeSpan.setStartedAt(100) metrics.setHeadlessAppStartListener {} @@ -312,7 +313,7 @@ class AppStartMetricsTest { @Test fun `resolveHeadlessAppStartEndTime falls back to CLASS_LOADED_UPTIME_MS when no plugin and no ApplicationStartInfo`() = - headlessProcess { + withProcessImportance(false) { val metrics = AppStartMetrics.getInstance() metrics.setClassLoadedUptimeMs(200) metrics.appStartTimeSpan.setStartedAt(100) @@ -371,12 +372,18 @@ class AppStartMetricsTest { Shadows.shadowOf(Looper.getMainLooper()).idle() } - // Simulates a real headless start (broadcast/service), i.e. a non-foreground-importance process. - // The Robolectric default importance in this test class is IMPORTANCE_FOREGROUND, so headless - // scenarios must opt into a background importance explicitly. - private fun headlessProcess(block: () -> T): T = + /** + * Mocks the process importance to simulate a user initiated start (e.g. launcher) or a real + * headless start (broadcast/service), i.e. a non-foreground-importance process. + * + * The Robolectric default importance in this test class is IMPORTANCE_FOREGROUND, so any headless + * scenarios must opt into a background importance explicitly. + */ + private fun withProcessImportance(isForeground: Boolean, block: () -> T): T = mockStatic(ContextUtils::class.java).use { contextUtils -> - contextUtils.`when` { ContextUtils.isForegroundImportance() }.thenReturn(false) + contextUtils + .`when` { ContextUtils.isForegroundImportance() } + .thenReturn(isForeground) block() } @@ -1123,4 +1130,20 @@ class AppStartMetricsTest { assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) metrics.appStartExtension.setExtendAppStartListener(null) } + + @Test + fun `broadcast starts are not considered a foreground start`() = + withProcessImportance(false) { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `typical app starts are considered a foreground start`() = + withProcessImportance(true) { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + assertTrue(metrics.isAppLaunchedInForeground) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt index 0624e70b898..a7eace97371 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -1,11 +1,13 @@ package io.sentry.android.core.performance +import android.app.Activity import android.app.ActivityManager.RunningAppProcessInfo import android.app.Application import android.app.ApplicationStartInfo import android.os.Build import android.os.Handler import android.os.Looper +import android.os.SystemClock import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.SentryShadowActivityManager @@ -16,6 +18,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock @@ -263,6 +266,115 @@ class AppStartMetricsTestApi35 { assertNull(metrics.appStartReason) } + @Test + fun `background start reason marks app as not launched in foreground`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_PUSH) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `all background start reasons mark app as not launched in foreground`() { + val backgroundReasons = + listOf( + ApplicationStartInfo.START_REASON_ALARM, + ApplicationStartInfo.START_REASON_BACKUP, + ApplicationStartInfo.START_REASON_BOOT_COMPLETE, + ApplicationStartInfo.START_REASON_BROADCAST, + ApplicationStartInfo.START_REASON_CONTENT_PROVIDER, + ApplicationStartInfo.START_REASON_JOB, + ApplicationStartInfo.START_REASON_PUSH, + ApplicationStartInfo.START_REASON_SERVICE, + ) + + val app = ApplicationProvider.getApplicationContext() + for (reason in backgroundReasons) { + AppStartMetrics.getInstance().clear() + SentryShadowActivityManager.reset() + + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(reason) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + + AppStartMetrics.getInstance().registerLifecycleCallbacks(app) + + assertFalse( + AppStartMetrics.getInstance().isAppLaunchedInForeground, + "reason $reason should not be launched in foreground", + ) + } + } + + @Test + fun `user-initiated start reason keeps app launched in foreground`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertTrue(metrics.isAppLaunchedInForeground) + } + + @Test + fun `unknown start reason falls back to foreground importance check`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_OTHER) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertTrue(metrics.isAppLaunchedInForeground) + } + + @Test + fun `background-spawned start is re-classified as warm on the first activity`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_PUSH) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + // App start span anchored at background process creation. + metrics.appStartTimeSpan.setStartedAt(42) + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + // User opens the app 20s later (under the 1-minute warm threshold). + val activityCreatedUptimeMs = 20_000L + SystemClock.setCurrentTimeMillis(activityCreatedUptimeMs) + metrics.onActivityCreated(mock(), null) + + // Re-classified as a warm start re-anchored at activity creation. + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertTrue(metrics.isAppLaunchedInForeground) + assertEquals(activityCreatedUptimeMs, metrics.appStartTimeSpan.startUptimeMs) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle() diff --git a/sentry-android-distribution/build.gradle.kts b/sentry-android-distribution/build.gradle.kts index 2d23bf3ab74..c699c364c3b 100644 --- a/sentry-android-distribution/build.gradle.kts +++ b/sentry-android-distribution/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { @@ -12,6 +13,10 @@ android { defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } buildFeatures { buildConfig = false } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + testOptions { unitTests.apply { isReturnDefaultValues = true @@ -21,7 +26,7 @@ android { } kotlin { - jvmToolchain(17) + compilerOptions.jvmTarget = JVM_1_8 compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 explicitApi() } diff --git a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt index 3f1d083919c..f1817a39f34 100644 --- a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt +++ b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt @@ -8,8 +8,10 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) class UpdateResponseParserTest { private lateinit var options: SentryOptions diff --git a/sentry-android-fragment/build.gradle.kts b/sentry-android-fragment/build.gradle.kts index 1bd182d618c..3ef1c1934f8 100644 --- a/sentry-android-fragment/build.gradle.kts +++ b/sentry-android-fragment/build.gradle.kts @@ -1,4 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") @@ -23,10 +25,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts index 459c1653fa9..c3ca2379a76 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -64,13 +65,11 @@ android { } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + kotlin { compilerOptions.jvmTarget = JvmTarget.JVM_11 } lint { warningsAsErrors = true checkDependencies = true - // Suppress OldTargetApi: lint 8.13.1 expects API 37 but we target 36 - disable += "OldTargetApi" // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. checkReleaseBuilds = false diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts index 4b0cd68ca90..6f875e7a5e9 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts @@ -1,4 +1,5 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -31,7 +32,7 @@ android { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + kotlin { compilerOptions.jvmTarget = JvmTarget.JVM_11 } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } androidComponents.beforeVariants { diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt index 46bfe7e44b7..7e0ff9d61c3 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt @@ -66,6 +66,9 @@ class MainActivity : ComponentActivity() { Button(onClick = { Sentry.close() }) { Text("Close SDK") } Button( onClick = { + // The SDK creates the outbox dir lazily on its executor, so an external + // writer has to create it itself. + File(outboxPath).mkdirs() val file = File(outboxPath, "corrupted.envelope") val corruptedEnvelopeContent = """ diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/README.md b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/README.md index eae36ac178f..4b89b7b6105 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/README.md +++ b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/README.md @@ -5,14 +5,17 @@ performance changes on a real device in a **stable, reproducible** way. Not run ## What it measures -`SentryStartupBenchmark` runs a cold start and reports **`timeToInitialDisplay`** -(`StartupTimingMetric`) per iteration — the whole app cold start, taken from framework trace -events. No trace markers are required in the SDK or the app. - -The flip side of marker-free measurement: an SDK change has to be large enough (roughly tens of -milliseconds) to show above cold-start noise. Sub-millisecond changes are not resolvable with -`timeToInitialDisplay` alone; for those, capture a perfetto trace and inspect the relevant slices -directly (each iteration's trace is saved under +`SentryStartupBenchmark` runs a cold start and reports two metrics per iteration: + +- **`timeToInitialDisplay`** (`StartupTimingMetric`) — the whole app cold start, taken from + framework trace events. Because it captures the entire start, an SDK change has to be large enough + (roughly tens of milliseconds) to show above cold-start noise. +- **`SentryAndroid.init`** (`TraceSectionMetric`) — the duration of the `SentryAndroid.init` + `android.os.Trace` section the SDK emits, which isolates SDK-init cost from the rest of the start + and resolves changes that `timeToInitialDisplay` would lose in the noise. + +For even finer detail (sub-millisecond changes, or cost inside init), capture a perfetto trace and +inspect the relevant slices directly (each iteration's trace is saved under `build/outputs/connected_android_test_additional_output/`). `CompilationMode.Full()` pins ART AOT so dexopt state can't drift between runs. `StartupMode.COLD` @@ -49,5 +52,6 @@ Results print to the console and are written to Macrobenchmark measures one build per run, so compare separate runs — but **interleave them**: running all of variant A followed by all of variant B lets thermal drift systematically penalize whichever variant runs second. Instead, alternate A/B rounds (build variant A, run, build variant -B, run, repeat 2–3 times), keep each round's `*-benchmarkData.json`, and compare the -`timeToInitialDisplay` values pooled per variant. +B, run, repeat 2–3 times), keep each round's `*-benchmarkData.json`, and compare the values pooled +per variant. Prefer the `SentryAndroid.init` metric for SDK-init changes — it isolates init cost, so +it moves on changes that `timeToInitialDisplay` would bury in cold-start noise. diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts index 2d2aab48a1b..a00d76d6029 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + plugins { id("com.android.test") alias(libs.plugins.kotlin.android) @@ -30,7 +32,7 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 } + kotlin { compilerOptions.jvmTarget = JVM_11 } targetProjectPath = ":sentry-samples:sentry-samples-android" // Run the test in its own process so it measures the target app cold, not itself. diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt index 24b8707ba5d..ee49fe8beff 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt +++ b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt @@ -1,8 +1,10 @@ package io.sentry.uitest.android.macrobenchmark import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.ExperimentalMetricApi import androidx.benchmark.macro.StartupMode import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.TraceSectionMetric import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Rule @@ -13,10 +15,12 @@ import org.junit.runner.RunWith * Cold-start benchmark for the sentry-samples-android app, used to evaluate SDK-init changes on a * real device in a stable, repeatable way. * - * Reports timeToInitialDisplay ([StartupTimingMetric]) per iteration. This measures the whole app - * cold start from framework trace events, with no trace markers in the SDK or the app — which also - * means SDK changes need to be large enough (roughly tens of milliseconds) to show above cold-start - * noise. + * Reports two metrics per iteration: + * - timeToInitialDisplay ([StartupTimingMetric]) — the whole app cold start from framework trace + * events. Because it captures the entire start, an SDK change has to be large enough (roughly + * tens of milliseconds) to show above cold-start noise. + * - SentryAndroid.init ([TraceSectionMetric]) — the duration of the `SentryAndroid.init` + * [android.os.Trace] section the SDK emits, isolating SDK-init cost from the rest of the start. * * [CompilationMode.Full] pins ART AOT compilation so dexopt state does not drift between runs. * Iterations are capped at 12: on an unthrottled Pixel 3, back-to-back cold starts hit thermal @@ -24,6 +28,7 @@ import org.junit.runner.RunWith * it requires a connected device. To A/B an SDK change, see README.md (build the app twice, once * per SDK variant, in interleaved rounds). */ +@OptIn(ExperimentalMetricApi::class) @RunWith(AndroidJUnit4::class) class SentryStartupBenchmark { @@ -33,7 +38,7 @@ class SentryStartupBenchmark { fun startupFullCompilation() = benchmarkRule.measureRepeated( packageName = TARGET_PACKAGE, - metrics = listOf(StartupTimingMetric()), + metrics = listOf(StartupTimingMetric(), TraceSectionMetric(INIT_TRACE_SECTION)), compilationMode = CompilationMode.Full(), startupMode = StartupMode.COLD, iterations = 12, @@ -44,5 +49,8 @@ class SentryStartupBenchmark { private companion object { const val TARGET_PACKAGE = "io.sentry.samples.android" + + // Matches the android.os.Trace section name in SentryAndroid.init. + const val INIT_TRACE_SECTION = "SentryAndroid.init" } } diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 1d725b0b595..52c17199e4d 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -56,19 +57,18 @@ android { buildTypes { getByName("release") { isMinifyEnabled = true + isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName("debug") // to be able to run release mode testProguardFiles("proguard-rules.pro") } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + kotlin { compilerOptions.jvmTarget = JvmTarget.JVM_11 } lint { warningsAsErrors = true checkDependencies = true - // Suppress OldTargetApi: lint 8.13.1 expects API 37 but we target 36 - disable += "OldTargetApi" // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. checkReleaseBuilds = false diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt index ade47363296..3fa2c904873 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt @@ -262,9 +262,14 @@ class EnvelopeTests : BaseUiTest() { optionsRef = options } + // The SDK creates the outbox dir lazily on its executor, so an external writer racing + // Sentry.init has to create it itself. + val outboxDir = File(optionsRef!!.outboxPath!!) + outboxDir.mkdirs() + // based on // https://github.com/getsentry/sentry-native/blob/20d5d5f75f1f48228f2f47e2bb99b17f9996ebbf/ndk/lib/src/androidTest/java/io/sentry/ndk/SentryNdkTest.java#L131 - File(optionsRef!!.outboxPath, "14779dbf-b2f0-4c00-f4e5-4a287abc4267") + File(outboxDir, "14779dbf-b2f0-4c00-f4e5-4a287abc4267") .writeText( """ {"dsn":"https://key@sentry.io/proj","event_id":"729ff878-5539-458d-f657-a1acf423a127","sent_at":"2025-04-02T10:02:04.732577Z"} diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt index 3827561e37c..9c53f0a022d 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt @@ -70,11 +70,10 @@ class ReplayTest : BaseUiTest() { initSentry { it.sessionReplay.sessionSampleRate = 1.0 - it.beforeSendReplay = - SentryOptions.BeforeSendReplayCallback { event, _ -> - sent.set(true) - event - } + it.beforeSendReplay = SentryOptions.BeforeSendReplayCallback { event, _ -> + sent.set(true) + event + } } // wait until first segment is being sent diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt index b76017aeb0b..2ea1905b761 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt @@ -89,11 +89,10 @@ class UserInteractionTests : BaseUiTest() { options.profilesSampleRate = 1.0 options.isEnableUserInteractionTracing = true options.isEnableUserInteractionBreadcrumbs = true - options.beforeBreadcrumb = - SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> - breadcrumbs.add(breadcrumb) - breadcrumb - } + options.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + breadcrumbs.add(breadcrumb) + breadcrumb + } } } } diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts index eaa204b3860..6c1aa62a57d 100644 --- a/sentry-android-navigation/build.gradle.kts +++ b/sentry-android-navigation/build.gradle.kts @@ -1,4 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") @@ -23,10 +25,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index c2d0a33d823..6867d964124 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -26,6 +26,10 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } testOptions { diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 12fe214176d..3efee26e37d 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -76,6 +76,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun onWindowSizeChanged (II)V public fun pause ()V public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V + public fun registerSegmentName (Ljava/lang/String;)V public fun registerTraceId (Lio/sentry/protocol/SentryId;)V public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 8d0f63797aa..6d03ba771b0 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -1,4 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask plugins { @@ -25,20 +27,21 @@ android { buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() - useLiveLiterals = false - } + composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } buildTypes { getByName("debug") { consumerProguardFiles("proguard-rules.pro") } getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { @@ -80,6 +83,7 @@ dependencies { testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.test.runner) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.androidx.compose.ui) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index b3b9edae055..92d4a0c4018 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -22,6 +22,7 @@ import java.io.File import java.io.StringReader import java.util.Date import java.util.LinkedList +import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean /** @@ -162,7 +163,16 @@ public class ReplayCache(private val options: SentryOptions, private val replayI bitRate = bitRate, ), ) - .also { it.start() } + .apply { + // the constructor already opened the MediaMuxer, so release it if start() fails, + // otherwise the encoder is never assigned and its resources leak (CloseGuard warning) + try { + start() + } catch (t: Throwable) { + release() + throw t + } + } } val step = 1000 / frameRate.toLong() @@ -271,11 +281,29 @@ public class ReplayCache(private val options: SentryOptions, private val replayI } override fun close() { - encoderLock.acquire().use { - encoder?.release() - encoder = null + // close() is called inline from the lifecycle path (ReplayIntegration.stop/close), which holds + // its own lock, so blocking here can freeze the main thread. If the encoder is wedged in a + // native MediaCodec call we'd never get the lock, so we give up instead: the already-dead codec + // is not released (leaking a native handle), which beats an ANR. + try { + val token = encoderLock.tryAcquire(ENCODER_RELEASE_TIMEOUT_MS, MILLISECONDS) + if (token == null) { + options.logger.log( + WARNING, + "Timed out waiting for the video encoder, skipping its release to not block the caller", + ) + } else { + token.use { + encoder?.release() + encoder = null + } + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } finally { + // has to happen on all paths, callers rely on it to stop persisting segment values + isClosed.set(true) } - isClosed.set(true) } // TODO: it's awful, choose a better serialization format @@ -305,6 +333,13 @@ public class ReplayCache(private val options: SentryOptions, private val replayI } internal companion object { + /** + * How long [close] waits for the video encoder to become available. Below Android's ~5s ANR + * budget, and above the encoder's own bail-out (see MAX_EOS_STALL_ITERATIONS), so an encoder + * that's merely slow is still awaited rather than abandoned. + */ + private const val ENCODER_RELEASE_TIMEOUT_MS = 2000L + internal const val ONGOING_SEGMENT = ".ongoing_segment" internal const val SEGMENT_KEY_HEIGHT = "config.height" @@ -317,6 +352,7 @@ public class ReplayCache(private val options: SentryOptions, private val replayI internal const val SEGMENT_KEY_REPLAY_SCREEN_AT_START = "replay.screen-at-start" internal const val SEGMENT_KEY_REPLAY_RECORDING = "replay.recording" internal const val SEGMENT_KEY_ID = "segment.id" + internal const val SEGMENT_KEY_FLUSHED = "replay.flushed" fun makeReplayCacheDir(options: SentryOptions, replayId: SentryId): File? = if (options.cacheDirPath.isNullOrEmpty()) { @@ -415,8 +451,11 @@ public class ReplayCache(private val options: SentryOptions, private val replayI } cache.frames.sortBy { it.timestamp } - // TODO: this should be removed when we start sending buffered segments on next launch - val normalizedSegmentId = if (replayType == SESSION) segmentId else 0 + val wasFlushed = lastSegment[SEGMENT_KEY_FLUSHED]?.toBooleanStrictOrNull() == true + // In buffer mode, if the buffer was never flushed (no error triggered captureReplay), + // no segments were ever sent, so we normalize to 0. After a flush + conversion to + // session mode, the persisted segmentId is the real sequence number. + val normalizedSegmentId = if (replayType == SESSION || wasFlushed) segmentId else 0 val normalizedTimestamp = if (replayType == SESSION) { segmentTimestamp diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 612517438f6..98333260c7d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Build +import android.os.Looper import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DataCategory.All @@ -130,7 +131,7 @@ public class ReplayIntegration( private var replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null private var mainLooperHandler: MainLooperHandler = MainLooperHandler() private var gestureRecorderProvider: (() -> GestureRecorder)? = null - private val lifecycleLock = AutoClosableReentrantLock() + internal val lifecycleLock = AutoClosableReentrantLock() private val lifecycle = ReplayLifecycle() override fun register(scopes: IScopes, options: SentryOptions) { @@ -261,6 +262,7 @@ public class ReplayIntegration( onSegmentSent = { newTimestamp -> captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1 captureStrategy?.segmentTimestamp = newTimestamp + captureStrategy?.isFlushed = true }, ) captureStrategy = captureStrategy?.convert() @@ -296,6 +298,13 @@ public class ReplayIntegration( captureStrategy?.registerTraceId(traceId) } + override fun registerSegmentName(segmentName: String) { + if (!isEnabled.get() || !isRecording()) { + return + } + captureStrategy?.registerSegmentName(segmentName) + } + private fun pauseInternal() { lifecycleLock.acquire().use { if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) { @@ -344,7 +353,7 @@ public class ReplayIntegration( } addFrame(bitmap, frameTimeStamp, screen) } - checkCanRecord() + postOnMainThread { checkCanRecord() } } override fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) { @@ -367,7 +376,7 @@ public class ReplayIntegration( } addFrame(screenshot, frameTimestamp, screen) } - checkCanRecord() + postOnMainThread { checkCanRecord() } } override fun close() { @@ -382,21 +391,23 @@ public class ReplayIntegration( recorder?.close() recorder = null rootViewsSpy.close() - if (lazyReplayExecutor.isInitialized()) { - if (options.threadChecker.isMainThread) { - replayExecutor.gracefulShutdown() - } else { - replayExecutor.shutdown() - } + lifecycle.currentState = CLOSED + } + // shutdown outside lock — awaiting termination while holding lifecycleLock deadlocks + // if any executor task tries to acquire the same lock + if (lazyReplayExecutor.isInitialized()) { + if (options.threadChecker.isMainThread) { + replayExecutor.gracefulShutdown() + } else { + replayExecutor.shutdown() } - if (lazyPersistingExecutor.isInitialized()) { - if (options.threadChecker.isMainThread) { - persistingExecutor.gracefulShutdown() - } else { - persistingExecutor.shutdown() - } + } + if (lazyPersistingExecutor.isInitialized()) { + if (options.threadChecker.isMainThread) { + persistingExecutor.gracefulShutdown() + } else { + persistingExecutor.shutdown() } - lifecycle.currentState = CLOSED } } @@ -436,6 +447,17 @@ public class ReplayIntegration( captureStrategy?.onTouchEvent(event) } + // Runs [block] on the main thread. If already there, executes inline; otherwise posts via + // the main looper handler. Prevents deadlocks when lifecycle-lock-acquiring code (e.g. + // checkCanRecord -> pauseInternal) is called from the replay executor thread. + private inline fun postOnMainThread(crossinline block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainLooperHandler.post { block() } + } + } + /** * Check if we're offline or rate-limited and pause for session mode to not overflow the envelope * cache. diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 9b9d8f0157b..e81c815dc11 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -83,14 +83,13 @@ internal object WindowSpy { } } - fun pullWindow(maybeDecorView: View): Window? = - decorViewClass?.let { decorViewClass -> - if (decorViewClass.isInstance(maybeDecorView)) { - windowField?.let { windowField -> windowField[maybeDecorView] as Window } - } else { - null - } + fun pullWindow(maybeDecorView: View): Window? = decorViewClass?.let { decorViewClass -> + if (decorViewClass.isInstance(maybeDecorView)) { + windowField?.let { windowField -> windowField[maybeDecorView] as Window } + } else { + null } + } } /** diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 6bb58c5e2a2..f505d21a151 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -13,6 +13,7 @@ import io.sentry.SentryReplayEvent.ReplayType.BUFFER import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FLUSHED import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID @@ -53,7 +54,7 @@ internal abstract class BaseCaptureStrategy( internal companion object { private const val TAG = "CaptureStrategy" // https://github.com/getsentry/sentry-javascript/blob/30eb68fff5077211c30c61ba74625e66ab514870/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts#L41 - private const val MAX_TRACE_IDS = 100 + private const val MAX_CONTEXT_VALUES = 100 } private val gestureConverter = ReplayGestureConverter(dateProvider) @@ -89,10 +90,16 @@ internal abstract class BaseCaptureStrategy( get() = cache?.replayCacheDir override var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) + // Tracks whether the buffer was flushed (segments sent to server). Used by fromDisk() + // to decide whether to normalize the segment ID to 0 on crash recovery: if never flushed, + // no segments reached the server, so the recovered segment must be 0. + override var isFlushed: Boolean by + persistableAtomic(initialValue = false, propertyName = SEGMENT_KEY_FLUSHED) protected val currentEvents: Deque = ConcurrentLinkedDeque() - private val traceIdsLock = Any() - private val currentTraceIds: MutableList = mutableListOf() + private val replayContextLock = Any() + private val currentTraceIds: MutableSet = linkedSetOf() + private val currentSegmentNames: MutableSet = linkedSetOf() override fun start(segmentId: Int, replayId: SentryId, replayType: ReplayType?) { cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId) @@ -133,11 +140,12 @@ internal abstract class BaseCaptureStrategy( breadcrumbs: List? = null, events: Deque = this.currentEvents, ): ReplaySegment { - val traceIds = - synchronized(traceIdsLock) { - val ids = currentTraceIds.toList() + val (traceIds, segmentNames) = + synchronized(replayContextLock) { + val context = currentTraceIds.toList() to currentSegmentNames.toList() currentTraceIds.clear() - ids + currentSegmentNames.clear() + context } return createSegment( scopes, @@ -156,6 +164,7 @@ internal abstract class BaseCaptureStrategy( breadcrumbs, events, traceIds, + segmentNames, ) } @@ -174,12 +183,19 @@ internal abstract class BaseCaptureStrategy( override fun registerTraceId(traceId: SentryId) { if (traceId != SentryId.EMPTY_ID) { - synchronized(traceIdsLock) { - if (currentTraceIds.size < MAX_TRACE_IDS) { - val id = traceId.toString() - if (!currentTraceIds.contains(id)) { - currentTraceIds.add(id) - } + synchronized(replayContextLock) { + if (currentTraceIds.size < MAX_CONTEXT_VALUES) { + currentTraceIds.add(traceId.toString()) + } + } + } + } + + override fun registerSegmentName(segmentName: String) { + if (segmentName.isNotEmpty()) { + synchronized(replayContextLock) { + if (currentSegmentNames.size < MAX_CONTEXT_VALUES) { + currentSegmentNames.add(segmentName) } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 0df8a642f63..4d7bcd64cf4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -4,6 +4,8 @@ import android.annotation.SuppressLint import android.annotation.TargetApi import android.graphics.Bitmap import android.view.MotionEvent +import io.sentry.DataCategory.All +import io.sentry.DataCategory.Replay import io.sentry.DateUtils import io.sentry.IScopes import io.sentry.SentryLevel.DEBUG @@ -17,6 +19,7 @@ import io.sentry.android.replay.capture.CaptureStrategy.Companion.rotateEvents import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.util.ReplayRunnable import io.sentry.android.replay.util.sample +import io.sentry.clientreport.DiscardReason.RATELIMIT_BACKOFF import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils @@ -25,6 +28,18 @@ import java.io.File import java.util.Date import java.util.concurrent.ScheduledExecutorService +/** + * Records a rolling `errorReplayDuration` window: segments are encoded but held in memory, and + * frames and segments older than the window are dropped on every screenshot. Used when the session + * is not sampled by `sessionSampleRate` but `onErrorSampleRate` is set. + * + * Nothing is sent until [captureReplay] flushes the buffer for an error — sampled per error against + * `onErrorSampleRate`, unlike session mode which samples once at start. After a successful flush + * [convert] hands over to a [SessionCaptureStrategy] so the rest of the session is recorded live. + * + * Since nothing is in flight, `ReplayIntegration` deliberately keeps this strategy recording while + * rate-limited, so the buffer stays warm for when the limit expires. + */ @SuppressLint("UseRequiresApi") @TargetApi(26) internal class BufferCaptureStrategy( @@ -100,12 +115,23 @@ internal class BufferCaptureStrategy( return } + if (isReplayRateLimited()) { + // the segment envelopes would be dropped by the transport anyway, so don't waste resources + // encoding videos that will only be discarded + options.logger.log(INFO, "Replay is rate-limited, not capturing for event") + // one lost event per flush, not per segment: the transport would have counted the current + // segment plus every buffered one, but a flush only ever loses a single replay from the + // user's perspective. Under-reporting here is preferable to making replay look like it + // dropped data it never held. + options.clientReportRecorder.recordLostEvent(RATELIMIT_BACKOFF, Replay) + return + } + createCurrentSegment("capture_replay") { segment -> bufferedSegments.capture() if (segment is ReplaySegment.Created) { segment.capture(scopes) - // we only want to increment segment_id in the case of success, but currentSegment // might be irrelevant since we changed strategies, so in the callback we increment // it on the new strategy already @@ -152,6 +178,13 @@ internal class BufferCaptureStrategy( ) return this } + if (isReplayRateLimited()) { + // captureReplay skipped the flush, so there is nothing to continue in session mode. Staying + // in buffer mode keeps the rolling buffer warm, so the next error after the rate limit + // expires can send a complete replay starting at segment 0. + options.logger.log(DEBUG, "Not converting to session mode, because replay is rate-limited") + return this + } // we hand over replayExecutor and persistingExecutor to the new strategy to preserve order of // execution val captureStrategy = @@ -171,6 +204,11 @@ internal class BufferCaptureStrategy( rotateEvents(currentEvents, bufferLimit) } + private fun isReplayRateLimited(): Boolean = + scopes?.rateLimiter?.let { + it.isActiveForCategory(All) || it.isActiveForCategory(Replay) + } == true + private fun deleteFile(file: File?) { if (file == null) { return diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 6dc391a15ec..780cdd92481 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -29,6 +29,7 @@ internal interface CaptureStrategy { val replayCacheDir: File? var replayType: ReplayType var segmentTimestamp: Date? + var isFlushed: Boolean fun start(segmentId: Int = 0, replayId: SentryId = SentryId(), replayType: ReplayType? = null) @@ -55,6 +56,8 @@ internal interface CaptureStrategy { fun registerTraceId(traceId: SentryId) + fun registerSegmentName(segmentName: String) + companion object { private fun Breadcrumb?.isNetworkAvailable(): Boolean = this != null && @@ -87,6 +90,7 @@ internal interface CaptureStrategy { breadcrumbs: List?, events: Deque, traceIds: List = emptyList(), + segmentNames: List = emptyList(), ): ReplaySegment { val generatedVideo = cache?.createVideoOf( @@ -126,6 +130,7 @@ internal interface CaptureStrategy { replayBreadcrumbs, events, traceIds, + segmentNames, ) } @@ -146,6 +151,7 @@ internal interface CaptureStrategy { breadcrumbs: List, events: Deque, traceIds: List, + segmentNames: List, ): ReplaySegment { val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration) val replay = @@ -158,6 +164,7 @@ internal interface CaptureStrategy { this.replayType = replayType this.videoFile = video this.traceIds = traceIds + this.segmentNames = segmentNames } val recordingPayload = mutableListOf() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index d62efb534cc..df6e09b5358 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -16,6 +16,17 @@ import io.sentry.util.FileUtils import java.util.Date import java.util.concurrent.ScheduledExecutorService +/** + * Records a full session: segments are encoded and sent continuously, one per + * `sessionSegmentDuration`, until the 1h `sessionDuration` deadline. Used when the session is + * sampled by `sessionSampleRate`. + * + * [captureReplay] is a no-op here — there is no buffer to flush, the segment covering the error is + * sent like any other. Because envelopes are in flight the whole time, `ReplayIntegration` pauses + * this strategy while offline or rate-limited so the envelope cache doesn't overflow. + * + * See [BufferCaptureStrategy] for the on-error counterpart. + */ internal class SessionCaptureStrategy( private val options: SentryOptions, private val scopes: IScopes?, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt index 70d0988d3b0..cbedfc24cc2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt @@ -65,11 +65,10 @@ internal class ReplayGestureConverter(private val dateProvider: ICurrentDateProv moveEvents += RRWebInteractionMoveEvent().apply { this.timestamp = now - this.positions = - positions.map { pos -> - pos.timeOffset -= totalOffset - pos - } + this.positions = positions.map { pos -> + pos.timeOffset -= totalOffset + pos + } this.pointerId = pointerId } currentPositions[pointerId]!!.clear() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt index 406b23a9e4e..c3deefbf71b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt @@ -97,7 +97,7 @@ internal class CanvasStrategy( Bitmap.createBitmap( config.recordingWidth, config.recordingHeight, - Bitmap.Config.ARGB_8888, + Bitmap.Config.RGB_565, ) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt index 4b9618df6ec..06be58e19de 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -51,7 +51,15 @@ internal class PixelCopyStrategy( private val executor = executorProvider.getExecutor() private val mainLooperHandler = executorProvider.getMainLooperHandler() private val screenshot = - Bitmap.createBitmap(config.recordingWidth, config.recordingHeight, Bitmap.Config.ARGB_8888) + Bitmap.createBitmap( + config.recordingWidth, + config.recordingHeight, + if (options.sessionReplay.isCaptureSurfaceViews) { + Bitmap.Config.ARGB_8888 + } else { + Bitmap.Config.RGB_565 + }, + ) private val prescaledMatrix by lazy(NONE) { Matrix().apply { preScale(config.scaleFactorX, config.scaleFactorY) } } private val lastCaptureSuccessful = AtomicBoolean(false) @@ -59,6 +67,7 @@ internal class PixelCopyStrategy( private val contentChanged = AtomicBoolean(false) private val unstableCaptures = AtomicInteger(0) private val isClosed = AtomicBoolean(false) + private val frameInFlight = AtomicBoolean(false) private val dstOverPaint by lazy(NONE) { Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } } private val screenshotCanvas by lazy(NONE) { Canvas(screenshot) } @@ -77,8 +86,15 @@ internal class PixelCopyStrategy( return } + if (!frameInFlight.compareAndSet(false, true)) { + options.logger.log(DEBUG, "PixelCopyStrategy capture is already in flight, skipping") + markContentChanged() + return + } + if (isClosed.get()) { options.logger.log(DEBUG, "PixelCopyStrategy is closed, not capturing screenshot") + finishFrame() return } @@ -90,6 +106,7 @@ internal class PixelCopyStrategy( { copyResult: Int -> if (isClosed.get()) { options.logger.log(DEBUG, "PixelCopyStrategy is closed, ignoring capture result") + finishFrame() return@request } @@ -97,44 +114,64 @@ internal class PixelCopyStrategy( options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) unstableCaptures.set(0) lastCaptureSuccessful.set(false) + finishFrame() return@request } val changedDuringCapture = contentChanged.get() if (changedDuringCapture && shouldSkipUnstableCapture()) { + finishFrame() return@request } - // TODO: disableAllMasking here and dont traverse? - val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay) - val surfaceViewNodes = - if (options.sessionReplay.isCaptureSurfaceViews) { - mutableListOf() - } else { - null - } - root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes) - - if (surfaceViewNodes.isNullOrEmpty()) { - executor.submit( - ReplayRunnable("screenshot_recorder.mask") { - applyMaskingAndNotify( - root, - viewHierarchy, - resetUnstableCaptures = !changedDuringCapture, + // Release the frame gate if anything below throws before we hand work off to the + // executor — otherwise a single failure wedges captures forever. + try { + // TODO: disableAllMasking here and dont traverse? + val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay) + val surfaceViewNodes = + if (options.sessionReplay.isCaptureSurfaceViews) { + mutableListOf() + } else { + null + } + root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes) + + if (surfaceViewNodes.isNullOrEmpty()) { + val submitted = + executor.submit( + ReplayRunnable("screenshot_recorder.mask") { + try { + applyMaskingAndNotify( + root, + viewHierarchy, + resetUnstableCaptures = !changedDuringCapture, + ) + } finally { + finishFrame() + } + } ) + if (submitted == null) { + finishFrame() } - ) - } else { - // Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger - // ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever. - markContentChanged() - captureSurfaceViews( - root, - surfaceViewNodes, - viewHierarchy, - resetUnstableCaptures = !changedDuringCapture, - ) + } else { + // Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger + // ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever. + markContentChanged() + captureSurfaceViews( + root, + surfaceViewNodes, + viewHierarchy, + resetUnstableCaptures = !changedDuringCapture, + ) + } + } catch (e: RuntimeException) { + // OEM View subclasses have been observed throwing during hierarchy traversal + // (e.g. Redmi's TextView NPE). Release the frame gate so a single bad frame + // doesn't wedge the recorder. Errors (OOM, LinkageError) intentionally propagate. + options.logger.log(WARNING, "Failed to process replay frame", e) + finishFrame() } }, mainLooperHandler.handler, @@ -143,6 +180,7 @@ internal class PixelCopyStrategy( options.logger.log(WARNING, "Failed to capture replay recording", e) unstableCaptures.set(0) lastCaptureSuccessful.set(false) + finishFrame() } } @@ -272,37 +310,46 @@ internal class PixelCopyStrategy( windowY: Int, resetUnstableCaptures: Boolean, ) { - executor.submit( - ReplayRunnable("screenshot_recorder.composite") { - if (isClosed.get() || screenshot.isRecycled) { - options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping compositing") - recycleCaptures(captures) - return@ReplayRunnable - } + val submitted = + executor.submit( + ReplayRunnable("screenshot_recorder.composite") { + try { + if (isClosed.get() || screenshot.isRecycled) { + options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping compositing") + recycleCaptures(captures) + return@ReplayRunnable + } - for (capture in captures) { - if (capture == null) continue - if (capture.bitmap.isRecycled) continue - - compositeSurfaceViewInto( - screenshotCanvas, - dstOverPaint, - tmpSrcRect, - tmpDstRect, - capture.bitmap, - capture.x, - capture.y, - windowX, - windowY, - config.scaleFactorX, - config.scaleFactorY, - ) - capture.bitmap.recycle() - } + for (capture in captures) { + if (capture == null) continue + if (capture.bitmap.isRecycled) continue + + compositeSurfaceViewInto( + screenshotCanvas, + dstOverPaint, + tmpSrcRect, + tmpDstRect, + capture.bitmap, + capture.x, + capture.y, + windowX, + windowY, + config.scaleFactorX, + config.scaleFactorY, + ) + capture.bitmap.recycle() + } - applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures) - } - ) + applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures) + } finally { + finishFrame() + } + } + ) + if (submitted == null) { + recycleCaptures(captures) + finishFrame() + } } private fun recycleCaptures(captures: Array) { @@ -322,15 +369,57 @@ internal class PixelCopyStrategy( } override fun emitLastScreenshot() { - if (lastCaptureSuccessful() && !screenshot.isRecycled) { - screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + if (!frameInFlight.compareAndSet(false, true)) { + return + } + if (!lastCaptureSuccessful() || screenshot.isRecycled) { + finishFrame() + return + } + // Submit to the executor so the downstream consumer's bitmap read (JPEG compress) runs inline + // on the worker thread while the gate is held, same as the masked capture path. + val submitted = + executor.submit( + ReplayRunnable("PixelCopyStrategy.emit") { + try { + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + } finally { + finishFrame() + } + } + ) + if (submitted == null) { + finishFrame() } } override fun close() { isClosed.set(true) unstableCaptures.set(0) - executor.submit( + cleanUpIfIdle() + } + + private fun finishFrame() { + frameInFlight.set(false) + if (isClosed.get()) { + cleanUpIfIdle() + } + } + + /** + * Schedules cleanup only for the caller that owns the gate. Whoever wins [frameInFlight]'s CAS + * (close when no frame is running, or the finishFrame of the last in-flight frame after close) + * runs cleanup exactly once; a racing capture that took the gate loses the CAS and backs off, so + * we never recycle the shared screenshot while that capture is still using it. + */ + private fun cleanUpIfIdle() { + if (frameInFlight.compareAndSet(false, true)) { + scheduleCleanup() + } + } + + private fun scheduleCleanup() { + val cleanup = ReplayRunnable( "PixelCopyStrategy.close", { @@ -344,7 +433,12 @@ internal class PixelCopyStrategy( maskRenderer.close() }, ) - ) + // ReplayExecutorService.submit returns null only on genuine rejection (post-shutdown); + // inline execution on the worker thread returns a completed future. Fall back to running + // cleanup here so the bitmap + mask renderer are freed even when the executor is dead. + if (executor.submit(cleanup) == null) { + cleanup.run() + } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt index 9e9491f516f..5ba334f8029 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt @@ -4,6 +4,7 @@ import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions import java.util.concurrent.Future import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit.MILLISECONDS /** @@ -14,11 +15,20 @@ internal class ReplayExecutorService( private val delegate: ScheduledExecutorService, private val options: SentryOptions, ) : ScheduledExecutorService by delegate { + /** + * Submits [task] for execution and returns a [Future] describing what happened. The return value + * has three distinct outcomes callers can rely on: + * - [CompletedFuture] — the caller is already on the replay worker thread, so the task was run + * inline before this method returned. Skips the queue. + * - A regular [Future] from the underlying [ScheduledExecutorService] — the task was queued and + * will run asynchronously. + * - `null` — the underlying executor rejected the submission (typically because it has been shut + * down). The task did NOT run; callers that need cleanup must handle it themselves. + */ override fun submit(task: Runnable): Future<*>? { if (Thread.currentThread().name.startsWith("SentryReplayIntegration")) { - // we're already on the worker thread, no need to submit task.run() - return null + return CompletedFuture } return try { delegate.submit { @@ -68,3 +78,16 @@ internal class ReplayExecutorService( } internal class ReplayRunnable(val taskName: String, delegate: Runnable) : Runnable by delegate + +/** A Future that represents an already-completed inline execution — never used as null. */ +internal object CompletedFuture : Future { + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false + + override fun isCancelled(): Boolean = false + + override fun isDone(): Boolean = true + + override fun get() {} + + override fun get(timeout: Long, unit: TimeUnit) {} +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt index e32af9bb44b..0063cf636e4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -67,9 +67,10 @@ internal class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer } override fun release() { - // stop() throws if the muxer was never started (e.g. no frame was ever muxed), so we guard it - // to ensure release() is always reached and the underlying resources are freed - if (started) { + // stop() throws unless the muxer was started AND at least one sample was written, so we guard + // it + // to ensure muxer.release() is always reached and the underlying resources are freed + if (started && videoFrames > 0) { muxer.stop() } muxer.release() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index de14aadaaab..dd9af1c24d6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -37,6 +37,7 @@ import android.media.MediaFormat import android.os.Build import android.view.Surface import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.android.replay.util.SystemProperties import java.io.File @@ -45,6 +46,16 @@ import kotlin.LazyThreadSafetyMode.NONE private const val TIMEOUT_USEC = 100_000L +/** + * How many consecutive [MediaCodec.dequeueOutputBuffer] calls may come back without producing + * anything before we give up on the encoder. At [TIMEOUT_USEC] per call that's ~1s. + * + * Some hardware encoders never emit [MediaCodec.BUFFER_FLAG_END_OF_STREAM] after + * [MediaCodec.signalEndOfInputStream], which used to spin the drain loop forever while holding the + * encoder lock, wedging the whole replay pipeline (and with it the app's lifecycle callbacks). + */ +private const val MAX_EOS_STALL_ITERATIONS = 10 + @SuppressLint("UseRequiresApi") @TargetApi(26) internal class SimpleVideoEncoder( @@ -81,7 +92,7 @@ internal class SimpleVideoEncoder( val videoCapabilities = mediaCodec.codecInfo.getCapabilitiesForType(muxerConfig.mimeType).videoCapabilities - if (!videoCapabilities.bitrateRange.contains(bitRate)) { + if (videoCapabilities != null && !videoCapabilities.bitrateRange.contains(bitRate)) { options.logger.log( DEBUG, "Encoder doesn't support the provided bitRate: $bitRate, the value will be clamped to the closest one", @@ -214,19 +225,26 @@ internal class SimpleVideoEncoder( mediaCodec.signalEndOfInputStream() } var encoderOutputBuffers: Array? = mediaCodec.outputBuffers + // counts consecutive iterations that made no progress, so a codec that never signals EOS can't + // spin us forever, see MAX_EOS_STALL_ITERATIONS + var stalledIterations = 0 while (true) { val encoderStatus: Int = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_USEC) if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) { // no output available yet if (!endOfStream) { break // out of while - } else if (options.sessionReplay.isDebug) { + } + stalledIterations++ + if (options.sessionReplay.isDebug) { options.logger.log(DEBUG, "[Encoder]: no output available, spinning to await EOS") } } else if (encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + stalledIterations = 0 // not expected for an encoder encoderOutputBuffers = mediaCodec.outputBuffers } else if (encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + stalledIterations = 0 // should happen before receiving buffers, and should only happen once if (frameMuxer.isStarted()) { throw RuntimeException("format changed twice") @@ -245,8 +263,10 @@ internal class SimpleVideoEncoder( "[Encoder]: unexpected result from encoder.dequeueOutputBuffer: $encoderStatus", ) } - // let's ignore it + // let's ignore it, but still count it as no progress so we can't loop on it forever + stalledIterations++ } else { + stalledIterations = 0 val encodedData = encoderOutputBuffers?.get(encoderStatus) ?: throw RuntimeException("encoderOutputBuffer $encoderStatus was null") @@ -279,6 +299,14 @@ internal class SimpleVideoEncoder( break // out of while } } + + if (stalledIterations >= MAX_EOS_STALL_ITERATIONS) { + options.logger.log( + WARNING, + "[Encoder]: encoder made no progress for $stalledIterations iterations, dropping the remaining frames", + ) + break // out of while + } } } @@ -287,12 +315,25 @@ internal class SimpleVideoEncoder( onClose?.invoke() drainCodec(true) mediaCodec.stop() - mediaCodec.release() - surface?.release() - - frameMuxer.release() - } catch (e: Throwable) { + } catch (e: RuntimeException) { options.logger.log(DEBUG, "Failed to properly release video encoder", e) + } finally { + // always release the native resources, even if draining/stopping the codec above threw (e.g. + // when the encoder failed to fully start), otherwise they leak (CloseGuard warning). guard + // each + // call so failing to release one resource neither skips the others nor propagates to callers, + // which treat release() as safe cleanup + releaseQuietly("MediaCodec") { mediaCodec.release() } + releaseQuietly("Surface") { surface?.release() } + releaseQuietly("MediaMuxer") { frameMuxer.release() } + } + } + + private inline fun releaseQuietly(name: String, block: () -> Unit) { + try { + block() + } catch (e: RuntimeException) { + options.logger.log(DEBUG, "Failed to release $name", e) } } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt index 1214c55c057..3df08b2af24 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt @@ -155,11 +155,10 @@ class AnrWithReplayIntegrationTest { it.sessionReplay.onErrorSampleRate = 1.0 // beforeSend is called after event processors are applied, so we can assert here // against the enriched ANR event - it.beforeSend = - SentryOptions.BeforeSendCallback { event, _ -> - assertEquals(replayId2.toString(), event.contexts[Contexts.REPLAY_ID]) - event - } + it.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> + assertEquals(replayId2.toString(), event.contexts[Contexts.REPLAY_ID]) + event + } it.addEventProcessor( object : EventProcessor { override fun process(event: SentryReplayEvent, hint: Hint): SentryReplayEvent { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt index 3da118190f0..749d3496698 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt @@ -368,8 +368,9 @@ class DefaultReplayBreadcrumbConverterTest { } // Set up options with a user callback that returns modified breadcrumb - val userBeforeBreadcrumbCallback = - SentryOptions.BeforeBreadcrumbCallback { _, _ -> userModifiedBreadcrumb } + val userBeforeBreadcrumbCallback = SentryOptions.BeforeBreadcrumbCallback { _, _ -> + userModifiedBreadcrumb + } val options = SentryOptions.empty() options.beforeBreadcrumb = userBeforeBreadcrumbCallback diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 257941a9114..96e5a926af4 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -4,11 +4,14 @@ import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat.JPEG import android.graphics.Bitmap.Config.ARGB_8888 import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import io.sentry.DateUtils import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FLUSHED import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID @@ -23,10 +26,13 @@ import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchEnd import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart import java.io.File import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit.SECONDS import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -35,6 +41,7 @@ import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowBitmapFactory +import org.robolectric.shadows.ShadowCloseGuard @RunWith(AndroidJUnit4::class) @Config(sdk = [26], shadows = [ReplayShadowMediaCodec::class]) @@ -55,6 +62,11 @@ class ReplayCacheTest { @BeforeTest fun `set up`() { ReplayShadowMediaCodec.framesToEncode = 5 + ReplayShadowMediaCodec.throwOnStart = false + ReplayShadowMediaCodec.neverSignalEos = false + ReplayShadowMediaCodec.blockOnDequeue = null + ReplayShadowMediaCodec.blockedOnDequeue = CountDownLatch(1) + ReplayShadowMediaCodec.released = false ShadowBitmapFactory.setAllowInvalidImageData(true) } @@ -92,6 +104,26 @@ class ReplayCacheTest { assertNull(video) } + @Test + fun `releases the muxer when the encoder fails to start`() { + ReplayShadowMediaCodec.throwOnStart = true + val replayCache = fixture.getSut(tmpDir) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + ShadowCloseGuard.reset() + assertFailsWith { + replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) + } + + val muxerLeaks = + ShadowCloseGuard.getErrors().filter { error -> + error.stackTrace.any { it.className.contains("MediaMuxer") } + } + assertTrue(muxerLeaks.isEmpty(), "MediaMuxer was not released: $muxerLeaks") + } + @Test fun `deletes frames after creating a video`() { ReplayShadowMediaCodec.framesToEncode = 3 @@ -443,7 +475,7 @@ class ReplayCacheTest { } @Test - fun `sets segmentId to 0 for buffer mode`() { + fun `sets segmentId to 0 for buffer mode when not flushed`() { fixture.options.run { cacheDirPath = tmpDir.newFolder()?.absolutePath } val replayId = SentryId() val replayCacheFolder = @@ -474,6 +506,39 @@ class ReplayCacheTest { assertEquals(0, lastSegment.id) } + @Test + fun `preserves segmentId for buffer mode when already flushed`() { + fixture.options.run { cacheDirPath = tmpDir.newFolder()?.absolutePath } + val replayId = SentryId() + val replayCacheFolder = + File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=5 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + $SEGMENT_KEY_FLUSHED=true + """ + .trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(5, lastSegment.id) + } + @Test fun `when screenshot is corrupted, deletes it immediately`() { ShadowBitmapFactory.setAllowInvalidImageData(false) @@ -597,4 +662,88 @@ class ReplayCacheTest { // No crash is success assertNull(error.get()) } + + @Test + fun `createVideoOf returns when the encoder never signals end of stream`() { + ReplayShadowMediaCodec.neverSignalEos = true + val replayCache = fixture.getSut(tmpDir) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + val done = CountDownLatch(1) + val error = AtomicReference() + val encoder = + thread(isDaemon = true) { + try { + replayCache.createVideoOf(1000L, 0L, 0, 100, 200, 1, 20_000) + } catch (t: Throwable) { + error.set(t) + } finally { + done.countDown() + } + } + + assertWithMessage("createVideoOf did not return, the drain loop is spinning") + .that(done.await(30, SECONDS)) + .isTrue() + encoder.join(SECONDS.toMillis(10)) + assertThat(error.get()).isNull() + } + + @Test + fun `close does not block when the encoder is wedged, and still marks the cache closed`() { + val wedge = CountDownLatch(1) + ReplayShadowMediaCodec.blockOnDequeue = wedge + val replayCache = fixture.getSut(tmpDir) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + // parks inside MediaCodec while holding the encoder lock + val encoder = + thread(isDaemon = true) { replayCache.createVideoOf(1000L, 0L, 0, 100, 200, 1, 20_000) } + try { + assertWithMessage("the encoder never reached dequeueOutputBuffer") + .that(ReplayShadowMediaCodec.blockedOnDequeue.await(30, SECONDS)) + .isTrue() + + // on a separate thread so a regression fails the test instead of hanging the run + val closed = CountDownLatch(1) + thread(isDaemon = true) { + replayCache.close() + closed.countDown() + } + assertWithMessage("close() blocked on the wedged encoder") + .that(closed.await(30, SECONDS)) + .isTrue() + + // giving up on the lock still counts as closed, otherwise we'd keep persisting segments + replayCache.persistSegmentValues(SEGMENT_KEY_ID, "0") + assertThat(File(replayCache.replayCacheDir, ONGOING_SEGMENT).exists()).isFalse() + + assertWithMessage("encoder should not be released when the lock times out") + .that(ReplayShadowMediaCodec.released) + .isFalse() + } finally { + wedge.countDown() + encoder.join(SECONDS.toMillis(10)) + } + } + + @Test + fun `createVideoOf releases the encoder even when EOS is never signalled`() { + ReplayShadowMediaCodec.neverSignalEos = true + val replayCache = fixture.getSut(tmpDir) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + // the stall bound breaks the drain loop, but release() must still be called + replayCache.createVideoOf(1000L, 0L, 0, 100, 200, 1, 20_000) + + assertWithMessage("encoder should be released even when EOS was never signalled") + .that(ReplayShadowMediaCodec.released) + .isTrue() + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 61b5213e76f..32b7f4e9285 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -1043,7 +1043,9 @@ class ReplayIntegrationTest { replay.start() fixture.options.sessionReplay.frameObserver = - SentryReplayOptions.ReplayFrameObserver { _, _, _ -> throw RuntimeException("test") } + SentryReplayOptions.ReplayFrameObserver { _, _, _ -> + throw RuntimeException("test") + } val sourceBitmap = mock { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index c26e6be9c41..b5e15b5534f 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -23,13 +23,16 @@ import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.transport.RateLimiter import java.time.Duration +import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.BeforeTest import kotlin.test.assertEquals import kotlin.test.assertNotEquals +import kotlin.test.assertTrue import org.awaitility.core.ConditionTimeoutException import org.awaitility.kotlin.await import org.junit.Rule @@ -41,6 +44,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -61,11 +65,17 @@ class ReplaySmokeTest { internal class Fixture { val options = SentryOptions() val scope = Scope(options) + val rateLimiter = + mock { + on { isActiveForCategory(any()) }.thenReturn(false) + } val scopes = mock { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(it) .configureScope(any()) + + on { rateLimiter }.doReturn(rateLimiter) } private class ImmediateHandler : @@ -91,7 +101,10 @@ class ReplaySmokeTest { mainLooperHandler = mock { whenever(mock.handler).thenReturn(ImmediateHandler()) - whenever(mock.post(any())).then { (it.arguments[0] as Runnable).run() } + whenever(mock.post(any())).then { + (it.arguments[0] as Runnable).run() + true + } whenever(mock.postDelayed(any(), anyLong())).then { // have to use another thread here otherwise it will block the test thread recordingThread.schedule( @@ -243,6 +256,45 @@ class ReplaySmokeTest { assertNotEquals(falseReplay.rootViewsSpy, replay.rootViewsSpy) assertEquals(0, falseReplay.rootViewsSpy.listeners.size) } + + @Test + fun `close does not deadlock when executor task is waiting on lifecycleLock`() { + fixture.options.sessionReplay.sessionSampleRate = 1.0 + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay = fixture.getSut(context) + replay.register(fixture.scopes, fixture.options) + replay.start() + + val taskBlocked = CountDownLatch(1) + val lockReleased = CountDownLatch(1) + + // hold lifecycleLock on this thread + val token = replay.lifecycleLock.acquire() + + // submit a task on the executor that tries to acquire the same lock — it will block + replay.replayExecutor.submit { + taskBlocked.countDown() + replay.lifecycleLock.acquire().use {} + } + + // wait for the executor task to actually be running and blocked + assertTrue(taskBlocked.await(2, TimeUnit.SECONDS)) + + // release the lock, then close — if shutdown were inside the lock this would deadlock + token.close() + + // close() must complete within a reasonable time + val closedInTime = AtomicBoolean(false) + val closeThread = Thread { + replay.close() + closedInTime.set(true) + } + closeThread.start() + closeThread.join(5000) + + assertTrue(closedInTime.get(), "close() deadlocked") + } } private class ExampleActivity : Activity() { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt index 0a5c73f8a5c..00b58666669 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt @@ -14,8 +14,10 @@ import kotlin.test.Test import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) class ScreenshotRecorderTest { internal class Fixture() { @@ -42,10 +44,9 @@ class ScreenshotRecorderTest { @Test fun `when config uses PIXEL_COPY strategy, ScreenshotRecorder creates PixelCopyStrategy`() { - val recorder = - fixture.getSut { options -> - options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY - } + val recorder = fixture.getSut { options -> + options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY + } val strategy = getStrategy(recorder) @@ -57,10 +58,9 @@ class ScreenshotRecorderTest { @Test fun `when config uses CANVAS strategy, ScreenshotRecorder creates CanvasStrategy`() { - val recorder = - fixture.getSut { options -> - options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.CANVAS - } + val recorder = fixture.getSut { options -> + options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.CANVAS + } val strategy = getStrategy(recorder) assertTrue( diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index b5048e856ff..fc1981a84b1 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -2,9 +2,12 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import android.view.MotionEvent +import io.sentry.DataCategory import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.SentryEnvelope +import io.sentry.SentryEnvelopeHeader import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.DefaultReplayBreadcrumbConverter @@ -17,9 +20,12 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayFrame import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.BufferCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION +import io.sentry.clientreport.DiscardReason +import io.sentry.clientreport.DiscardedEvent import io.sentry.protocol.SentryId import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.transport.RateLimiter import io.sentry.util.Random import java.io.File import kotlin.test.Test @@ -93,6 +99,16 @@ class BufferCaptureStrategyTest { bitRate = 20_000, ) + // client report counts are only readable by draining them onto an envelope + fun discardedEvents(): List = + options.clientReportRecorder + .attachReportToEnvelope(SentryEnvelope(SentryEnvelopeHeader(), emptyList())) + .items + .firstOrNull() + ?.getClientReport(options.serializer) + ?.discardedEvents + .orEmpty() + fun getSut( onErrorSampleRate: Double = 1.0, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), @@ -239,6 +255,19 @@ class BufferCaptureStrategyTest { assertTrue(converted is BufferCaptureStrategy) } + @Test + fun `convert stays in buffer mode when rate-limited`() { + val rateLimiter = mock { on { isActiveForCategory(any()) }.thenReturn(true) } + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) + val strategy = fixture.getSut() + strategy.start() + + strategy.captureReplay(false) {} + + val converted = strategy.convert() + assertTrue(converted is BufferCaptureStrategy) + } + @Test fun `convert converts to session strategy and sets replayId to scope`() { val strategy = fixture.getSut() @@ -336,6 +365,52 @@ class BufferCaptureStrategyTest { assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) } + @Test + fun `captureReplay does not capture segments when rate-limited`() { + val rateLimiter = mock { on { isActiveForCategory(any()) }.thenReturn(true) } + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + strategy.pause() + + strategy.captureReplay(false) {} + + // neither the current nor the buffered segment should be sent while rate-limited + verify(fixture.scopes, never()).captureReplay(any(), any()) + // the replayId is still set on the scope so the error that flushed the buffer stays linked to + // the replay that gets recorded once the rate limit lifts + assertEquals(strategy.currentReplayId, fixture.scope.replayId) + } + + @Test + fun `captureReplay records a lost replay event when rate-limited`() { + val rateLimiter = mock { on { isActiveForCategory(any()) }.thenReturn(true) } + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + strategy.pause() + + strategy.captureReplay(false) {} + + val discarded = fixture.discardedEvents() + assertEquals(1, discarded.size) + assertEquals(DiscardReason.RATELIMIT_BACKOFF.reason, discarded.first().reason) + assertEquals(DataCategory.Replay.category, discarded.first().category) + } + + @Test + fun `captureReplay does not record a lost replay event when not rate-limited`() { + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.captureReplay(false) {} + + assertTrue(fixture.discardedEvents().none { it.category == DataCategory.Replay.category }) + } + @Test fun `captureReplay sets replayId to scope and captures buffered segments`() { var called = false diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index dd9e6c6ce1d..fc2354eb1c0 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -562,4 +562,58 @@ class SessionCaptureStrategyTest { any(), ) } + + @Test + fun `registerSegmentName includes unique segment names in next segment and clears them`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.registerSegmentName("CheckoutActivity") + strategy.registerSegmentName("CheckoutActivity") + strategy.registerSegmentName("ProductDetailsActivity") + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && + event.segmentNames == listOf("CheckoutActivity", "ProductDetailsActivity") + }, + any(), + ) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 1 && event.segmentNames.isNullOrEmpty() + }, + any(), + ) + } + + @Test + fun `registerSegmentName ignores empty names and limits names to 100`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.registerSegmentName("") + repeat(101) { strategy.registerSegmentName("ProductActivity$it") } + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> event is SentryReplayEvent && event.segmentNames?.size == 100 }, + any(), + ) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt index 779cf7d4311..587927e9793 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt @@ -25,8 +25,11 @@ import io.sentry.SentryOptions import io.sentry.android.replay.ExecutorProvider import io.sentry.android.replay.ScreenshotRecorderCallback import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.CompletedFuture import io.sentry.android.replay.util.DebugOverlayDrawable import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.ReplayRunnable +import java.util.concurrent.Future import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -37,6 +40,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -85,7 +89,10 @@ class PixelCopyStrategyTest { return mock { doAnswer { (it.arguments[0] as Runnable).run() - null // submit(Runnable) returns Future; returning Unit breaks the cast + // Mirror ReplayExecutorService's inline contract: a completed future, not null. Null + // means "rejected" and would make capture() run its null-fallback finishFrame on top of + // the task's own, a double-release production never does on the inline path. + CompletedFuture } .whenever(mock) .submit(any()) @@ -112,25 +119,30 @@ class PixelCopyStrategyTest { } @Test - fun `when close is called while executor task is running, does not crash with recycled bitmap`() { + fun `when close races the mask task, masking is skipped and no screenshot is emitted`() { val activity = buildActivity(SimpleActivity::class.java).setup() shadowOf(Looper.getMainLooper()).idle() var strategy: PixelCopyStrategy? = null val failure = AtomicReference() - // Custom executor that closes the strategy before executing tasks + // Custom executor that closes the strategy right before running the mask task, to simulate + // close() racing an in-flight mask task. We key off the mask task specifically (not "the first + // submit") because close() itself submits the cleanup task — closing again when that runs would + // recurse via close() -> scheduleCleanup() -> submit(), a loop no real code path can produce. val executorThatClosesFirst = mock() whenever(executorThatClosesFirst.submit(any())).doAnswer { val task = it.getArgument(0) - strategy?.close() + if ((task as? ReplayRunnable)?.taskName == "screenshot_recorder.mask") { + strategy?.close() + } try { task.run() } catch (e: Throwable) { // PixelCopyStrategy swallows the exception, so we have to capture it here and rethrow later failure.set(e) } - null + CompletedFuture } strategy = fixture.getSut(executor = executorThatClosesFirst) @@ -138,6 +150,251 @@ class PixelCopyStrategyTest { shadowOf(Looper.getMainLooper()).idle() if (failure.get() != null) throw failure.get() + // close() landed before masking ran, so applyMaskingAndNotify must bail out early and never + // hand a screenshot to the callback after the strategy is closed. + verify(fixture.callback, never()).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture drops frame while PixelCopy is in flight`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + + strategy.capture(root) + strategy.capture(root) + + assertTrue(fixture.contentChangedMarked.get()) + + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(fixture.callback).onScreenshotRecorded(any()) + + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture drops frame while masking is in flight`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val tasks = mutableListOf() + val executor = mock() + whenever(executor.submit(any())).doAnswer { + tasks += it.getArgument(0) + mock>() + } + val strategy = fixture.getSut(executor) + + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, tasks.size) + tasks.removeAt(0).run() + + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, tasks.size) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `emitLastScreenshot skips while frame is in flight`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + captureStableFrame(strategy, root) + + strategy.capture(root) + strategy.emitLastScreenshot() + + verify(fixture.callback).onScreenshotRecorded(any()) + + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `emitLastScreenshot holds the frame gate until the emit task drains`() { + // emit submits the consumer call to the executor so the bitmap read (JPEG compress) runs + // inline on the worker thread while the gate is held — same pattern as the masked capture path. + // Invariant: while the emit task is still queued (gate held), a racing capture is dropped. + // Without the gate (old `if (!frameInFlight.get())`) that capture proceeds -> extra frame. + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val tasks = mutableListOf() + val executor = mock() + whenever(executor.submit(any())).doAnswer { + tasks.add(it.arguments[0] as Runnable) + mock>() + } + val strategy = fixture.getSut(executor) + + // Set up a successful last capture: capture -> queued mask task -> drain releases the gate. + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + tasks.removeAll { + it.run() + true + } + verify(fixture.callback, times(1)).onScreenshotRecorded(any()) + + // Emit takes the gate and queues the consumer task (still pending). + strategy.emitLastScreenshot() + // Callback hasn't fired yet — the task is queued, not drained. + verify(fixture.callback, times(1)).onScreenshotRecorded(any()) + + // A capture racing in before the emit task drains must be dropped (gate held). + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + verify(fixture.callback, times(1)).onScreenshotRecorded(any()) + + // Drain the emit task -> callback fires, gate released -> captures resume. + tasks.removeAll { + it.run() + true + } + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + captureStableFrame(strategy, root) + tasks.removeAll { + it.run() + true + } + verify(fixture.callback, times(3)).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `close defers cleanup until PixelCopy completes`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val executor = mock() + val strategy = fixture.getSut(executor) + + strategy.capture(root) + strategy.close() + + verify(executor, never()).submit(any()) + + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(executor).submit(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `close-triggered cleanup keeps the frame gate so a racing capture cannot double-clean up`() { + // Guards the CAS handoff in finishFrame(). The real race is a 3-thread interleave (a new + // capture takes the gate the instant finishFrame releases it, then the old finishFrame recycles + // the bitmap the new capture is writing) and isn't deterministically reproducible single- + // threaded. This exercises its observable invariant instead: when finishFrame cleans up on + // close, it must re-take the gate (frameInFlight stays held), so any later capture is dropped + // rather than sneaking through to schedule a *second* cleanup on the shared screenshot. + // Without the CAS (plain frameInFlight.set(false)) the gate is left free and the follow-up + // capture reaches the isClosed guard and schedules cleanup again -> 2 submits. + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val executor = mock() + whenever(executor.submit(any())).thenReturn(mock>()) + val strategy = fixture.getSut(executor) + + strategy.capture(root) + strategy.close() // in-flight -> cleanup deferred, no submit yet + + // PixelCopy completes; the callback sees isClosed and runs finishFrame -> the one cleanup. + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + // A capture racing in after close must be dropped (gate still held), not schedule cleanup + // again. + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(executor, times(1)).submit(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `idle close claims the gate so a racing capture cannot schedule a second cleanup`() { + // Mirror of the finishFrame guard, but for close()'s idle path (no frame in flight). close() + // must atomically claim the gate before scheduling cleanup; otherwise a capture racing in right + // after the check can take the gate, see isClosed, run finishFrame and schedule cleanup a + // second + // time. Both cleanups are idempotent, but a single submit is the invariant we keep uniform. + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + val executor = mock() + whenever(executor.submit(any())).thenReturn(mock>()) + val strategy = fixture.getSut(executor) + + strategy.close() // idle -> claims gate, schedules the one cleanup + // A capture landing after close must be dropped (gate held), not schedule cleanup again. + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(executor, times(1)).submit(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `frame gate is released when masking submit is rejected`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + // Simulate an already-shutdown executor: submit returns null. + val executor = mock() + whenever(executor.submit(any())).thenReturn(null) + val strategy = fixture.getSut(executor) + + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + // Gate must have been released; a follow-up capture should proceed rather than being dropped. + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + verify(executor, times(2)).submit(any()) + } + + @Test + fun `close cleans up inline when executor is already shut down`() { + // submit returns null → previously the bitmap + maskRenderer would leak. + val executor = mock() + whenever(executor.submit(any())).thenReturn(null) + val strategy = fixture.getSut(executor) + + strategy.close() + + // No crash and the submit was attempted exactly once (cleanup ran inline as fallback). + verify(executor).submit(any()) } @Test @@ -214,7 +471,9 @@ class PixelCopyStrategyTest { assertFalse(fixture.contentChangedMarked.get()) assertTrue(strategy.lastCaptureSuccessful()) - verify(fixture.callback).onScreenshotRecorded(any()) + val screenshot = argumentCaptor() + verify(fixture.callback).onScreenshotRecorded(screenshot.capture()) + assertEquals(Bitmap.Config.RGB_565, screenshot.firstValue.config) } @Test @@ -246,7 +505,9 @@ class PixelCopyStrategyTest { shadowOf(Looper.getMainLooper()).idle() assertTrue(strategy.lastCaptureSuccessful()) - verify(fixture.callback).onScreenshotRecorded(any()) + val screenshot = argumentCaptor() + verify(fixture.callback).onScreenshotRecorded(screenshot.capture()) + assertEquals(Bitmap.Config.ARGB_8888, screenshot.firstValue.config) } @Test diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt index f60c6688386..e0e13076ea0 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt @@ -3,6 +3,7 @@ package io.sentry.android.replay.util import android.media.MediaCodec import android.media.MediaCodec.BufferInfo import java.nio.ByteBuffer +import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit.MICROSECONDS import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean @@ -15,17 +16,44 @@ class ReplayShadowMediaCodec : ShadowMediaCodec() { companion object { var frameRate = 1 var framesToEncode = 5 + var throwOnStart = false + + /** Simulates an encoder that never emits [MediaCodec.BUFFER_FLAG_END_OF_STREAM]. */ + var neverSignalEos = false + + /** + * When set, [dequeueOutputBuffer] awaits this latch, simulating a native call that never + * returns. [blockedOnDequeue] is counted down right before, so tests can wait until the codec + * is actually stuck. + */ + var blockOnDequeue: CountDownLatch? = null + + var blockedOnDequeue = CountDownLatch(1) + + /** Set to `true` when [release] is called. */ + var released = false } private val encoded = AtomicBoolean(false) + @Implementation + fun release() { + released = true + } + @Implementation fun start() { + if (throwOnStart) { + throw IllegalStateException("Simulated codec start failure") + } super.native_start() } @Implementation fun signalEndOfInputStream() { + if (neverSignalEos) { + return + } encodeFrame(framesToEncode, frameRate, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) } @@ -33,6 +61,10 @@ class ReplayShadowMediaCodec : ShadowMediaCodec() { @Implementation fun dequeueOutputBuffer(info: BufferInfo, timeoutUs: Long): Int { + blockOnDequeue?.let { + blockedOnDequeue.countDown() + it.await() + } val encoderStatus = super.native_dequeueOutputBuffer(info, timeoutUs) super.validateOutputByteBuffer(getOutputBuffers(), encoderStatus, info) if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER && !encoded.getAndSet(true)) { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt index 2eaa8411cfe..3d5f6a9c506 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt @@ -20,8 +20,10 @@ import kotlin.test.assertTrue import org.junit.runner.RunWith import org.robolectric.Robolectric.buildActivity import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) class ViewsTest { @BeforeTest diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index baf0a32a415..0accb9ed16d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -233,10 +233,9 @@ class ComposeMaskingOptionsTest { val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] - val unmaskNode = - textNodes.first { - (it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request" - } + val unmaskNode = textNodes.first { + (it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request" + } assertTrue(unmaskNode.isVisible, "The unmasked node must be visible for the test to be valid") assertFalse(unmaskNode.shouldMask, "Node with sentryReplayUnmask() should not be masked") diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index 6e0275b29b8..9637b91546a 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -1,4 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") @@ -23,10 +25,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { @@ -47,10 +53,6 @@ android { buildFeatures { buildConfig = true } - // Needed b/c Kotlin 1.4.x would otherwise pull in an older version without the annotations we - // want. - configurations.all { resolutionStrategy.force(libs.jetbrains.annotations.get()) } - androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) } @@ -69,14 +71,12 @@ dependencies { api(projects.sentry) compileOnly(libs.androidx.sqlite) - compileOnly(libs.jetbrains.annotations) implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid)) // tests testImplementation(libs.androidx.sqlite) testImplementation(libs.kotlin.test.junit) - testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt index f5f8424aca3..01da8c476db 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt @@ -2,20 +2,20 @@ package io.sentry.android.sqlite import android.database.CrossProcessCursor import android.database.CursorWindow +import android.database.CursorWrapper /* * SQLiteCursor executes the query lazily, when one of getCount() and onMove() is called. * Also, by docs, fillWindow() can be used to fill the cursor with data. * So we wrap these methods to create a span. - * SQLiteCursor is never used directly in the code, but only the Cursor interface. - * This means we can use CrossProcessCursor - that extends Cursor - as wrapper, since - * CrossProcessCursor is an interface and we can use Kotlin delegation. + * Ordinary Cursor methods are delegated through CursorWrapper to avoid adding Sentry frames to + * app database exceptions that the wrapper did not instrument. */ internal class SentryCrossProcessCursor( private val delegate: CrossProcessCursor, private val spans: OpenHelperSpans, private val sql: String, -) : CrossProcessCursor by delegate { +) : CursorWrapper(delegate), CrossProcessCursor { // We have to start the span only the first time, regardless of how many times its methods get // called. private var isSpanStarted = false @@ -36,6 +36,8 @@ internal class SentryCrossProcessCursor( return spans.performSql(sql) { delegate.onMove(oldPosition, newPosition) } } + override fun getWindow(): CursorWindow? = delegate.window + override fun fillWindow(position: Int, window: CursorWindow?) { if (isSpanStarted) { return delegate.fillWindow(position, window) diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt index 4a616ba3abe..28b661cd3e7 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -3,9 +3,8 @@ package io.sentry.sqlite import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteDriver import io.sentry.ScopesAdapter -import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel -import org.jetbrains.annotations.ApiStatus +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion /** * Wraps a [SQLiteDriver] and automatically adds spans for each SQL statement it executes. @@ -23,16 +22,15 @@ import org.jetbrains.annotations.ApiStatus * ``` * * If you're using the Sentry Android Gradle Plugin (SAGP) 6.13.0+, wrapping will be performed - * automatically. + * automatically for Room. * * @param delegate The [SQLiteDriver] instance to delegate calls to. */ -@ApiStatus.Experimental public class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) : SQLiteDriver { init { - SentryIntegrationPackageStorage.getInstance().addIntegration("SQLiteDriver") + addIntegrationToSdkVersion("SQLiteDriver") } @Suppress("INAPPLICABLE_JVM_NAME") diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt index 27eff29c9f3..ba77b2398c7 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt @@ -1,6 +1,7 @@ package io.sentry.android.sqlite import android.database.CrossProcessCursor +import android.database.CursorWrapper import io.sentry.IScopes import io.sentry.ISpan import io.sentry.SentryOptions @@ -52,13 +53,14 @@ class SentryCrossProcessCursorTest { cursor.fillWindow(0, mock()) verify(fixture.mockCursor).fillWindow(eq(0), any()) + } - // Let's verify other methods are delegated, even if not explicitly - cursor.close() - verify(fixture.mockCursor).close() + @Test + fun `ordinary cursor methods are delegated by Android CursorWrapper`() { + val getStringMethod = + SentryCrossProcessCursor::class.java.getMethod("getString", Int::class.javaPrimitiveType!!) - cursor.getString(1) - verify(fixture.mockCursor).getString(eq(1)) + assertEquals(CursorWrapper::class.java, getStringMethod.declaringClass) } @Test diff --git a/sentry-android-timber/build.gradle.kts b/sentry-android-timber/build.gradle.kts index d8f8431bef1..3c8ac1ea1e4 100644 --- a/sentry-android-timber/build.gradle.kts +++ b/sentry-android-timber/build.gradle.kts @@ -1,4 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") @@ -30,10 +32,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt b/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt index 9c87c8a461d..61b1f99fb16 100644 --- a/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt +++ b/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt @@ -248,8 +248,9 @@ public class SentryTimberTree( ) { // checks the log level if (isLoggable(sentryLogLevel, minLogLevel)) { - val attributes = - tag?.let { SentryAttributes.of(SentryAttribute.stringAttribute("timber.tag", tag)) } + val attributes = tag?.let { + SentryAttributes.of(SentryAttribute.stringAttribute("timber.tag", tag)) + } val params = SentryLogParameters.create(attributes) params.origin = "auto.log.timber" diff --git a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt index 43a45da7bb3..7c21eca8ef0 100644 --- a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt +++ b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt @@ -1,10 +1,14 @@ package io.sentry.android.timber import io.sentry.IScopes +import io.sentry.ITransportFactory +import io.sentry.ScopesAdapter +import io.sentry.Sentry import io.sentry.SentryLevel import io.sentry.SentryLogLevel import io.sentry.SentryOptions import io.sentry.protocol.SdkVersion +import io.sentry.transport.ITransport import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -12,6 +16,7 @@ import kotlin.test.assertTrue import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import timber.log.Timber class SentryTimberIntegrationTest { @@ -112,4 +117,43 @@ class SentryTimberIntegrationTest { assertTrue(fixture.options.sdkVersion!!.integrationSet.contains("Timber")) } + + @Test + fun `a beforeSend callback that logs via Timber does not recurse`() { + // End-to-end guard against SDK-CRASHES-JAVA-3T3H style recursion: with a real Sentry instance, + // a beforeSend callback that logs through the planted SentryTimberTree must not loop back into + // capture forever. + val transport = mock() + val transportFactory = mock() + whenever(transportFactory.create(any(), any())).thenReturn(transport) + + var beforeSendInvocations = 0 + Sentry.init { options -> + options.dsn = "https://key@sentry.io/123" + options.setTransportFactory(transportFactory) + options.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> + beforeSendInvocations++ + Timber.e("logging from beforeSend") + event + } + } + Timber.plant( + SentryTimberTree( + ScopesAdapter.getInstance(), + SentryLevel.ERROR, + SentryLevel.INFO, + SentryLogLevel.INFO, + ) + ) + + try { + Timber.e("outer error") + + // Without the core re-entrancy guard this recurses until a StackOverflowError. The nested + // Timber.e is dropped before its own beforeSend, so the callback runs exactly once. + assertEquals(1, beforeSendInvocations) + } finally { + Sentry.close() + } + } } diff --git a/sentry-apache-http-client-5/build.gradle.kts b/sentry-apache-http-client-5/build.gradle.kts index 00916258b8f..7502fb6c4b8 100644 --- a/sentry-apache-http-client-5/build.gradle.kts +++ b/sentry-apache-http-client-5/build.gradle.kts @@ -1,18 +1,20 @@ import net.ltgt.gradle.errorprone.errorprone -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } -tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 +kotlin { + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index d70085e27bd..70f43d946ef 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -37,13 +37,8 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -tasks { check { dependsOn(animalsnifferMain) } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index abb7ccb760e..4f1276f0bf4 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -39,13 +39,8 @@ dependencies { testImplementation(libs.mockito.inline) testImplementation(libs.okhttp.mockwebserver) testImplementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0") - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -tasks { check { dependsOn(animalsnifferMain) } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index 0fc853886df..2da8d8b20c1 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -38,13 +38,8 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -tasks { check { dependsOn(animalsnifferMain) } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index 17093fe6a09..17454baa662 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt index d565fd9d51d..26a10352176 100644 --- a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt @@ -218,13 +218,12 @@ class JfrAsyncProfilerToSentryProfileConverterTest { assertTrue(frames.isNotEmpty()) // Find frames with complete information - val completeFrames = - frames.filter { frame -> - frame.function != null && - frame.module != null && - frame.lineno != null && - frame.filename != null - } + val completeFrames = frames.filter { frame -> + frame.function != null && + frame.module != null && + frame.lineno != null && + frame.filename != null + } assertTrue(completeFrames.isNotEmpty(), "Should have frames with complete information") } @@ -238,15 +237,15 @@ class JfrAsyncProfilerToSentryProfileConverterTest { val frames = sentryProfile.frames // Verify system packages are marked as not in-app - val systemFrames = - frames.filter { frame -> - frame.module?.let { - it.startsWith("java.") || it.startsWith("sun.") || it.startsWith("jdk.") - } ?: false - } + val systemFrames = frames.filter { frame -> + frame.module?.let { + it.startsWith("java.") || it.startsWith("sun.") || it.startsWith("jdk.") + } ?: false + } - val inappSentryFrames = - frames.filter { frame -> frame.module?.startsWith("io.sentry.") ?: false } + val inappSentryFrames = frames.filter { frame -> + frame.module?.startsWith("io.sentry.") ?: false + } val emptyModuleFrames = frames.filter { it.module.isNullOrEmpty() } diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index c45a431b1b3..8b835ba16fe 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -44,13 +44,13 @@ kotlin { } sourceSets { - val commonMain by getting { + getByName("commonMain") { compilerOptions { apiVersion.set(KotlinVersion.KOTLIN_1_9) languageVersion.set(KotlinVersion.KOTLIN_1_9) } } - val androidMain by getting { + getByName("androidMain") { dependencies { api(projects.sentry) api(projects.sentryAndroidNavigation) @@ -60,7 +60,7 @@ kotlin { implementation(libs.androidx.lifecycle.common.java8) } } - val androidUnitTest by getting { + getByName("androidUnitTest") { dependencies { implementation(libs.androidx.compose.ui.test.junit4) implementation(libs.androidx.navigation.compose) @@ -87,13 +87,15 @@ android { buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } - sourceSets["main"].apply { manifest.srcFile("src/androidMain/AndroidManifest.xml") } - buildTypes { getByName("debug") { consumerProguardFiles("proguard-rules.pro") } getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + testOptions { animationsDisabled = true unitTests.apply { diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt index 787c66b3b0b..3c8fb48c35a 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt @@ -55,13 +55,11 @@ public object SentryModifier { } // SemanticsModifierNode.isImportantForBounds() was added as an abstract method in - // compose-ui 1.11. Classes compiled against earlier versions lack this method in + // compose-ui 1.11. Classes compiled against earlier versions lack this method in // their bytecode, which causes AbstractMethodError when the accessibility tree is - // traversed on 1.11+ runtimes. We can't use the `override` keyword here because - // the method doesn't exist in the compile-time dependency (compose-ui 1.6.x), but - // the JVM satisfies the abstract-method requirement at runtime via signature - // matching. SentryTagModifierNode only stores a semantic tag and has no visual - // effect on layout, so it is not important for bounds. - @Suppress("unused") fun isImportantForBounds(): Boolean = false + // traversed on 1.11+ runtimes. + // Returning true to match the default behavior + // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt;l=69-83;drc=bd7809b4bc9205721c2f1bc681694dd348885849 + @Suppress("unused") fun isImportantForBounds(): Boolean = true } } diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts index 3c0667fd0d4..32db28fae8f 100644 --- a/sentry-graphql-22/build.gradle.kts +++ b/sentry-graphql-22/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-graphql-core/build.gradle.kts b/sentry-graphql-core/build.gradle.kts index 62635ded34e..34f71ab9cfb 100644 --- a/sentry-graphql-core/build.gradle.kts +++ b/sentry-graphql-core/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index 30000655079..d92dc52c6d7 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-jcache/build.gradle.kts b/sentry-jcache/build.gradle.kts index 1cc3b6e0e3d..b388f35881f 100644 --- a/sentry-jcache/build.gradle.kts +++ b/sentry-jcache/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-jdbc/build.gradle.kts b/sentry-jdbc/build.gradle.kts index 1e86048053e..e2a7f573138 100644 --- a/sentry-jdbc/build.gradle.kts +++ b/sentry-jdbc/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java b/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java index 6879723ebf9..4583dc9e6c1 100644 --- a/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java +++ b/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java @@ -131,7 +131,6 @@ public static DatabaseDetails parse(final @Nullable String databaseConnectionUrl String pathWithoutProperties = StringUtils.substringBefore(path, ";"); return new DatabaseDetails(dbSystem, pathWithoutProperties); } catch (Throwable t) { - System.out.println(t.getMessage()); // ignore } return new DatabaseDetails(dbSystem, null); diff --git a/sentry-jul/build.gradle.kts b/sentry-jul/build.gradle.kts index 66c46bcee21..2eec61eb171 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts index ef1ff252468..0d543bad270 100644 --- a/sentry-kafka/build.gradle.kts +++ b/sentry-kafka/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index 8c4312641a8..101761b2a82 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -32,13 +32,8 @@ dependencies { testImplementation(libs.kotlinx.coroutines) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.kotlin) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -tasks { check { dependsOn(animalsnifferMain) } } - tasks.withType().configureEach { // Target version of the generated JVM bytecode. It is used for type resolution. jvmTarget = JavaVersion.VERSION_1_8.toString() diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index 647563cc1d1..fefcdbfebaf 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -39,13 +39,8 @@ dependencies { testImplementation(libs.ktor.client.core) testImplementation(libs.ktor.client.java) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -tasks { check { dependsOn(animalsnifferMain) } } - buildConfig { useJavaOutput() packageName("io.sentry.ktorClient") diff --git a/sentry-launchdarkly-android/build.gradle.kts b/sentry-launchdarkly-android/build.gradle.kts index 427ec473676..f201c57b97d 100644 --- a/sentry-launchdarkly-android/build.gradle.kts +++ b/sentry-launchdarkly-android/build.gradle.kts @@ -27,6 +27,10 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } testOptions { diff --git a/sentry-launchdarkly-server/build.gradle.kts b/sentry-launchdarkly-server/build.gradle.kts index 370252c2154..95aba9faaf5 100644 --- a/sentry-launchdarkly-server/build.gradle.kts +++ b/sentry-launchdarkly-server/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 1c5cf94e8eb..6e5250ece50 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java index df0f9eeb2d2..0218b53518d 100644 --- a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java +++ b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java @@ -41,7 +41,6 @@ import org.apache.logging.log4j.core.config.plugins.PluginAttribute; import org.apache.logging.log4j.core.config.plugins.PluginElement; import org.apache.logging.log4j.core.config.plugins.PluginFactory; -import org.apache.logging.log4j.core.impl.ThrowableProxy; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -274,13 +273,12 @@ protected void captureLog(@NotNull LogEvent loggingEvent) { event.setLogger(loggingEvent.getLoggerName()); event.setLevel(formatLevel(loggingEvent.getLevel())); - final ThrowableProxy throwableInformation = loggingEvent.getThrownProxy(); - if (throwableInformation != null) { + final @Nullable Throwable thrown = loggingEvent.getThrown(); + if (thrown != null) { final Mechanism mechanism = new Mechanism(); mechanism.setType(MECHANISM_TYPE); final Throwable mechanismException = - new ExceptionMechanismException( - mechanism, throwableInformation.getThrowable(), Thread.currentThread()); + new ExceptionMechanismException(mechanism, thrown, Thread.currentThread()); event.setThrowable(mechanismException); } diff --git a/sentry-logback/build.gradle.kts b/sentry-logback/build.gradle.kts index 1c42a4e1c03..5fd6c975231 100644 --- a/sentry-logback/build.gradle.kts +++ b/sentry-logback/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt index e93d6ef2db1..877d2a23d75 100644 --- a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt +++ b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt @@ -121,7 +121,9 @@ class SentryAppenderTest { Fixture( startLater = true, options = - SentryOptions().also { it.setTag("only-present-if-logger-init-was-run", "another-value") }, + SentryOptions().also { + it.setTag("only-present-if-logger-init-was-run", "another-value") + }, ) initForTest { it.dsn = "http://key@localhost/proj" diff --git a/sentry-okhttp/build.gradle.kts b/sentry-okhttp/build.gradle.kts index d547720c174..47b8bfe5b15 100644 --- a/sentry-okhttp/build.gradle.kts +++ b/sentry-okhttp/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -38,13 +38,8 @@ dependencies { testImplementation(libs.mockito.inline) testImplementation(libs.okhttp) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -tasks { check { dependsOn(animalsnifferMain) } } - buildConfig { useJavaOutput() packageName("io.sentry.okhttp") diff --git a/sentry-openfeature/build.gradle.kts b/sentry-openfeature/build.gradle.kts index fbabcb81aa5..b079ead1fc5 100644 --- a/sentry-openfeature/build.gradle.kts +++ b/sentry-openfeature/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-openfeign/build.gradle.kts b/sentry-openfeign/build.gradle.kts index 9b1ac2bbc29..3baa85dee26 100644 --- a/sentry-openfeign/build.gradle.kts +++ b/sentry-openfeign/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt index 571a2339326..c25a81f9501 100644 --- a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt +++ b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt @@ -286,11 +286,10 @@ class SentryFeignClientTest { @Test fun `customizer modifies span`() { - val sut = - fixture.getSut { span, _, _ -> - span.description = "overwritten description" - span - } + val sut = fixture.getSut { span, _, _ -> + span.description = "overwritten description" + span + } sut.getOk() assertEquals(1, fixture.sentryTracer.children.size) val httpClientSpan = fixture.sentryTracer.children.first() @@ -299,13 +298,12 @@ class SentryFeignClientTest { @Test fun `customizer receives request and response`() { - val sut = - fixture.getSut { span, request, response -> - assertEquals(request.url(), request.url()) - assertEquals(request.httpMethod().name, request.httpMethod().name) - assertNotNull(response) { assertEquals(201, it.status()) } - span - } + val sut = fixture.getSut { span, request, response -> + assertEquals(request.url(), request.url()) + assertEquals(request.httpMethod().name, request.httpMethod().name) + assertNotNull(response) { assertEquals(201, it.status()) } + span + } sut.getOk() } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts index ef98d488bd1..054db790dc2 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts @@ -2,6 +2,7 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.shadow) } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts index 71f31ce2afb..087568d03ee 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts index c02ca0ca468..5a94dcd4422 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } dependencies { diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts index 43e87d53beb..72508d73737 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } dependencies { diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api index ba43abdbec7..1f81e4324d4 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api @@ -85,6 +85,7 @@ public final class io/sentry/opentelemetry/OtelStrongRefSpanWrapper : io/sentry/ public fun startChild (Lio/sentry/SpanContext;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts index d4bd1af9ede..3585aa40d4a 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) @@ -27,6 +28,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java index 995fe7f787b..907d71a278b 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java @@ -125,6 +125,12 @@ public void setTransactionName(@NotNull String name, @NotNull TransactionNameSou return delegate.startChild(spanContext, spanOptions); } + @Override + public @NotNull ISpan startChild( + @NotNull String operation, @Nullable String description, @Nullable SentryDate timestamp) { + return delegate.startChild(operation, description, timestamp); + } + @Override public @NotNull ISpan startChild( @NotNull String operation, diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/test/kotlin/io/sentry/opentelemetry/OtelStrongRefSpanWrapperTest.kt b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/test/kotlin/io/sentry/opentelemetry/OtelStrongRefSpanWrapperTest.kt new file mode 100644 index 00000000000..c8547d8220f --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/test/kotlin/io/sentry/opentelemetry/OtelStrongRefSpanWrapperTest.kt @@ -0,0 +1,26 @@ +package io.sentry.opentelemetry + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.api.trace.Span +import io.sentry.ISpan +import io.sentry.SentryLongDate +import kotlin.test.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class OtelStrongRefSpanWrapperTest { + @Test + fun `startChild with timestamp forwards to delegate`() { + val delegate = mock() + val wrapper = OtelStrongRefSpanWrapper(mock(), delegate) + val timestamp = SentryLongDate(1234) + val expectedChild = mock() + whenever(delegate.startChild("child-op", "description", timestamp)).thenReturn(expectedChild) + + val child = wrapper.startChild("child-op", "description", timestamp) + + verify(delegate).startChild("child-op", "description", timestamp) + assertThat(child).isSameInstanceAs(expectedChild) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api index 847d69bca1b..3ed25d1a9cf 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api +++ b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api @@ -99,6 +99,7 @@ public final class io/sentry/opentelemetry/OtelSpanWrapper : io/sentry/opentelem public fun startChild (Lio/sentry/SpanContext;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts index 91ec023e178..a252628c1a2 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) @@ -37,6 +38,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java index a72084ad67f..80da51f9db7 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java @@ -141,6 +141,12 @@ public OtelSpanWrapper( return childSpan; } + @Override + public @NotNull ISpan startChild( + @NotNull String operation, @Nullable String description, @Nullable SentryDate timestamp) { + return startChild(operation, description, timestamp, Instrumenter.SENTRY); + } + @Override public @NotNull ISpan startChild( @NotNull String operation, diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSpanWrapperTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSpanWrapperTest.kt new file mode 100644 index 00000000000..98d3989aaaf --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSpanWrapperTest.kt @@ -0,0 +1,60 @@ +package io.sentry.opentelemetry + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.TraceFlags +import io.opentelemetry.api.trace.TraceState +import io.opentelemetry.sdk.trace.ReadWriteSpan +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.ISpanFactory +import io.sentry.Instrumenter +import io.sentry.SentryLongDate +import io.sentry.SentryOptions +import io.sentry.SpanOptions +import kotlin.test.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class OtelSpanWrapperTest { + @Test + fun `startChild with timestamp forwards timestamp and Sentry instrumenter`() { + val otelSpan = mock() + whenever(otelSpan.spanContext) + .thenReturn( + SpanContext.create( + "2722d9f6ec019ade60c776169d9a8904", + "cedf5b7571cb4972", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + ) + whenever(otelSpan.name).thenReturn("parent") + + val spanFactory = mock() + val options = SentryOptions().apply { this.spanFactory = spanFactory } + val scopes = mock() + whenever(scopes.options).thenReturn(options) + + val parent = OtelSpanWrapper(otelSpan, scopes, SentryLongDate(0), null, null, null, null) + val expectedChild = mock() + whenever(spanFactory.createSpan(eq(scopes), any(), any(), eq(parent))).thenReturn(expectedChild) + val timestamp = SentryLongDate(1234) + + val child = parent.startChild("child-op", "description", timestamp) + + val spanOptions = argumentCaptor() + val spanContext = argumentCaptor() + verify(spanFactory) + .createSpan(eq(scopes), spanOptions.capture(), spanContext.capture(), eq(parent)) + assertThat(child).isSameInstanceAs(expectedChild) + assertThat(spanOptions.firstValue.startTimestamp).isSameInstanceAs(timestamp) + assertThat(spanContext.firstValue.operation).isEqualTo("child-op") + assertThat(spanContext.firstValue.description).isEqualTo("description") + assertThat(spanContext.firstValue.instrumenter).isEqualTo(Instrumenter.SENTRY) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts index 1ff16cd0a31..8a5093a6570 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts @@ -1,5 +1,6 @@ plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") } diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index d63c8a5c451..ec240c681ae 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) @@ -20,7 +21,7 @@ dependencies { api(libs.otel.extension.autoconfigure) api(libs.otel.exporter.otlp) compileOnly(libs.otel.extension.autoconfigure.spi) - // compileOnly(libs.otel.semconv) + implementation(libs.otel.semconv) // compileOnly(libs.otel.semconv.incubating) compileOnly(libs.jetbrains.annotations) diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java index a4249b27ec0..4cdf1b0ed09 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java @@ -2,6 +2,7 @@ import static io.sentry.SentryTraceHeader.SENTRY_TRACE_HEADER; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.api.trace.TraceFlags; @@ -11,14 +12,20 @@ import io.opentelemetry.context.propagation.TextMapGetter; import io.opentelemetry.context.propagation.TextMapPropagator; import io.opentelemetry.context.propagation.TextMapSetter; +import io.opentelemetry.sdk.trace.ReadableSpan; +import io.opentelemetry.semconv.ServerAttributes; +import io.opentelemetry.semconv.UrlAttributes; import io.sentry.Baggage; import io.sentry.BaggageHeader; import io.sentry.IScopes; import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.SentryTraceHeader; import io.sentry.exception.InvalidSentryTraceHeaderException; +import io.sentry.util.PropagationTargetsUtils; import io.sentry.util.TracingUtils; +import java.net.URL; import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -61,6 +68,10 @@ public void inject(final Context context, final C carrier, final TextMapSett return; } + if (!shouldInjectTracingHeaders(otelSpan)) { + return; + } + setter.set( carrier, SENTRY_TRACE_HEADER, @@ -76,6 +87,50 @@ public void inject(final Context context, final C carrier, final TextMapSett } } + private boolean shouldInjectTracingHeaders(final @NotNull Span otelSpan) { + final @NotNull SentryOptions options = scopes.getOptions(); + final @Nullable String url = extractUrl(otelSpan, options); + + return url == null + || PropagationTargetsUtils.contain(options.getTracePropagationTargets(), url); + } + + private @Nullable String extractUrl( + final @NotNull Span otelSpan, final @NotNull SentryOptions options) { + if (!(otelSpan instanceof ReadableSpan)) { + return null; + } + + final @NotNull Attributes attributes = ((ReadableSpan) otelSpan).getAttributes(); + final @Nullable String urlFull = attributes.get(UrlAttributes.URL_FULL); + if (urlFull != null) { + return urlFull; + } + + final @Nullable String scheme = attributes.get(UrlAttributes.URL_SCHEME); + final @Nullable String serverAddress = attributes.get(ServerAttributes.SERVER_ADDRESS); + final @Nullable Long serverPort = attributes.get(ServerAttributes.SERVER_PORT); + final @Nullable String path = attributes.get(UrlAttributes.URL_PATH); + + if (scheme == null || serverAddress == null) { + return null; + } + + try { + final @NotNull String pathToUse = path == null ? "" : path; + if (serverPort == null) { + return new URL(scheme, serverAddress, pathToUse).toString(); + } else { + return new URL(scheme, serverAddress, serverPort.intValue(), pathToUse).toString(); + } + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.WARNING, "Unable to combine URL span attributes into one.", t); + return null; + } + } + @Override public Context extract( final Context context, final C carrier, final TextMapGetter getter) { diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt index 1d5d56c5bff..afa728c7ff0 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt @@ -7,6 +7,7 @@ import io.opentelemetry.api.trace.TraceState import io.opentelemetry.context.Context import io.opentelemetry.context.propagation.TextMapGetter import io.opentelemetry.context.propagation.TextMapSetter +import io.opentelemetry.sdk.trace.SdkTracerProvider import io.sentry.Baggage import io.sentry.Sentry import kotlin.test.AfterTest @@ -171,6 +172,97 @@ class OpenTelemetryOtlpPropagatorTest { ) } + @Test + fun `injects headers if URL in span attributes matches tracePropagationTargets`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("sentry.io")) + } + val propagator = OpenTelemetryOtlpPropagator() + val carrier = mutableMapOf() + val tracerProvider = SdkTracerProvider.builder().build() + val otelSpan = + tracerProvider + .get("test") + .spanBuilder("test") + .setAttribute("url.full", "https://sentry.io/api/0/") + .startSpan() + val baggage = + Baggage.fromHeader( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + + try { + val context = + Context.root().with(otelSpan).with(OpenTelemetryOtlpPropagator.SENTRY_BAGGAGE_KEY, baggage) + + propagator.inject(context, carrier, MapSetter()) + } finally { + otelSpan.end() + tracerProvider.shutdown() + } + + assertEquals( + "${otelSpan.spanContext.traceId}-${otelSpan.spanContext.spanId}-1", + carrier["sentry-trace"], + ) + assertEquals( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + carrier["baggage"], + ) + } + + @Test + fun `does not inject headers if URL in span attributes does not match tracePropagationTargets`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("github.com")) + } + val propagator = OpenTelemetryOtlpPropagator() + val carrier = mutableMapOf() + val tracerProvider = SdkTracerProvider.builder().build() + val otelSpan = + tracerProvider + .get("test") + .spanBuilder("test") + .setAttribute("url.full", "https://sentry.io/api/0/") + .startSpan() + + try { + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + } finally { + otelSpan.end() + tracerProvider.shutdown() + } + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `injects headers if tracePropagationTargets is restricted and URL is unavailable`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("sentry.io")) + } + val propagator = OpenTelemetryOtlpPropagator() + val carrier = mutableMapOf() + + val otelSpanContext = + SpanContext.create( + "f9118105af4a2d42b4124532cd1065ff", + "424cffc8f94feeee", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + val otelSpan = Span.wrap(otelSpanContext) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + @Test fun `does not inject headers when no span in context`() { val propagator = OpenTelemetryOtlpPropagator() diff --git a/sentry-quartz/build.gradle.kts b/sentry-quartz/build.gradle.kts index 6e227abafe6..f4f0d9d07d2 100644 --- a/sentry-quartz/build.gradle.kts +++ b/sentry-quartz/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index f994081450d..31009f6dbb9 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -114,6 +114,9 @@ android { // Suffix the id so debug and release builds can be installed side by side. applicationIdSuffix = ".debug" addManifestPlaceholders(mapOf("sentryDebug" to true, "sentryEnvironment" to "debug")) + // The SDK modules only publish a release variant, so fall back to it for the + // debug build of the sample. + matchingFallbacks += "release" } getByName("release") { isMinifyEnabled = true @@ -133,10 +136,6 @@ android { kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 } - androidComponents.beforeVariants { - it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) - } - androidComponents.onVariants { variant -> variant.buildConfigFields?.put( "USE_SAGP", diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 61f4df5b8d9..ac53c538de5 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -90,7 +90,8 @@ + android:exported="false" + android:theme="@style/AppTheme.Main" /> + + + + android:value="false" /> @@ -266,7 +272,7 @@ android:value="canvas" /> + android:value="1.0" /> diff --git a/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp b/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp index abac2bf58fe..6b9e6e89d87 100644 --- a/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp +++ b/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp @@ -1,15 +1,23 @@ #include #include #include -#include #define TAG "sentry-sample" extern "C" { +// Faults inside this named function so the crashing frame resolves to a real +// symbol + source line. A bare raise(SIGSEGV) would instead fault in libc and, +// for a JNI-originated crash, not exercise app-native symbolication. +[[gnu::noinline]] +static void trigger_null_deref() { + volatile int *ptr = nullptr; + *ptr = 42; +} + JNIEXPORT void JNICALL Java_io_sentry_samples_android_NativeSample_crash(JNIEnv *env, jclass cls) { __android_log_print(ANDROID_LOG_WARN, TAG, "About to crash."); - raise(SIGSEGV); + trigger_null_deref(); } JNIEXPORT void JNICALL Java_io_sentry_samples_android_NativeSample_message(JNIEnv *env, jclass cls) { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index 90e75feee71..b38f17ed64c 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -805,21 +805,23 @@ fun UserFeedbackScreen() { } } - // Enable shake-to-show for a specific form instance + // Toggle shake-to-show at runtime using the global Sentry.feedback() API item(span = { GridItemSpan(maxLineSpan) }) { + var shakeEnabled by remember { mutableStateOf(Sentry.feedback().isOnShakeEnabled) } Button( modifier = Modifier, onClick = { - SentryUserFeedbackForm.Builder(activity) - .configurator { options -> - options.isUseShakeGesture = true - options.formTitle = "Shake Feedback" - } - .create() - Toast.makeText(activity, "Shake your device to open the form!", Toast.LENGTH_SHORT).show() + if (shakeEnabled) { + Sentry.feedback().disableOnShake() + } else { + Sentry.feedback().enableOnShake() + Toast.makeText(activity, "Shake your device to open the form!", Toast.LENGTH_SHORT) + .show() + } + shakeEnabled = Sentry.feedback().isOnShakeEnabled }, ) { - Text(text = "Enable Shake-to-Show") + Text(text = if (shakeEnabled) "Disable Shake-to-Show" else "Enable Shake-to-Show") } } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt index 8626c12c6c8..e24822b3e42 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt @@ -1,161 +1,108 @@ package io.sentry.samples.android +import android.os.Build import android.os.Bundle -import android.view.View -import android.widget.SeekBar import android.widget.Toast -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity -import androidx.recyclerview.widget.LinearLayoutManager -import io.sentry.ITransaction -import io.sentry.ProfilingTraceData +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import io.sentry.Sentry -import io.sentry.SentryEnvelopeItem -import io.sentry.samples.android.databinding.ActivityProfilingBinding -import java.io.ByteArrayOutputStream -import java.io.File -import java.util.UUID import java.util.concurrent.Executors -import java.util.zip.GZIPOutputStream -class ProfilingActivity : AppCompatActivity() { - private lateinit var binding: ActivityProfilingBinding +class ProfilingActivity : ComponentActivity() { + private val executors = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()) private var profileFinished = true override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (profileFinished) { - isEnabled = false - onBackPressedDispatcher.onBackPressed() - } else { - Toast.makeText(this@ProfilingActivity, R.string.profiling_running, Toast.LENGTH_SHORT) - .show() - } - } - }, - ) - binding = ActivityProfilingBinding.inflate(layoutInflater) - - binding.profilingDurationSeekbar.setOnSeekBarChangeListener( - object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(p0: SeekBar, p1: Int, p2: Boolean) { - binding.profilingDurationText.text = - getString(R.string.profiling_duration, getProfileDuration()) - } - - override fun onStartTrackingTouch(p0: SeekBar) {} - - override fun onStopTrackingTouch(p0: SeekBar) {} - } - ) - binding.profilingDurationText.text = - getString(R.string.profiling_duration, getProfileDuration()) - - binding.profilingThreadsSeekbar.setOnSeekBarChangeListener( - object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(p0: SeekBar, p1: Int, p2: Boolean) { - binding.profilingThreadsText.text = - getString(R.string.profiling_threads, getBackgroundThreads()) - } - - override fun onStartTrackingTouch(p0: SeekBar) {} - - override fun onStopTrackingTouch(p0: SeekBar) {} - } - ) - binding.profilingThreadsSeekbar.max = Runtime.getRuntime().availableProcessors() - 1 - binding.profilingThreadsText.text = - getString(R.string.profiling_threads, getBackgroundThreads()) - - binding.profilingList.adapter = ProfilingListAdapter() - binding.profilingList.layoutManager = LinearLayoutManager(this) - - binding.profilingStart.setOnClickListener { - binding.profilingProgressBar.visibility = View.VISIBLE - profileFinished = false - val seconds = getProfileDuration() - val threads = getBackgroundThreads() - val t = Sentry.startTransaction("Profiling Test", "$seconds s - $threads threads") - repeat(threads) { executors.submit { runMathOperations() } } - executors.submit { swipeList() } - - Thread { - Thread.sleep((seconds * 1000).toLong()) - finishTransactionAndPrintResults(t) - binding.root.post { binding.profilingProgressBar.visibility = View.GONE } - } - .start() - } - setContentView(binding.root) - Sentry.reportFullyDisplayed() + setContent { MaterialTheme { ProfilingScreen() } } } - private fun finishTransactionAndPrintResults(t: ITransaction) { - t.finish() - profileFinished = true - val profilesDirPath = Sentry.getCurrentScopes().options.profilingTracesDirPath - if (profilesDirPath == null) { - Toast.makeText(this, R.string.profiling_no_dir_set, Toast.LENGTH_SHORT).show() - return - } - - // We have concurrent profiling now. We have to wait for all transactions to finish (e.g. button - // click) - // before reading the profile, otherwise it's empty and a crash occurs - if (Sentry.getSpan() != null) { - val timeout = Sentry.getCurrentScopes().options.idleTimeout ?: 0 - val duration = (getProfileDuration() * 1000).toLong() - Thread.sleep((timeout - duration).coerceAtLeast(0)) - } - - try { - // Get the last trace file, which is the current profile - val origProfileFile = File(profilesDirPath).listFiles()?.maxByOrNull { f -> f.lastModified() } - // Create a new profile file and copy the content of the original file into it - val profile = File(cacheDir, UUID.randomUUID().toString()) - origProfileFile?.copyTo(profile) - - val profileLength = profile.length() - val traceData = ProfilingTraceData(profile, t) - // Create envelope item from copied profile - val item = - SentryEnvelopeItem.fromProfilingTrace( - traceData, - Long.MAX_VALUE, - Sentry.getCurrentScopes().options.serializer, - ) - val itemData = item.data - - // Compress the envelope item using Gzip - val bos = ByteArrayOutputStream() - GZIPOutputStream(bos).bufferedWriter().use { it.write(String(itemData)) } - - binding.root.post { - binding.profilingResult.text = - getString(R.string.profiling_result, profileLength, itemData.size, bos.toByteArray().size) + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun ProfilingScreen() { + val context = LocalContext.current + val options = remember { Sentry.getCurrentScopes().options } + val isPerfetto = remember { Build.VERSION.SDK_INT >= 35 } + val isContinuousEnabled = remember { options.isContinuousProfilingEnabled } + + var showProgress by remember { mutableStateOf(false) } + var manualActive by remember { mutableStateOf(false) } + + val statusText = + when { + !isContinuousEnabled -> stringResource(R.string.profiling_status_none) + isPerfetto -> stringResource(R.string.profiling_status_perfetto) + else -> stringResource(R.string.profiling_status_legacy) } - } catch (e: Exception) { - e.printStackTrace() - } - } - private fun swipeList() { - while (!profileFinished) { - if ( - (binding.profilingList.layoutManager as? LinearLayoutManager) - ?.findFirstVisibleItemPosition() == 0 + Scaffold(topBar = { TopAppBar(title = { Text("Profiling") }) }) { innerPadding -> + Column( + modifier = Modifier.fillMaxSize().padding(innerPadding).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - binding.profilingList.smoothScrollToPosition(100) - } else { - binding.profilingList.smoothScrollToPosition(0) + Text(text = statusText, fontWeight = FontWeight.Bold) + + Text("profiling.enable-legacy-profiling: ${options.isEnableLegacyProfiling}") + Text("Build.VERSION.SDK_INT: ${Build.VERSION.SDK_INT}") + Text("traces.profiling.session-sample-rate: ${options.profileSessionSampleRate}") + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + Button( + onClick = { + if (!manualActive) { + Sentry.startProfiler() + manualActive = true + profileFinished = false + showProgress = true + + val threads = 2 + repeat(threads) { executors.submit { runMathOperations() } } + + Toast.makeText(context, R.string.profiling_manual_started, Toast.LENGTH_SHORT).show() + } else { + Sentry.stopProfiler() + manualActive = false + profileFinished = true + showProgress = false + + Toast.makeText(context, R.string.profiling_manual_stopped, Toast.LENGTH_SHORT).show() + } + } + ) { + Text( + if (manualActive) stringResource(R.string.profiling_stop_manual) + else stringResource(R.string.profiling_start_manual) + ) + } + + if (showProgress) { + CircularProgressIndicator() + } } - Thread.sleep(3000) } } @@ -167,21 +114,8 @@ class ProfilingActivity : AppCompatActivity() { private fun fibonacci(n: Int): Int = when { - profileFinished -> n // If we destroy the activity we stop this function + profileFinished -> n n <= 1 -> 1 else -> fibonacci(n - 1) + fibonacci(n - 2) } - - private fun getProfileDuration(): Float { - // Minimum duration of the profile is 100 milliseconds - return binding.profilingDurationSeekbar.progress / 10.0F + 0.1F - } - - private fun getBackgroundThreads(): Int { - // Minimum duration of the profile is 100 milliseconds - return binding.profilingThreadsSeekbar.progress.coerceIn( - 0, - Runtime.getRuntime().availableProcessors() - 1, - ) - } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt deleted file mode 100644 index bf025118c80..00000000000 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.sentry.samples.android - -import android.graphics.Bitmap -import android.graphics.Color -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.ImageView -import androidx.recyclerview.widget.RecyclerView -import io.sentry.samples.android.databinding.ProfilingItemListBinding -import kotlin.random.Random - -class ProfilingListAdapter : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val binding = - ProfilingItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ViewHolder(binding) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.imageView.setImageBitmap(generateBitmap()) - } - - @Suppress("MagicNumber") - private fun generateBitmap(): Bitmap { - val bitmapSize = 128 - val colors = - (0 until (bitmapSize * bitmapSize)) - .map { Color.rgb(Random.nextInt(256), Random.nextInt(256), Random.nextInt(256)) } - .toIntArray() - return Bitmap.createBitmap(colors, bitmapSize, bitmapSize, Bitmap.Config.ARGB_8888) - } - - // Disables view recycling. - override fun getItemViewType(position: Int): Int = position - - override fun getItemCount(): Int = 200 -} - -class ViewHolder(binding: ProfilingItemListBinding) : RecyclerView.ViewHolder(binding.root) { - val imageView: ImageView = binding.benchmarkItemListImage -} diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_profiling.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_profiling.xml deleted file mode 100644 index 8100834f78b..00000000000 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_profiling.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - -