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/.craft.yml b/.craft.yml index cc4636cd32d..bee668917c1 100644 --- a/.craft.yml +++ b/.craft.yml @@ -45,6 +45,8 @@ targets: maven:io.sentry:sentry-launchdarkly-android: maven:io.sentry:sentry-launchdarkly-server: maven:io.sentry:sentry-opentelemetry-agent: + # TODO: Add after first release of the artifact. + # maven:io.sentry:sentry-opentelemetry-bom: maven:io.sentry:sentry-opentelemetry-agentcustomization: maven:io.sentry:sentry-opentelemetry-agentless: maven:io.sentry:sentry-opentelemetry-agentless-spring: diff --git a/.cursor/rules/coding.mdc b/.cursor/rules/coding.mdc deleted file mode 100644 index e7af7273f15..00000000000 --- a/.cursor/rules/coding.mdc +++ /dev/null @@ -1,53 +0,0 @@ ---- -alwaysApply: true -description: Cursor Coding Rules ---- - -# Contributing Rules for Agents - -## Overview - -sentry-java is the Java and Android SDK for Sentry. This repository contains the source code and examples for SDK usage. - -## Tech Stack - -- **Language**: Java and Kotlin -- **Build Framework**: Gradle - -## Key Commands - -```bash -# Format code and regenerate .api files -./gradlew spotlessApply apiDump - -# Run all tests and linter -./gradlew check - -# Run unit tests for a specific file -./gradle '::testDebugUnitTest' --tests="**" --info -``` - -## Contributing Guidelines - -1. Follow existing code style and language -2. Do not modify the API files (e.g. sentry.api) manually, instead run `./gradlew apiDump` to regenerate them -3. Write comprehensive tests -4. New features should always be opt-in by default, extend `SentryOptions` or similar Option classes with getters and setters to enable/disable a new feature -5. Consider backwards compatibility - -## Coding rules - -1. First think through the problem, read the codebase for relevant files, and propose a plan -2. Before you begin working, check in with me and I will verify the plan -3. Then, begin working on the todo items, marking them as complete as you go -4. Please do not describe every step of the way and just give me a high level explanation of what changes you made -5. Make every task and code change you do as simple as possible. We want to avoid making any massive or complex changes. Every change should impact as little code as possible. Everything is about simplicity. -6. Once you're done, format the code and regenerate the .api files using the following command `./gradlew spotlessApply apiDump` -7. As a last step, git stage the relevant files and propose (but not execute) a single git commit command (e.g. `git commit -m ""`) - - -## Useful Resources - -- Main SDK documentation: https://develop.sentry.dev/sdk/overview/ -- Internal contributing guide: https://docs.sentry.io/internal/contributing/ -- Git commit messages conventions: https://develop.sentry.dev/engineering-practices/commit-messages/ diff --git a/.cursor/rules/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 e15c0a0a563..00000000000 --- a/.cursor/rules/pr.mdc +++ /dev/null @@ -1,262 +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/)) -``` - -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 b337ac9ea4e..baa2dad44a2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,5 @@ ## :scroll: Description - + ## :bulb: Motivation and Context @@ -12,6 +12,10 @@ --> ## :green_heart: How did you test it? + ## :pencil: Checklist @@ -25,6 +29,7 @@ - [ ] Review from the native team if needed. - [ ] No breaking change or entry added to the changelog. - [ ] No breaking change for hybrid SDKs or communicated to hybrid SDKs. +- [ ] Public API changes reviewed by another Mobile SDK team member or implemented according to the [develop docs](https://develop.sentry.dev/) spec. ## :crystal_ball: Next steps diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index aebcbf87d5e..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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -50,7 +50,7 @@ jobs: sudo udevadm trigger --name-match=kvm - name: AVD cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: avd-cache with: path: | @@ -60,7 +60,7 @@ jobs: - name: Create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # pin@v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 with: api-level: 30 target: aosp_atd @@ -79,7 +79,7 @@ jobs: # We tried to use the cache action to cache gradle stuff, but it made tests slower and timeout - name: Run instrumentation tests - uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # pin@v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 with: api-level: 30 target: aosp_atd @@ -112,10 +112,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: build/outputs/androidTest-results/**/*.xml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2d9e2a3ba38..ef9aa7cfc36 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,51 +19,46 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Run Tests with coverage and Lint + - name: Run Tests and Lint run: make preMerge - name: 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 }} SENTRY_ORG: sentry-sdks SENTRY_PROJECT: sentry-android - - name: Upload coverage to Codecov - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v4 - with: - name: sentry-java - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} - - name: Upload test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml deleted file mode 100644 index 23daafa1a05..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@3e6a0f477702864bb5854384b390a0db3325428e # v2 - secrets: inherit diff --git a/.github/workflows/changes-in-high-risk-code.yml b/.github/workflows/changes-in-high-risk-code.yml index 028b4217ef2..44d65924209 100644 --- a/.github/workflows/changes-in-high-risk-code.yml +++ b/.github/workflows/changes-in-high-risk-code.yml @@ -16,10 +16,10 @@ jobs: high_risk_code: ${{ steps.changes.outputs.high_risk_code }} high_risk_code_files: ${{ steps.changes.outputs.high_risk_code_files }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get changed files id: changes - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 with: token: ${{ github.token }} filters: .github/file-filters.yml diff --git a/.github/workflows/check-tombstone-proto-schema.yml b/.github/workflows/check-tombstone-proto-schema.yml index 535b2170fae..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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - 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 1276f2bd715..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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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 01ee3db1584..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@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + 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 28cb78df4e3..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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.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 af0b44ddadd..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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.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 65cfcf242fc..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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.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,22 +77,22 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: app-plain-cache with: path: sentry-android-integration-tests/test-app-plain/build/outputs/apk/release/test-app-plain-release.apk diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index 19598699165..3e76c951f86 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -20,23 +20,23 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Java Version - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: "temurin" java-version: "17" # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.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 8973148cadd..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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Enable KVM run: | @@ -85,18 +94,36 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + # The runner ships an outdated avdmanager that writes target=android-0 into the + # AVD config for minor-versioned packages (android-37.x), so the emulator clamps + # to API 3 and boots misconfigured. Update cmdline-tools so avdmanager parses it. + # See https://github.com/ReactiveCircus/android-emulator-runner/issues/482 + - name: Update SDK cmdline-tools + id: cmdline-tools + run: | + SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" + yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --install "cmdline-tools;latest" > /dev/null + # sdkmanager won't overwrite the preinstalled dir, so it installs to latest-2. + if [ -d "$SDK/cmdline-tools/latest-2" ]; then + rm -rf "$SDK/cmdline-tools/latest" + mv "$SDK/cmdline-tools/latest-2" "$SDK/cmdline-tools/latest" + fi + echo "version=$("$SDK/cmdline-tools/latest/bin/sdkmanager" --version 2>/dev/null | grep -Eo '^[0-9][0-9.]*' | head -1)" >> "$GITHUB_OUTPUT" + - name: AVD cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: avd-cache with: path: | ~/.android/avd/* ~/.android/adb* - key: avd-api-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }} + # Keyed on memory and the cmdline-tools version so incompatible snapshots + # and AVDs created by the old, broken avdmanager are invalidated automatically. + key: avd-api-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }}-memory${{ matrix.memory }}-tools${{ steps.cmdline-tools.outputs.version }} - name: Create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # pin@v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 with: api-level: ${{ matrix.api-level }} target: ${{ matrix.target }} @@ -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." @@ -120,7 +147,7 @@ jobs: version: ${{env.MAESTRO_VERSION}} - name: Run tests - uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # pin@v2.37.0 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2.38.0 with: api-level: ${{ matrix.api-level }} target: ${{ matrix.target }} @@ -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 4af564cd2c3..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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.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,17 +75,18 @@ 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 - if: ${{ !cancelled() && env.SAUCE_USERNAME != null }} + # Skip on PRs from forks, which don't have access to the upload secret + if: ${{ !cancelled() && env.SAUCE_USERNAME != null && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} run: | shopt -s globstar nullglob pngs=(artifacts/**/*.png) if [ ${#pngs[@]} -gt 0 ]; then mkdir -p replay-snapshots cp "${pngs[@]}" replay-snapshots/ - sentry-cli build snapshots ./replay-snapshots \ + sentry-cli snapshots upload ./replay-snapshots \ --app-id sentry-android-replay else echo "No replay snapshot files found, skipping upload" @@ -94,10 +95,3 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: sentry-sdks SENTRY_PROJECT: sentry-android - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: ./artifacts/*.xml diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 16cfe4531a0..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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.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 88cac7c6754..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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - 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@3e6a0f477702864bb5854384b390a0db3325428e # 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 bbcb3cfc0bc..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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' @@ -45,20 +45,20 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -72,88 +72,62 @@ jobs: perl -0pi -e 'BEGIN { $v = shift } s/^springboot2[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot2 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml echo "Updated Spring Boot 2.x version to $springboot_version" - - name: Exclude android modules from build + - name: Build sample artifacts run: | - sed -i \ - -e '/.*"sentry-android-ndk",/d' \ - -e '/.*"sentry-android",/d' \ - -e '/.*"sentry-compose",/d' \ - -e '/.*"sentry-android-core",/d' \ - -e '/.*"sentry-android-fragment",/d' \ - -e '/.*"sentry-android-navigation",/d' \ - -e '/.*"sentry-android-sqlite",/d' \ - -e '/.*"sentry-android-timber",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-size",/d' \ - -e '/.*"sentry-samples:sentry-samples-android",/d' \ - -e '/.*"sentry-android-replay",/d' \ - settings.gradle.kts - - - name: Exclude android modules from ignore list - run: | - sed -i \ - -e '/.*"sentry-uitest-android",/d' \ - -e '/.*"sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-uitest-android-critical",/d' \ - -e '/.*"test-app-sentry",/d' \ - -e '/.*"test-app-size",/d' \ - -e '/.*"sentry-samples-android",/d' \ - build.gradle.kts - - - name: Build SDK - run: | - ./gradlew assemble --parallel + ./gradlew \ + :sentry-samples:sentry-samples-spring-boot:shadowJar \ + :sentry-samples:sentry-samples-spring-boot:testClasses \ + :sentry-samples:sentry-samples-spring-boot-webflux:shadowJar \ + :sentry-samples:sentry-samples-spring-boot-webflux:testClasses \ + :sentry-samples:sentry-samples-spring-boot-opentelemetry:shadowJar \ + :sentry-samples:sentry-samples-spring-boot-opentelemetry:testClasses \ + :sentry-samples:sentry-samples-spring-boot-opentelemetry-noagent:shadowJar \ + :sentry-samples:sentry-samples-spring-boot-opentelemetry-noagent:testClasses \ + :sentry-samples:sentry-samples-spring:war \ + :sentry-samples:sentry-samples-spring:testClasses \ + :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - name: Test sentry-samples-spring-boot run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-webflux run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-webflux" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-opentelemetry agent init true run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-opentelemetry" \ --agent true \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-opentelemetry agent init false run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-opentelemetry" \ --agent true \ - --auto-init "false" \ - --build "true" + --auto-init "false" - name: Test sentry-samples-spring-boot-opentelemetry-noagent run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-opentelemetry-noagent" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Upload test results if: always() @@ -176,10 +150,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 781d8a876f9..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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' @@ -45,20 +45,20 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -68,88 +68,62 @@ jobs: perl -0pi -e 'BEGIN { $v = shift } s/^springboot3[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot3 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml echo "Updated Spring Boot 3.x version to $springboot_version" - - name: Exclude android modules from build + - name: Build sample artifacts run: | - sed -i \ - -e '/.*"sentry-android-ndk",/d' \ - -e '/.*"sentry-android",/d' \ - -e '/.*"sentry-compose",/d' \ - -e '/.*"sentry-android-core",/d' \ - -e '/.*"sentry-android-fragment",/d' \ - -e '/.*"sentry-android-navigation",/d' \ - -e '/.*"sentry-android-sqlite",/d' \ - -e '/.*"sentry-android-timber",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-size",/d' \ - -e '/.*"sentry-samples:sentry-samples-android",/d' \ - -e '/.*"sentry-android-replay",/d' \ - settings.gradle.kts - - - name: Exclude android modules from ignore list - run: | - sed -i \ - -e '/.*"sentry-uitest-android",/d' \ - -e '/.*"sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-uitest-android-critical",/d' \ - -e '/.*"test-app-sentry",/d' \ - -e '/.*"test-app-size",/d' \ - -e '/.*"sentry-samples-android",/d' \ - build.gradle.kts - - - name: Build SDK - run: | - ./gradlew assemble --parallel + ./gradlew \ + :sentry-samples:sentry-samples-spring-boot-jakarta:bootJar \ + :sentry-samples:sentry-samples-spring-boot-jakarta:testClasses \ + :sentry-samples:sentry-samples-spring-boot-webflux-jakarta:bootJar \ + :sentry-samples:sentry-samples-spring-boot-webflux-jakarta:testClasses \ + :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:bootJar \ + :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:testClasses \ + :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent:bootJar \ + :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent:testClasses \ + :sentry-samples:sentry-samples-spring-jakarta:war \ + :sentry-samples:sentry-samples-spring-jakarta:testClasses \ + :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - name: Test sentry-samples-spring-boot-jakarta run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-jakarta" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-webflux-jakarta run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-webflux-jakarta" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init true run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-jakarta-opentelemetry" \ --agent true \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init false run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-jakarta-opentelemetry" \ --agent true \ - --auto-init "false" \ - --build "true" + --auto-init "false" - name: Test sentry-samples-spring-boot-jakarta-opentelemetry-noagent run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-jakarta-opentelemetry-noagent" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Test sentry-samples-spring-jakarta run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-jakarta" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Upload test results if: always() @@ -172,10 +146,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index bc1b1686692..f75f31e38ef 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - springboot-version: [ '4.0.0', '4.0.5' ] + springboot-version: [ '4.0.0', '4.0.5', '4.1.0' ] name: Spring Boot ${{ matrix.springboot-version }} env: @@ -30,12 +30,12 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' @@ -45,20 +45,20 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -68,88 +68,62 @@ jobs: perl -0pi -e 'BEGIN { $v = shift } s/^springboot4[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot4 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml echo "Updated Spring Boot 4.x version to $springboot_version" - - name: Exclude android modules from build + - name: Build sample artifacts run: | - sed -i \ - -e '/.*"sentry-android-ndk",/d' \ - -e '/.*"sentry-android",/d' \ - -e '/.*"sentry-compose",/d' \ - -e '/.*"sentry-android-core",/d' \ - -e '/.*"sentry-android-fragment",/d' \ - -e '/.*"sentry-android-navigation",/d' \ - -e '/.*"sentry-android-sqlite",/d' \ - -e '/.*"sentry-android-timber",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-size",/d' \ - -e '/.*"sentry-samples:sentry-samples-android",/d' \ - -e '/.*"sentry-android-replay",/d' \ - settings.gradle.kts - - - name: Exclude android modules from ignore list - run: | - sed -i \ - -e '/.*"sentry-uitest-android",/d' \ - -e '/.*"sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-uitest-android-critical",/d' \ - -e '/.*"test-app-sentry",/d' \ - -e '/.*"test-app-size",/d' \ - -e '/.*"sentry-samples-android",/d' \ - build.gradle.kts - - - name: Build SDK - run: | - ./gradlew assemble --parallel + ./gradlew \ + :sentry-samples:sentry-samples-spring-boot-4:bootJar \ + :sentry-samples:sentry-samples-spring-boot-4:testClasses \ + :sentry-samples:sentry-samples-spring-boot-4-webflux:bootJar \ + :sentry-samples:sentry-samples-spring-boot-4-webflux:testClasses \ + :sentry-samples:sentry-samples-spring-boot-4-opentelemetry:bootJar \ + :sentry-samples:sentry-samples-spring-boot-4-opentelemetry:testClasses \ + :sentry-samples:sentry-samples-spring-boot-4-opentelemetry-noagent:bootJar \ + :sentry-samples:sentry-samples-spring-boot-4-opentelemetry-noagent:testClasses \ + :sentry-samples:sentry-samples-spring-7:war \ + :sentry-samples:sentry-samples-spring-7:testClasses \ + :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - name: Run sentry-samples-spring-boot-4 run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Run sentry-samples-spring-boot-4-webflux run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4-webflux" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Run sentry-samples-spring-boot-4-opentelemetry agent init true run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4-opentelemetry" \ --agent true \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Run sentry-samples-spring-boot-4-opentelemetry agent init false run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4-opentelemetry" \ --agent true \ - --auto-init "false" \ - --build "true" + --auto-init "false" - name: Run sentry-samples-spring-boot-4-opentelemetry-noagent run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-boot-4-opentelemetry-noagent" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Run sentry-samples-spring-7 run: | python3 test/system-test-runner.py test \ --module "sentry-samples-spring-7" \ --agent false \ - --auto-init "true" \ - --build "true" + --auto-init "true" - name: Upload test results if: always() @@ -172,10 +146,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index b1884cd4a7a..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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: 'recursive' - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.10.5' @@ -112,45 +112,16 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Exclude android modules from build - run: | - sed -i \ - -e '/.*"sentry-android-ndk",/d' \ - -e '/.*"sentry-android",/d' \ - -e '/.*"sentry-compose",/d' \ - -e '/.*"sentry-android-core",/d' \ - -e '/.*"sentry-android-fragment",/d' \ - -e '/.*"sentry-android-navigation",/d' \ - -e '/.*"sentry-android-sqlite",/d' \ - -e '/.*"sentry-android-timber",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ - -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ - -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ - -e '/.*"sentry-samples:sentry-samples-android",/d' \ - -e '/.*"sentry-android-replay",/d' \ - settings.gradle.kts - - - name: Exclude android modules from ignore list - run: | - sed -i \ - -e '/.*"sentry-uitest-android",/d' \ - -e '/.*"sentry-uitest-android-benchmark",/d' \ - -e '/.*"sentry-uitest-android-critical",/d' \ - -e '/.*"test-app-sentry",/d' \ - -e '/.*"sentry-samples-android",/d' \ - build.gradle.kts - - name: Build and run system tests run: | python3 test/system-test-runner.py test --module "${{ matrix.sample }}" --agent "${{ matrix.agent }}" --auto-init "${{ matrix.agent-auto-init }}" --build "true" diff --git a/AGENTS.md b/AGENTS.md index ff50727c662..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,12 +48,6 @@ The project uses **Gradle** with Kotlin DSL. Key build files: # Run all tests and linter ./gradlew check -# Build entire project -./gradlew build - -# Create coverage reports -./gradlew jacocoTestReport koverXmlReportRelease - # Generate documentation ./gradlew aggregateJavadocs ``` @@ -47,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 ``` @@ -90,7 +98,15 @@ 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 + +This repo ships task-specific skills (declared in `agents.toml`, sources under `.agents/skills`). Prefer them over performing the steps manually: +- **`create-java-pr`**: Branch, format, `apiDump`, commit, push, open PR, and add the changelog entry (automates the PR workflow above) +- **`test`**: Run unit or system tests for a module or a specific class +- **`check-code-attribution`**: Verify third-party code attribution on the current branch (see Third-Party Code Attribution below) +- **`btrace-perfetto`**: Capture and compare Perfetto traces for Android performance work ## Module Architecture @@ -100,15 +116,22 @@ The repository is organized into multiple modules: - **`sentry`** - Core Java SDK implementation - **`sentry-android-core`** - Core Android SDK implementation - **`sentry-android`** - High-level Android SDK +- **`sentry-android-ndk`** - Native (NDK) crash handling ### Integration Modules - **Spring Framework**: `sentry-spring*`, `sentry-spring-boot*` -- **Logging**: `sentry-logback`, `sentry-log4j2`, `sentry-jul` -- **Web**: `sentry-servlet*`, `sentry-okhttp`, `sentry-apache-http-client-5` +- **Logging**: `sentry-logback`, `sentry-log4j2`, `sentry-jul`, `sentry-android-timber` +- **Web**: `sentry-servlet*`, `sentry-okhttp`, `sentry-openfeign`, `sentry-apache-http-client-5` - **GraphQL**: `sentry-graphql*`, `sentry-apollo*` - **Android UI**: `sentry-android-fragment`, `sentry-android-navigation`, `sentry-compose` +- **Session Replay**: `sentry-android-replay` +- **Database**: `sentry-jdbc`, `sentry-android-sqlite`, `sentry-jcache` - **Reactive**: `sentry-reactor`, `sentry-ktor-client` +- **Feature Flags**: `sentry-launchdarkly-android`, `sentry-launchdarkly-server`, `sentry-openfeature` +- **Queues**: `sentry-kafka` +- **Profiling**: `sentry-async-profiler` (JVM continuous profiling) - **Monitoring**: `sentry-opentelemetry*`, `sentry-quartz` +- **Other**: `sentry-spotlight`, `sentry-kotlin-extensions`, `sentry-android-distribution` ### Utility Modules - **`sentry-test-support`** - Shared test utilities @@ -130,11 +153,39 @@ 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 - System tests validate end-to-end functionality with sample applications -- Coverage reports are generated for both JaCoCo (Java/Android) and Kover (KMP modules) +- **Assertions**: For new unit tests, prefer [Google Truth](https://truth.dev/) (`com.google.common.truth.Truth.assertThat`) over `kotlin.test`/JUnit assertions for its readable, fluent API. Keep using `kotlin.test` for test structure (`@Test`, `assertFailsWith`). See `sentry/src/test/java/io/sentry/DsnTest.kt` for the style. Don't rewrite existing `kotlin.test` assertions solely to switch libraries. +- Truth is wired into the `sentry` module. When adding Truth-based tests to another module, add `testImplementation(libs.google.truth)` to that module's `build.gradle.kts`. ### Contributing Guidelines 1. Follow existing code style and language @@ -171,6 +222,12 @@ gh pr view --json number -q '.number' gh pr view --json url -q '.url' ``` +### Changelog + +User-facing changes get an entry under the `## Unreleased` section of `CHANGELOG.md`. The +`create-java-pr` skill is the source of truth for the full changelog and PR workflow, including +subsection selection and the rebase caveat when a release renames `## Unreleased`. + ## Useful Resources - Main SDK documentation: https://develop.sentry.dev/sdk/overview/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a3b55b403..0a45ec3fe37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,388 @@ # Changelog +## Unreleased + +### Dependencies + +- Bump Native SDK from v0.16.2 to v0.16.3 ([#5962](https://github.com/getsentry/sentry-java/pull/5962)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0163) + - [diff](https://github.com/getsentry/sentry-native/compare/0.16.2...0.16.3) + +## 8.53.0 + +### Features + +- Allow child spans to use explicit start timestamps through `ISpan` ([#5929](https://github.com/getsentry/sentry-java/pull/5929)) +- Make `ISpan.startChild` overloads with `SpanOptions` public ([#5927](https://github.com/getsentry/sentry-java/pull/5927)) +- Add `Sentry.feedback().enableOnShake()`, `Sentry.feedback().disableOnShake()`, and `Sentry.feedback().isOnShakeEnabled()` to toggle and query shake-to-report at runtime ([#5827](https://github.com/getsentry/sentry-java/pull/5827)) + +### Improvements + +- Remove `ApiStatus.Experimental` annotation from `SentrySQLiteDriver` ([#5938](https://github.com/getsentry/sentry-java/pull/5938)) + +### Fixes + +- Clear contexts when calling `Scope.clear()` ([#5902](https://github.com/getsentry/sentry-java/pull/5902)) +- Preserve custom `Throwable` identities when R8 optimizes Android apps ([#5881](https://github.com/getsentry/sentry-java/pull/5881)) +- Report the correct cpu usage for the first performance sample of a transaction, which was measured against the time since device boot ([#5926](https://github.com/getsentry/sentry-java/pull/5926)) +- Prevent an ANR when the Session Replay video encoder gets stuck ([#5842](https://github.com/getsentry/sentry-java/pull/5842)) + - Some hardware encoders never signal end-of-stream, which made the replay worker spin forever while holding the encoder lock. The app's lifecycle callbacks then blocked on that lock and the app froze until the system killed it. The encoder now gives up instead of spinning, and closing the replay cache no longer waits indefinitely for a wedged encoder. + +### Performance + +- Read the clock once per performance collection round instead of once per in-flight transaction ([#5934](https://github.com/getsentry/sentry-java/pull/5934)) +- Reduce allocations while collecting cpu usage during transactions by reading the process cpu time via `Process.getElapsedCpuTime()` instead of parsing `/proc/self/stat` (33.6kB to 16 bytes per sample on a Pixel 3) ([#5926](https://github.com/getsentry/sentry-java/pull/5926)) +- Store performance measurements as primitives, removing a boxed allocation per measurement per performance sample ([#5935](https://github.com/getsentry/sentry-java/pull/5935)) + +### Dependencies + +- Bump Native SDK from v0.16.1 to v0.16.2 ([#5910](https://github.com/getsentry/sentry-java/pull/5910)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0162) + - [diff](https://github.com/getsentry/sentry-native/compare/0.16.1...0.16.2) + +## 8.52.0 + +### Fixes + +- Restore the interrupt flag when cached envelope processing is interrupted between files ([#5884](https://github.com/getsentry/sentry-java/pull/5884)) +- Reduce false-positive SDK crash attribution for host app SQLite cursor crashes ([#5883](https://github.com/getsentry/sentry-java/pull/5883)) +- Prevent inflated cold app start when the OS spawns the process in the background (e.g. FCM push) on API 35+ ([#5841](https://github.com/getsentry/sentry-java/pull/5841), [#5880](https://github.com/getsentry/sentry-java/pull/5880)) +- Preserve single-sample ANR profile chunks so profiles remain available on ANR events ([#5872](https://github.com/getsentry/sentry-java/pull/5872)) +- Avoid a CPU busy-loop when recording discarded log or metric envelopes under rate limiting ([#5835](https://github.com/getsentry/sentry-java/pull/5835)) + - `ClientReportRecorder` now reads the item count from the envelope item header instead of deserializing the payload, which under sustained rate limiting could pin CPU cores while repeatedly throwing exceptions +- Report tasks handed to a no-op `ISentryExecutorService` as cancelled ([#5874](https://github.com/getsentry/sentry-java/pull/5874)) + - `NoOpSentryExecutorService` previously returned a `Future` that was never run and never cancelled, so callers could not tell a dropped task from a queued one and `get()` would block until its timeout + +### Performance + +- Defer use of reflection by `SentryFrameMetricsCollector` during `Sentry.init` ([#5886](https://github.com/getsentry/sentry-java/pull/5886)) +- Avoid waiting up to `shutdownTimeoutMillis` when closing the SDK with a pending transaction timeout or session-end task ([#5851](https://github.com/getsentry/sentry-java/pull/5851)) +- Use `RGB_565` instead of `ARGB_8888` for screenshot and replay capture bitmaps, halving per-frame memory usage ([#5821](https://github.com/getsentry/sentry-java/pull/5821)) +- Remove an unused lock from `SentryPerformanceProvider`, which was allocated on every cold start in `ContentProvider.onCreate` without ever being acquired ([#5871](https://github.com/getsentry/sentry-java/pull/5871)) +- Reduce main-thread allocations when parsing the app start profiling config ([#5867](https://github.com/getsentry/sentry-java/pull/5867)) +- Batch and coalesce scope-persistence disk writes to reduce startup cost ([#5791](https://github.com/getsentry/sentry-java/pull/5791)) + - Scope mutations are now coalesced (latest value per field) and breadcrumbs are appended in batches behind a single fsync, instead of one synchronous disk write per mutation. +- Reduce the number of SDK threads: the `HostnameCache` worker thread now times out while idle instead of staying alive for the whole process lifetime ([#5817](https://github.com/getsentry/sentry-java/pull/5817)) + +### Dependencies + +- Bump Native SDK from v0.16.0 to v0.16.1 ([#5879](https://github.com/getsentry/sentry-java/pull/5879)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0161) + - [diff](https://github.com/getsentry/sentry-native/compare/0.16.0...0.16.1) + +## 8.51.0 + +### Features + +- Use Android's `ProfilingManager` (Perfetto) for continuous profiling on API 35+ devices ([#5251](https://github.com/getsentry/sentry-java/pull/5251)) + - On API 35+ devices, continuous profiling now automatically uses Android's system `ProfilingManager` with Perfetto-based stack sampling, providing lower-overhead and more accurate profiles. No configuration change is required. + - Devices below API 35 keep using the legacy `Debug`-based profiler. + - Added an `enableLegacyProfiling` option (default `true`) to disable the legacy `Debug`-based profiler. Setting it to `false` disables continuous profiling on API < 35 devices as well as transaction-based profiling (`profilesSampleRate`/`profilesSampler`) on all devices, since transaction-based profiling is not supported by Perfetto. + - It can also be configured via the `io.sentry.profiling.enable-legacy-profiling` manifest flag. + - See the [Android profiling docs](https://docs.sentry.io/platforms/android/profiling/) for details. + +### Behavioral Changes + +- The outbox and cache directories are no longer created by `Sentry.init` ([#5792](https://github.com/getsentry/sentry-java/pull/5792)) + - They are now created lazily by whichever component first writes into them, off the init thread. As a result, the directories at `SentryOptions.getOutboxPath()` and `SentryOptions.getCacheDirPath()` are not guaranteed to exist once `Sentry.init` returns. + - If you write envelopes into the outbox path yourself instead of going through the SDK — as hybrid SDKs do for `captureEnvelope` — create the directory first, e.g. `new File(outboxPath).mkdirs()`. + +### Improvements + +- Skip building Android manifest metadata debug log messages when debug logging is disabled, reducing allocations during SDK init ([#5790](https://github.com/getsentry/sentry-java/pull/5790)) + +### Fixes + +- Use the original app build's ProGuard UUID for ANR profile chunks ([#5852](https://github.com/getsentry/sentry-java/pull/5852)) +- Fix potential ANR/deadlock in Session Replay when `checkCanRecord` runs on the replay executor thread ([#5837](https://github.com/getsentry/sentry-java/pull/5837)) +- Prevent concurrent PixelCopy access during Session Replay masking and bitmap cleanup ([#5808](https://github.com/getsentry/sentry-java/pull/5808)) +- Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607)) +- Set the correct platform (`android` instead of `java`) on ANR profile chunks so they are billed as UI Profile Hours rather than Continuous Profile Hours ([#5836](https://github.com/getsentry/sentry-java/pull/5836)) +- Skip encoding and capturing buffered session replay segments while rate-limited, so we don't waste resources on envelopes the transport will drop ([#5813](https://github.com/getsentry/sentry-java/pull/5813)) + - These skipped replays are now reported as `ratelimit_backoff` discarded events in client reports, so they no longer disappear from drop statistics. One event is recorded per buffer flush rather than per segment. + - Buffer mode is also kept while rate-limited instead of switching to session mode, so the rolling buffer stays warm and the next error after the rate limit expires can send a complete replay. + +### Performance + +- Create the outbox and cache directories lazily in their consumers instead of during SDK init, moving the `mkdirs()` calls off the init (main) thread ([#5792](https://github.com/getsentry/sentry-java/pull/5792)) +- Reduce the number of SDK threads: `LifecycleWatcher` now schedules the session-end task on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5819](https://github.com/getsentry/sentry-java/pull/5819)) +- Reduce the number of SDK threads: `RateLimiter` now schedules its rate-limit-lifted notifications on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5814](https://github.com/getsentry/sentry-java/pull/5814)) +- Speed up deserialization of arbitrary JSON objects by typing numbers without throwing exceptions ([#5783](https://github.com/getsentry/sentry-java/pull/5783)) + +### Dependencies + +- Bump Native SDK from v0.15.4 to v0.16.0 ([#5845](https://github.com/getsentry/sentry-java/pull/5845)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0160) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.4...0.16.0) + +## 8.50.1 + +### Fixes + +- Pin the published Sentry Android SDK's AAR metadata `minCompileSdk` to our `minSdk` (`21`) instead of AGP 9's new default of the SDK's own `compileSdk` (`37`), so apps that depend on the SDK aren't forced to raise their `compileSdk` ([#5823](https://github.com/getsentry/sentry-java/pull/5823)) + +## 8.50.0 + +### Android 17 support + +- We've put Android 17 through a set of rigorous tests. We're now officially giving it the Sentry stamp of compatibility .([#5796](https://github.com/getsentry/sentry-java/pull/5796)) + +### Fixes + +- Reduce main-thread work during `Sentry.init` by resolving the shake-detector accelerometer off the main thread (~1.75ms on a Pixel 10) ([#5784](https://github.com/getsentry/sentry-java/pull/5784)) +- Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) +- `SentryTagModifierNode.isImportantForBounds` now matches the default behavior and returns `true` ([#5789](https://github.com/getsentry/sentry-java/pull/5789)) +- Prevent a `StackOverflowError` when a `beforeSend`, `beforeBreadcrumb`, `beforeSendLog`, or `beforeEnvelope` callback triggers another capture (directly or through a logging integration such as Timber) ([#5737](https://github.com/getsentry/sentry-java/pull/5737)) + - Captures made from within a user callback (event, transaction, breadcrumb, log, envelope, or check-in) are now dropped while that callback runs, instead of recursing. Captures made by event processors are unaffected. +- Replace deprecated `ThrowableProxy` with `LogEvent#getThrown()` in `sentry-log4j2` ([#5751](https://github.com/getsentry/sentry-java/pull/5751)) + +### Dependencies + +- Bump Native SDK from v0.15.3 to v0.15.4 ([#5793](https://github.com/getsentry/sentry-java/pull/5793)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0154) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.3...0.15.4) +- The SDK is now compiled with Android Gradle Plugin 9.2.1 ([#5779](https://github.com/getsentry/sentry-java/pull/5779)) + +## 8.49.0 + +### Features + +- Session Replay: Record segment names (transaction names) ([#5763](https://github.com/getsentry/sentry-java/pull/5763)) + +- Add `io.sentry:sentry-opentelemetry-bom` to align Sentry OpenTelemetry modules with tested OpenTelemetry dependencies ([#5629](https://github.com/getsentry/sentry-java/pull/5629)) + - Spring Boot Gradle plugin: add the Sentry BOM to `dependencyManagement`; explicit imports are applied after Spring Boot's implicit BOM + ```kotlin + dependencyManagement { + imports { + mavenBom("io.sentry:sentry-opentelemetry-bom:") + } + } + ``` + - Gradle: import it as a platform and omit versions from Sentry OpenTelemetry and OpenTelemetry dependencies + ```kotlin + implementation(platform("io.sentry:sentry-opentelemetry-bom:")) + ``` + - Maven: import it before Spring Boot's BOM in the same `` block, or in the child POM when using `spring-boot-starter-parent` + ```xml + + io.sentry + sentry-opentelemetry-bom + ${sentry.version} + pom + import + + ``` + +### Fixes + +- Session Replay: Fix first recording segment missing for replays in `buffer` mode ([#5753](https://github.com/getsentry/sentry-java/pull/5753)) +- Session Replay: Fix error-to-replay linkage in `buffer` mode ([#5754](https://github.com/getsentry/sentry-java/pull/5754)) +- Prevent logs and metrics from remaining queued after a flush scheduling race ([#5756](https://github.com/getsentry/sentry-java/pull/5756)) +- Fix main thread identification for tombstone (native crash) events ([#5742](https://github.com/getsentry/sentry-java/pull/5742)) +- Prevent malformed JDBC URLs, which may contain credentials, from being printed to stdout ([#5656](https://github.com/getsentry/sentry-java/pull/5656)) +- Restrict JVM-global proxy authentication credentials to challenges from the configured proxy host ([#5656](https://github.com/getsentry/sentry-java/pull/5656)) +- Sanitize Spring 7 and Spring Jakarta WebClient span descriptions to prevent embedded URL credentials from being sent to Sentry ([#5656](https://github.com/getsentry/sentry-java/pull/5656)) +- Respect `tracePropagationTargets` when injecting Sentry tracing headers through the OpenTelemetry OTLP propagator ([#5656](https://github.com/getsentry/sentry-java/pull/5656)) + +### Performance + +- Schedule transaction idle/deadline timeouts on a shared, dedicated executor instead of spawning a `Timer` thread per transaction ([#5670](https://github.com/getsentry/sentry-java/pull/5670)) + +### Dependencies + +- Bump OpenTelemetry to support Spring Boot 4.1 ([#5573](https://github.com/getsentry/sentry-java/pull/5573)) + - If this causes issues for you because you are also using Spring Boot Dependency Management Plugin (io.spring.dependency-management), + which may downgrade the OpenTelemetry SDK, please have a look at the changelog entry above that explains how to use `sentry-opentelemetry-bom`. + - OpenTelemetry to 1.63.0 (was 1.60.1) + - OpenTelemetry Instrumentation to 2.29.0 (was 2.26.0) + - OpenTelemetry Instrumentation Alpha to 2.29.0-alpha (was 2.26.0-alpha) + - OpenTelemetry Semantic Conventions to 1.42.0 (was 1.40.0) + - OpenTelemetry Semantic Conventions Alpha to 1.42.0-alpha (was 1.40.0-alpha) +- Bump Native SDK from v0.15.2 to v0.15.3 ([#5728](https://github.com/getsentry/sentry-java/pull/5728)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0153) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.2...0.15.3) + +## 8.48.0 + +### Features + +- Add `Sentry.extendAppStart()`, `Sentry.finishExtendedAppStart()`, and `Sentry.getExtendedAppStartSpan()` to extend the app start measurement past the first frame for extra launch-time work on Android ([#5604](https://github.com/getsentry/sentry-java/pull/5604)) + - Requires standalone app start tracing (`options.isEnableStandaloneAppStartTracing`). Call `extendAppStart()` in `Application.onCreate` after SDK init and `finishExtendedAppStart()` when done: + + ```kotlin + Sentry.extendAppStart() + + // Optionally, retrieve the extended app start span to attach your own child spans + val child = Sentry.getExtendedAppStartSpan()?.startChild("preload", "Preload resources") + // ... extra launch-time work ... + child?.finish() + + Sentry.finishExtendedAppStart() + ``` +- Add `trace_metric_byte` data category and record byte-level client reports when trace metrics are discarded ([#5626](https://github.com/getsentry/sentry-java/pull/5626)) +- Expose sentry-native's heartbeat-based app-hang detection through `SentryAndroidOptions` ([#5623](https://github.com/getsentry/sentry-java/pull/5623)) + - Enable via `setEnableNdkAppHangTracking(true)` (disabled by default) and tune the timeout with `setNdkAppHangTimeoutIntervalMillis(...)` (default `5000` ms), or the `io.sentry.ndk.app-hang.enable` / `io.sentry.ndk.app-hang.timeout-interval-millis` manifest entries + - Intended for hybrid SDKs: emit the heartbeat by calling the native `sentry_app_hang_heartbeat()` from the thread you want monitored. Independent of the JVM-based ANR detection (`setAnrEnabled`) +- Support the `io.sentry.tombstone.report-historical` manifest option to enable historical tombstone reporting via `AndroidManifest.xml` `` ([#5683](https://github.com/getsentry/sentry-java/pull/5683)) + +### Fixes + +- Fix `NoSuchMethodError` from using `Math.floorDiv`/`Math.floorMod` overloads that are unavailable on Java 8 ([#5743](https://github.com/getsentry/sentry-java/pull/5743)) +- Fix main thread identification parsing for ApplicationExitInfo ANRs ([#5733](https://github.com/getsentry/sentry-java/pull/5733)) +- Do not send threads without stacktraces for ApplicationExitInfo ANRs ([#5733](https://github.com/getsentry/sentry-java/pull/5733)) +- Record byte-level client reports when event processors discard logs or trace metrics ([#5718](https://github.com/getsentry/sentry-java/pull/5718)) +- Name the device-info caching thread `SentryDeviceInfoCache` so all threads spawned by the SDK are identifiable ([#5684](https://github.com/getsentry/sentry-java/pull/5684)) +- Apply byte-category rate limits to log and trace metric envelope items ([#5716](https://github.com/getsentry/sentry-java/pull/5716)) + +### Performance + +- Skip `Hint` allocation in `Scope.addBreadcrumb` when no `beforeBreadcrumb` callback is set ([#5689](https://github.com/getsentry/sentry-java/pull/5689)) +- Speed up scope persistence by detecting the Sentry executor thread via a marker instead of a `Thread.getName()` name scan on every scope mutation ([#5691](https://github.com/getsentry/sentry-java/pull/5691)) +- Remove executor prewarm during SDK init ([#5681](https://github.com/getsentry/sentry-java/pull/5681)) + - The single-threaded `SentryExecutorService` queued the prewarm work ahead of the first useful task, so it could only delay init work, never speed it up; the thread and class loading it warmed are paid identically by the first real task submitted right after. + +### Dependencies + +- Bump Native SDK from v0.15.2 to v0.15.3 ([#5623](https://github.com/getsentry/sentry-java/pull/5623)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0153) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.2...0.15.3) + +## 8.47.0 + +### Behavioral Changes + +- `SentryOkHttpInterceptor::intercept` now throws `IOException`. This is a source-only and Java-only breaking change ([#5654](https://github.com/getsentry/sentry-java/pull/5654)) + +### Fixes + +- Fix fragment tracing not working with detach/attach navigation ([#5660](https://github.com/getsentry/sentry-java/pull/5660)) +- Don't start a redundant UI interaction transaction when a transaction is already bound to the Scope ([#5491](https://github.com/getsentry/sentry-java/issues/5491)) + - Previously, `SentryGestureListener` always started a UI transaction and only afterwards skipped binding it to the Scope when a manually-bound transaction already existed, leaving the new transaction to be dropped as an idle transaction without children. +- Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) +- Fix memory leak in `ReplayIntegration` due to persisting executor not being shut down ([#5627](https://github.com/getsentry/sentry-java/pull/5627)) +- Fix AbstractMethodError when compose-ui 1.11+ is used in combination with `Modifier.sentryTag()` or the Sentry Kotlin compiler plugin ([#5672](https://github.com/getsentry/sentry-java/pull/5672)) + +### Performance + +- Speed up touch gesture target detection on deeply nested view hierarchies by hit-testing in local coordinates instead of calling `getLocationOnScreen` per view ([#5595](https://github.com/getsentry/sentry-java/pull/5595)) +- Probe class availability without initializing the class during SDK init ([#5635](https://github.com/getsentry/sentry-java/pull/5635)) +- Avoid constructing an exception per view when resolving view ids during view-hierarchy and gesture capture ([#5631](https://github.com/getsentry/sentry-java/pull/5631)) +- Start the frame metrics thread lazily on first collection instead of during SDK init ([#5641](https://github.com/getsentry/sentry-java/pull/5641)) +- Reduce `SentryId` and `SpanId` allocation overhead by replacing their per-instance `LazyEvaluator` (and its lock) with a lightweight lazily-generated `String`. ([#5645](https://github.com/getsentry/sentry-java/pull/5645)) +- Lazily allocate the `ReentrantLock` backing `AutoClosableReentrantLock` to avoid eager lock allocations for SDK objects that never contend during `SentryAndroid.init` ([#5643](https://github.com/getsentry/sentry-java/pull/5643)) + +## 8.46.0 + +### Fixes + +- Session Replay: Fix network detail response body size being unknown for gzip-compressed responses ([#5592](https://github.com/getsentry/sentry-java/pull/5592)) + +### Behavioral Changes + +- Collections returned by scope (e.g. `getBreadcrumbs`, `getTags`, `getAttachments`) are shared state and should not be mutated. ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) + - Previously, when going through `CombinedScopeView`, we were returning a copy where mutations didn't show up in the underlying scopes. + - This has now changed in order to reduce SDK overhead. +- `Date` objects returned by SDK data model getters are shared state and should not be mutated. ([#5603](https://github.com/getsentry/sentry-java/pull/5603)) + - Previously, these getters returned defensive copies for some date fields. + - This has now changed in order to reduce SDK overhead. + +### Performance + +- Reduce writer buffer size from 8192 to 512 ([#5544](https://github.com/getsentry/sentry-java/pull/5544)) +- Remove redundant event map copies ([#5536](https://github.com/getsentry/sentry-java/pull/5536)) +- Optimize combined scope by adding an early return if only one scope has data ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) +- Reduce model access overhead by avoiding defensive `Date` copies in SDK data model getters. ([#5603](https://github.com/getsentry/sentry-java/pull/5603)) +- Reduce timestamp parsing and formatting overhead with Sentry-specific ISO-8601 handling. ([#5602](https://github.com/getsentry/sentry-java/pull/5602)) +- Reduce JSON serialization overhead by creating the reflection serializer only when unknown-object fallback serialization is needed. ([#5601](https://github.com/getsentry/sentry-java/pull/5601)) +- Reduce JSON serialization overhead by allocating reflection cycle-tracking state only when reflection serialization is used. ([#5600](https://github.com/getsentry/sentry-java/pull/5600)) +- Reduce context serialization overhead by sorting key snapshots with arrays instead of temporary lists. ([#5599](https://github.com/getsentry/sentry-java/pull/5599)) +- Reduce breadcrumb allocation overhead by creating the `Breadcrumb` data map only when data is added. ([#5598](https://github.com/getsentry/sentry-java/pull/5598)) +- Reduce JSON serialization overhead by lowering the initial `JsonWriter` nesting stack size while preserving on-demand growth. ([#5591](https://github.com/getsentry/sentry-java/pull/5591)) +- Reduce timestamp helper overhead by replacing unnecessary `Calendar` usage in `DateUtils` with direct `Date` creation. ([#5589](https://github.com/getsentry/sentry-java/pull/5589)) +- Reduce Android startup overhead by using the default timezone directly on older devices or when no timezone info is available in the locale. ([#5587](https://github.com/getsentry/sentry-java/pull/5587)) + +## 8.45.0 + +### Features + +- On Android 15+ (API 35), the standalone `app.start` transaction now reports why the OS started the process via `app.vitals.start.reason` trace data (e.g. `launcher`, `broadcast`, `service`, `content_provider`), derived from `ApplicationStartInfo.getReason()`. You can search and group by this attribute in the Trace Explorer. ([#5552](https://github.com/getsentry/sentry-java/pull/5552)) + +### Fixes + +- Use `System.nanoTime()` for cron check-in duration measurement to avoid incorrect durations from wall-clock adjustments ([#5611](https://github.com/getsentry/sentry-java/pull/5611)) +- Fix crash when `getHistoricalProcessStartReasons` is called from an isolated or wrong-userId process ([#5597](https://github.com/getsentry/sentry-java/pull/5597)) +- Release `MediaMuxer` when a replay segment has no encodable frames to avoid a resource leak ([#5583](https://github.com/getsentry/sentry-java/pull/5583)) + +### Dependencies + +- Bump Native SDK from v0.15.1 to v0.15.2 ([#5610](https://github.com/getsentry/sentry-java/pull/5610)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0152) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.1...0.15.2) + +## 8.44.1 + +### Fixes + +- Fix `FirstDrawDoneListener` leaking an `OnGlobalLayoutListener` per registration ([#5567](https://github.com/getsentry/sentry-java/pull/5567)) + +### Features + +- Add experimental `SentrySQLiteDriver` to `sentry-android-sqlite` for instrumenting `androidx.sqlite.SQLiteDriver` ([#5563](https://github.com/getsentry/sentry-java/pull/5563)) + - To use it, pass `SQLiteDriver` to `SentrySQLiteDriver.create(...)` + - Requires `androidx.sqlite:sqlite` (2.5.0+) on runtime classpath (typically provided by Room or SQLDelight) + +### Dependencies + +- Bump Native SDK from v0.15.0 to v0.15.1 ([#5570](https://github.com/getsentry/sentry-java/pull/5570)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0151) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.0...0.15.1) + +## 8.44.0 + +### Features + +- Add `enableStandaloneAppStartTracing` option to send app start as a standalone transaction instead of attaching it as a child span of the first activity transaction ([#5342](https://github.com/getsentry/sentry-java/pull/5342)) + - Disabled by default; opt in via `options.isEnableStandaloneAppStartTracing = true` or manifest meta-data `io.sentry.standalone-app-start-tracing.enable` + - Emits a transaction named `App Start` with op `app.start`, carrying the existing app start measurements and phase spans (`process.load`, `contentprovider.load`, `application.load`, activity lifecycle spans) as direct children of the root + - The standalone transaction shares the same `traceId` as the first `ui.load` activity transaction so they remain linked in the trace view + - Also covers non-activity starts (broadcast receivers, services, content providers) + +### Improvements + +- Reduce boxing to improve performance ([#5523](https://github.com/getsentry/sentry-java/pull/5523), [#5527](https://github.com/getsentry/sentry-java/pull/5527), [#5551](https://github.com/getsentry/sentry-java/pull/5551)) +- Replace `Date` with a unix timestamp in `SentryNanotimeDate` to improve performance ([#5550](https://github.com/getsentry/sentry-java/pull/5550)) + - `SentryNanotimeDate` is now marked `@ApiStatus.Internal`. A new `(long unixDateMillis, long nanos)` constructor was added, where `unixDateMillis` is milliseconds since the epoch. The existing `(Date, long)` constructor is retained but deprecated. + +### Dependencies + +- Upgrade to asyncProfiler 4.4 ([#5418](https://github.com/getsentry/sentry-java/pull/5418)) +- Bump Native SDK from v0.14.2 to v0.15.0 ([#5528](https://github.com/getsentry/sentry-java/pull/5528)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0150) + - [diff](https://github.com/getsentry/sentry-native/compare/0.14.2...0.15.0) + +### Fixes + +- Fix attachments being duplicated on native events that carry scope attachments ([#5548](https://github.com/getsentry/sentry-java/pull/5548)) +- Fix performance collector scheduling many tasks in a row ([#5524](https://github.com/getsentry/sentry-java/pull/5524)) + +## 8.43.3 + +### Fixes + +- Fix crash when `getHistoricalProcessStartReasons` is called from an isolated or wrong-userId process ([#5597](https://github.com/getsentry/sentry-java/pull/5597)) + +## 8.43.2 + +### Improvements + +- Improve SDK init performance by replacing `java.net.URI` with custom string parsing for DSN ([#5448](https://github.com/getsentry/sentry-java/pull/5448)) +- Remove unnecessary boxing to improve performance ([#5520](https://github.com/getsentry/sentry-java/pull/5520)) + +### Fixes + +- Session Replay: Fix `VerifyError` in Compose masking under DexGuard/R8 obfuscation ([#5507](https://github.com/getsentry/sentry-java/pull/5507)) +- Session Replay: Fix Compose view masking not working on obfuscated/minified builds ([#5503](https://github.com/getsentry/sentry-java/pull/5503)) + ## 8.43.1 ### Fixes 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/CONTRIBUTING.md b/CONTRIBUTING.md index 7eb38413d64..f4354c72a89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,3 +68,8 @@ issue without a closing keyword is not enough. Build and tests are automatically run against branches and pull requests via GH Actions. + + +# AI Use + +You are welcome to use whatever tools you prefer for making a contribution. However, any changes you propose have to be reviewed and tested by you, a human, first, before you submit a pull request with them for the Sentry team to review. If we feel like that did not happen, we will close the PR outright. For example, we will not review visibly AI-generated PRs from an agent instructed to look for and "fix" open issues in the repo. This aligns with our SDK principle: [every line has an owner](https://develop.sentry.dev/sdk/getting-started/principles/#every-line-has-an-owner). diff --git a/Makefile b/Makefile index c9eca8b8b7e..3967ff856ad 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ -.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleBenchmarkTestRelease assembleUiTestRelease assembleUiTestCriticalRelease createCoverageReports runUiTestCritical setupPython systemTest systemTestInteractive check preMerge publish +.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleBenchmarkTestRelease assembleUiTestRelease assembleUiTestCriticalRelease runUiTestCritical setupPython systemTest systemTestInteractive check preMerge publish -all: stop clean javadocs compile createCoverageReports +all: stop clean javadocs compile assembleBenchmarks: assembleBenchmarkTestRelease assembleUiTests: assembleUiTestRelease -preMerge: check createCoverageReports +preMerge: check publish: clean dryRelease # deep clean @@ -51,13 +51,6 @@ assembleUiTestCriticalRelease: runUiTestCritical: ./scripts/test-ui-critical.sh -# Create coverage reports -# - Jacoco for Java & Android modules -# - Kover for KMP modules e.g sentry-compose -createCoverageReports: - ./gradlew jacocoTestReport - ./gradlew koverXmlReportRelease - # Create the Python virtual environment for system tests, and install the necessary dependencies setupPython: @test -d .venv || python3 -m venv .venv diff --git a/README.md b/README.md index 9aaf7aca4d8..849aaf74457 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ _Bad software is everywhere, and we're tired of it. Sentry is on a mission to he Sentry SDK for Java and Android =========== [![GH Workflow](https://img.shields.io/github/actions/workflow/status/getsentry/sentry-java/build.yml?branch=main)](https://github.com/getsentry/sentry-java/actions) -[![codecov](https://codecov.io/gh/getsentry/sentry-java/branch/main/graph/badge.svg)](https://codecov.io/gh/getsentry/sentry-java) [![X Follow](https://img.shields.io/twitter/follow/sentry?label=sentry&style=social)](https://x.com/intent/follow?screen_name=sentry) [![Discord Chat](https://img.shields.io/discord/621778831602221064?logo=discord&logoColor=ffffff&color=7389D8)](https://discord.gg/PXa5Apfe7K) @@ -65,6 +64,7 @@ Sentry SDK for Java and Android | sentry-launchdarkly-android | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-launchdarkly-android?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-launchdarkly-android) | | sentry-launchdarkly-server | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-launchdarkly-server?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-launchdarkly-server) | | sentry-opentelemetry-agent | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-agent?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-opentelemetry-agent) | +| sentry-opentelemetry-bom | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-bom?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-opentelemetry-bom) | | sentry-opentelemetry-agentcustomization | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-agentcustomization?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-opentelemetry-agentcustomization) | | sentry-opentelemetry-core | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-core?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-opentelemetry-core) | | sentry-opentelemetry-otlp | [![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-otlp?style=for-the-badge&logo=sentry&color=green)](https://central.sonatype.com/artifact/io.sentry/sentry-opentelemetry-otlp) | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5a48d567fac..a0040916145 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -34,6 +34,34 @@ limitations under the License. --- +## Google Guava — LongMath (Apache 2.0) + +**Source:** https://github.com/google/guava/blob/v33.0.0/guava/src/com/google/common/math/LongMath.java
+**License:** Apache License 2.0
+**Copyright:** Copyright (C) 2011 The Guava Authors + +### Scope + +The Sentry Java SDK includes adapted floor division logic from Guava's `LongMath` class to support older Android API levels. The code resides in `io.sentry.vendor.SentryMath`. + +``` +Copyright (C) 2011 The Guava Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + ## FasterXML Jackson — ISO8601Utils (Apache 2.0) **Source:** https://github.com/FasterXML/jackson-databind
@@ -62,6 +90,22 @@ limitations under the License. --- +## Howard Hinnant — Date Algorithms (Public Domain) + +**Source:** https://howardhinnant.github.io/date_algorithms.html
+**License:** Public Domain
+**Copyright:** None; public domain dedication by Howard Hinnant + +### Scope + +The Sentry Java SDK includes adapted civil date conversion algorithms from Howard Hinnant's date algorithms for UTC ISO 8601 timestamp parsing and formatting. The code resides in `io.sentry.vendor.SentryIso8601Utils`. + +``` +Consider these donated to the public domain. +``` + +--- + ## Android Open Source Project — Base64 (Apache 2.0) **Source:** https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/Base64.java
@@ -92,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. @@ -100,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. @@ -154,7 +200,7 @@ limitations under the License. ### Scope -The Sentry Java SDK includes an adapted version of Square's Curtains library for null-safe `Window.Callback` handling. The code resides in `io.sentry.android.replay.util.FixedWindowCallback`. +The Sentry Java SDK includes adapted versions of Square's Curtains library for null-safe `Window.Callback` handling and for tracking attached window roots. The code resides in `io.sentry.android.replay.util.FixedWindowCallback` and `io.sentry.android.replay.Windows`. ``` Copyright 2021 Square Inc. @@ -315,6 +361,35 @@ limitations under the License. --- +## Android Open Source Project — Jetpack Compose UI (Apache 2.0) + +**Source:** https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt#L187
+**Source:** https://github.com/androidx/androidx/blob/androidx-main/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/MathHelpers.kt
+**License:** Apache License 2.0
+**Copyright:** Copyright (C) 2019, 2020 The Android Open Source Project + +### Scope + +The Sentry Android Replay SDK includes code adapted from Jetpack Compose UI, used to compute Compose node bounds while traversing the view hierarchy for masking. The code resides in `io.sentry.android.replay.util.Nodes`: the `boundsInWindow` extension function (a faster copy of `LayoutCoordinates.boundsInWindow`) and the `fastMinOf`, `fastMaxOf`, `fastCoerceIn`, `fastCoerceAtLeast`, and `fastCoerceAtMost` numeric helpers (copied from `androidx.compose.ui.util.MathHelpers`). + +``` +Copyright (C) 2019, 2020 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + ## OpenTelemetry (Apache 2.0) **Source:** https://github.com/open-telemetry/opentelemetry-java (Commit: 0aacc55d1e3f5cc6dbb4f8fa26bcb657b01a7bc9)
@@ -484,3 +559,40 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` + +--- + +## fzyzcjy — Flutter Screen Recorder (MIT) + +**Source:** https://github.com/fzyzcjy/flutter_screen_recorder (Commit: dce41cec25c66baf42c6bac4198e95874ce3eb9d)
+**License:** MIT License
+**Copyright:** Copyright (c) 2021 fzyzcjy + +### Scope + +The Sentry Android Replay SDK includes adapted versions of the video encoding and muxing classes from the flutter_screen_recorder library, used to encode and mux replay video frames into an MP4 file. The code resides in the `io.sentry.android.replay.video` package and includes `SimpleFrameMuxer`, `SimpleMp4FrameMuxer`, and `SimpleVideoEncoder`. + +``` +Copyright (c) 2021 fzyzcjy + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +In addition to the standard MIT license, this library requires the following: The recorder itself +only saves data on user's phone locally, thus it does not have any privacy problem. However, if +you are going to get the records out of the local storage (e.g. upload the records to your +server), please explicitly ask the user for permission, and promise to only use the records to +debug your app. This is a part of the license of this library. +``` diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 8abe9f55283..bba758f9b79 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -7,5 +7,19 @@ repositories { } dependencies { + implementation(libs.animalsniffer.gradle.plugin) implementation(libs.spotlessLib) } + +gradlePlugin { + plugins { + register("sentryAnimalSniffer") { + id = "io.sentry.animalsniffer" + implementationClass = "io.sentry.gradle.SentryAnimalSnifferPlugin" + } + register("sentryAnimalSnifferAndroid") { + id = "io.sentry.animalsniffer.android" + implementationClass = "io.sentry.gradle.SentryAnimalSnifferAndroidPlugin" + } + } +} diff --git a/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts b/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts index e06cb677319..8fde556d751 100644 --- a/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts +++ b/build-logic/src/main/kotlin/io.sentry.javadoc.aggregate.gradle.kts @@ -1,11 +1,9 @@ import io.sentry.gradle.AggregateJavadoc import org.gradle.api.attributes.Category import org.gradle.api.attributes.LibraryElements -import org.gradle.kotlin.dsl.creating -import org.gradle.kotlin.dsl.getValue import org.gradle.kotlin.dsl.named -val javadocPublisher by configurations.creating { +val javadocPublisher = configurations.create("javadocPublisher") { isCanBeConsumed = false isCanBeResolved = true attributes { @@ -15,7 +13,7 @@ val javadocPublisher by configurations.creating { } subprojects { - javadocPublisher.dependencies.add(dependencies.create(this)) + javadocPublisher.dependencies.add(rootProject.dependencies.project(path)) } val javadocCollection = javadocPublisher.incoming.artifactView { lenient(true) }.files diff --git a/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts b/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts index 7eb796a02ff..21f81fec36a 100644 --- a/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts +++ b/build-logic/src/main/kotlin/io.sentry.javadoc.gradle.kts @@ -1,4 +1,4 @@ -val javadocConfig: Configuration by configurations.creating { +val javadocConfig: Configuration = configurations.create("javadocConfig") { isCanBeResolved = false isCanBeConsumed = true diff --git a/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts b/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts new file mode 100644 index 00000000000..a21079e1336 --- /dev/null +++ b/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts @@ -0,0 +1,38 @@ +import io.sentry.gradle.SystemTestExtension +import org.gradle.api.tasks.ClasspathNormalizer + +val systemTest = extensions.create("sentrySystemTest") + +// The sample system tests launch the packaged app (war/shadowJar/bootJar) from build/libs as a +// separate process, so the archive is a real input even though it is not on the test classpath. +// Agent-based samples are additionally launched with -javaagent:, another runtime +// input not on the classpath. See test/system-test-runner.py. +tasks.matching { it.name == "systemTest" }.configureEach { + val archiveTask = + listOf("war", "shadowJar", "bootJar").firstOrNull { it in tasks.names } + ?: throw GradleException( + "io.sentry.systemtest is applied to $path but none of war/shadowJar/bootJar " + + "exist to provide the launched app archive for the systemTest task" + ) + // Declaring the archive as an input also wires the dependency on its producing task. + inputs + .files(tasks.named(archiveTask)) + .withPropertyName("appArchive") + .withNormalizer(ClasspathNormalizer::class.java) + + if (systemTest.usesOpenTelemetryAgent.get()) { + // The runner builds the agent and launches the app with -javaagent before invoking this task, + // so the agent jar is tracked for content only (by path, no cross-project task dependency): a + // change to it makes systemTest out of date even though it runs outside the test JVM. + val version = providers.gradleProperty("versionName").get() + inputs + .files( + rootProject.layout.projectDirectory.file( + "sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/" + + "sentry-opentelemetry-agent-$version.jar" + ) + ) + .withPropertyName("openTelemetryAgent") + .withNormalizer(ClasspathNormalizer::class.java) + } +} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SentryAnimalSnifferPlugin.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SentryAnimalSnifferPlugin.kt new file mode 100644 index 00000000000..f1bc2bafcf7 --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/SentryAnimalSnifferPlugin.kt @@ -0,0 +1,57 @@ +package io.sentry.gradle + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.MinimalExternalModuleDependency +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.provider.ListProperty +import ru.vyarus.gradle.plugin.animalsniffer.AnimalSniffer + +abstract class SentryAnimalSnifferExtension { + abstract val ignoredClasses: ListProperty + abstract val excludedClasses: ListProperty + + fun ignoreClasses(vararg classes: String) { + ignoredClasses.addAll(*classes) + } + + fun mainExcludes(vararg excludes: String) { + excludedClasses.addAll(*excludes) + } +} + +class SentryAnimalSnifferPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply("ru.vyarus.animalsniffer") + + val extension = + project.extensions.create("sentryAnimalSniffer", SentryAnimalSnifferExtension::class.java) + + project.addSignatureDependency("java8-signature") + + project.tasks.named("animalsnifferMain", AnimalSniffer::class.java).configure { + ignoreClasses = ignoreClasses + extension.ignoredClasses.get() + exclude(extension.excludedClasses.get()) + } + + project.tasks.named("check").configure { dependsOn("animalsnifferMain") } + } +} + +class SentryAnimalSnifferAndroidPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply(SentryAnimalSnifferPlugin::class.java) + + project.addSignatureDependency("gummy-bears-api21") + } +} + +private fun Project.addSignatureDependency(libraryName: String) { + val libs = extensions.getByType(VersionCatalogsExtension::class.java).named("libs") + dependencies.add("signature", signatureNotation(libs.findLibrary(libraryName).get().get())) +} + +private fun signatureNotation(dependency: MinimalExternalModuleDependency): String { + val module = "${dependency.module.group}:${dependency.module.name}" + return "$module:${dependency.versionConstraint.requiredVersion}@signature" +} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt new file mode 100644 index 00000000000..9111ce17b1f --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt @@ -0,0 +1,17 @@ +package io.sentry.gradle + +import org.gradle.api.provider.Property + +/** Configuration for the `io.sentry.systemtest` convention plugin. */ +abstract class SystemTestExtension { + /** + * Set to `true` for samples that the system-test runner launches with the Sentry OpenTelemetry + * Java agent (`-javaagent`). The agent jar is then tracked as a `systemTest` input so the task + * re-runs when the agent changes, even though it is started outside the test JVM. + */ + abstract val usesOpenTelemetryAgent: Property + + init { + usesOpenTelemetryAgent.convention(false) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index d5b5dfc5d05..a663628b467 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,19 +3,15 @@ import com.vanniktech.maven.publish.JavadocJar import com.vanniktech.maven.publish.MavenPublishBaseExtension import groovy.util.Node import io.gitlab.arturbosch.detekt.extensions.DetektExtension -import kotlinx.kover.gradle.plugin.dsl.KoverReportExtension import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { `java-library` alias(libs.plugins.spotless) apply false - jacoco alias(libs.plugins.detekt) `maven-publish` alias(libs.plugins.binary.compatibility.validator) - alias(libs.plugins.jacoco.android) apply false - alias(libs.plugins.kover) apply false alias(libs.plugins.vanniktech.maven.publish) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.multiplatform) apply false @@ -96,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 { @@ -107,13 +103,9 @@ allprojects { TestLogEvent.PASSED, TestLogEvent.FAILED ) - - // Cap JVM args per test - minHeapSize = "256m" - maxHeapSize = "2g" } withType().configureEach { - options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing", "-Xlint:-try")) + options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing", "-Xlint:-try", "-Xlint:-options")) } } } @@ -121,44 +113,6 @@ allprojects { subprojects { apply { plugin("io.sentry.spotless") } - val jacocoAndroidModules = listOf( - "sentry-android-core", - "sentry-android-fragment", - "sentry-android-navigation", - "sentry-android-ndk", - "sentry-android-sqlite", - "sentry-android-replay", - "sentry-android-timber" - ) - if (jacocoAndroidModules.contains(name)) { - afterEvaluate { - jacoco { - toolVersion = "0.8.10" - } - - tasks.withType().configureEach { - configure { - isIncludeNoLocationClasses = true - excludes = listOf("jdk.internal.*") - } - } - } - } - - val koverKmpModules = listOf("sentry-compose") - if (koverKmpModules.contains(name)) { - afterEvaluate { - configure { - androidReports("release") { - xml { - // Change the report file name so the Codecov Github action can find it - setReportFile(project.layout.buildDirectory.file("reports/kover/report.xml").get().asFile) - } - } - } - } - } - plugins.withId(Config.QualityPlugins.detektPlugin) { configure { buildUponDefaultConfig = true @@ -214,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 3410d9601d3..09d2869988b 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -1,8 +1,6 @@ -import java.math.BigDecimal - object Config { - val AGP = System.getenv("VERSION_AGP") ?: "8.13.1" + val AGP = System.getenv("VERSION_AGP") ?: "9.2.1" val kotlinStdLib = "stdlib-jdk8" val kotlinStdLibVersionAndroid = "1.9.24" val kotlinTestJunit = "test-junit" @@ -14,8 +12,10 @@ object Config { object Android { val abiFilters = listOf("x86", "armeabi-v7a", "x86_64", "arm64-v8a") + // Debug variants are disabled everywhere. Unit tests run against the release + // variant, so building the debug variant would only add overhead. fun shouldSkipDebugVariant(name: String?): Boolean { - return System.getenv("CI")?.toBoolean() ?: false && name == "debug" + return name == "debug" } } @@ -37,11 +37,6 @@ object Config { } object QualityPlugins { - object Jacoco { - // TODO [POTEL] add tests and restore - val minimumCoverage = BigDecimal.valueOf(0.1) - } - // this can be removed when we upgrade to Gradle 8, which allows us to use a getter for the plugin ID val detektPlugin = "io.gitlab.arturbosch.detekt" } diff --git a/buildSrc/src/main/java/Publication.kt b/buildSrc/src/main/java/Publication.kt index 0aa717a5630..d545e6e32dc 100644 --- a/buildSrc/src/main/java/Publication.kt +++ b/buildSrc/src/main/java/Publication.kt @@ -7,10 +7,13 @@ private object Consts { val taskRegex = Regex("(.*)DistZip") } +private fun Project.versionName(): String = + providers.gradleProperty("versionName").get() + // configure distZip tasks for multiplatform fun DistributionContainer.configureForMultiplatform(project: Project) { val sep = File.separator - val version = project.properties["versionName"].toString() + val version = project.versionName() val name = project.name this.maybeCreate("android").contents { @@ -69,7 +72,7 @@ fun DistributionContainer.configureForMultiplatform(project: Project) { fun DistributionContainer.configureForJvm(project: Project) { val sep = File.separator - val version = project.properties["versionName"].toString() + val version = project.versionName() val name = project.name this.getByName("main").contents { diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 3a53b1f7b3f..00000000000 --- a/codecov.yml +++ /dev/null @@ -1,23 +0,0 @@ -comment: no -codecov: - require_ci_to_pass: no - max_report_age: off - -coverage: - status: - project: - default: - target: 78% - threshold: 4% - patch: off - range: 78...100 - precision: 3 - round: down - -ignore: - - "**/src/test/*" - - "sentry-android-integration-tests/*" - - "sentry-system-test-support/*" - - "sentry-test-support/*" - - "sentry-samples/*" - - "sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/**" diff --git a/develop-docs/README.md b/develop-docs/README.md new file mode 100644 index 00000000000..b9c2c7913ca --- /dev/null +++ b/develop-docs/README.md @@ -0,0 +1,149 @@ +# Develop Docs + +This folder holds internal developer documentation for the Sentry Java/Android SDK: +architecture notes, feature deep-dives, design decisions, and cross-module concepts +that don't belong in the public [Sentry docs](https://docs.sentry.io) or in inline +code comments. + +If you are documenting **how** or **why** something works for the people who maintain +this SDK, it goes here. If you are documenting **how to use** the SDK for end users, +it belongs in the public docs instead. + +## Rules + +These rules keep the docs consistent, easy to navigate, and easy to grep. + +### Directory structure + +Documents live in **subdirectories**, one level per level of grouping. Directories are +cheap: reach for a new one as soon as a topic has more than one document, or as soon as you +can name the group. + +Every document sits under one of these top-level categories: + +- `general/` — cross-cutting topics (e.g. `general/architecture.md`, `general/pipeline.md`) +- `feature/` — a specific SDK feature (e.g. `feature/errors/`, `feature/profiling/`) +- `integration/` — a specific integration or module (e.g. `integration/opentelemetry/`, `integration/spring/`) +- `platform/` — platform-specific concerns (e.g. `platform/android/`, `platform/jvm/`) +- `process/` — team processes and workflows (e.g. `process/release.md`) + +Add a new category only when an existing one clearly does not fit, and keep the list above +up to date. + +Below the category, nest by topic and then by sub-topic. A fully grown feature might look +like this: + +```text +develop-docs/ + README.md + general/ + pipeline.md + feature/ + profiling/ + overview.md + perfetto.md + anr.md + symbolication/ + deobfuscation.md +``` + +- Give a directory an `overview.md` once it holds several documents, and link to its + siblings from there. +- Do not create a directory that will only ever hold one document — put the document + directly in the category (`general/pipeline.md`, not `general/pipeline/pipeline.md`). + +### File naming + +- File names are **lowercase**, except for this `README.md`, which GitHub renders as the + folder's landing page. +- Use **dashes** (`-`) as separators, never underscores or spaces. For example, use + `session-replay.md`, not `session_replay.md` or `Session Replay.md`. +- Use the `.md` extension for all text documents. +- **Do not repeat the path in the file name.** The directories carry the namespace, so the + file name only needs the part that distinguishes it from its siblings: + `feature/profiling/perfetto.md`, not `feature/profiling/perfetto-profiling.md`. +- Choose short, descriptive names (`feature/replay/masking.md`, not + `feature/replay/how-masking-works.md`). + +### Images and other assets + +- When a document embeds images (or other binary assets), store them in an **`assets/` + folder next to the document**. Documents in the same directory share it: + + ```text + develop-docs/ + feature/ + profiling/ + perfetto.md + assets/ + pipeline.png + overview.svg + ``` + +- Reference assets with **relative paths**: `![Profiling pipeline](assets/pipeline.png)`. +- Asset file names follow the same rules as documents: lowercase, dashes, descriptive. +- Prefer **vector formats** (SVG) for diagrams and screenshots where practical +- Prefer **Mermaid** over a static image whenever a diagram can be expressed as one + (see below) — it lives in the document, is versioned as text, and is easy to update. + +### Writing style + +- Write in the **present tense** and the **active voice**. Describe how the system + behaves now ("The transport retries failed envelopes"), not how it will or did behave. + This way there's no need to update the docs once a feature ships. +- Keep one **top-level `# ` heading** per document (the title), and nest sections with + `##`, `###`, etc. Do not skip heading levels. +- Keep documents focused on a **single topic**. Split large topics into several documents + in a shared directory and link between them rather than growing one giant file. +- Use fenced **code blocks with a language identifier** (```kotlin `, + ` ```bash `) so syntax highlighting works. +- Prefer Kotlin snippets over Java. +- When referencing code, link to the file with a **relative path** (e.g. + `../../../sentry/src/main/java/io/sentry/Sentry.java`) rather than pasting large excerpts + that fall out of date. Count the `../` from the document's own directory. +- Avoid pinning content to a specific SDK version or date unless it is genuinely + version-specific; keep docs evergreen. +- Cross-link related documents with relative links (e.g. + `[the ingestion pipeline](../../general/pipeline.md)`). + +### Structuring a feature document + +Most feature documents answer the same four questions, and following that order makes them +easier to compare and to keep current: + +1. **Surface area** — where and when the SDK collects the data. +2. **Collection** — how the SDK collects it. +3. **Format** — what the collected data looks like on the wire. +4. **Pipeline** — how the backend ingests, stores, and serves it. + +Do not restate (4) in every document. Describe the shared path once in +[general/pipeline.md](general/pipeline.md) and cover only the deviations a feature +introduces. Omit any of the four that a feature does not have, and keep each as high-level +as the topic allows so the document stays true for longer. + +### Diagrams with Mermaid + +- Prefer [Mermaid](https://mermaid.js.org/) for diagrams. It renders directly on GitHub + and lives in the document as text, so it versions and reviews like code. +- Embed a Mermaid diagram in a fenced block tagged `mermaid`: + + ````markdown + ```mermaid + flowchart LR + Event[SentryEvent] --> Processor[EventProcessors] + Processor --> Transport + Transport --> Sentry[(Sentry)] + ``` + ```` + +- For complex diagrams, include a link to the [Mermaid Live Editor](https://mermaid.live/) + so reviewers can iterate quickly. +- Fall back to static images (stored per the asset rules above) if mermaid is not practicable. + +## Adding a new document + +1. Pick the right top-level category (or introduce a new one and document it above). +2. Pick or create the topic directory below it. +3. Create the document, naming it for what distinguishes it from its siblings. +4. If the directory now holds several documents, add or update its `overview.md`. +5. If the document embeds assets, put them in an `assets/` folder next to it. diff --git a/develop-docs/feature/profiling/perfetto.md b/develop-docs/feature/profiling/perfetto.md new file mode 100644 index 00000000000..d4168f8f2a4 --- /dev/null +++ b/develop-docs/feature/profiling/perfetto.md @@ -0,0 +1,232 @@ +# Perfetto profiling on Android + +This document describes how continuous profiling works on Android when the SDK +captures traces through the OS-level [`android.os.ProfilingManager`](https://developer.android.com/reference/android/os/ProfilingManager) +API (available on API 35+), and how a captured **profile chunk** flows all the way +from the device to a downloadable profile in Sentry. + +## What Perfetto is + +[Perfetto](https://perfetto.dev/) is Google's tracing framework for Android and Linux, and +the tooling Android itself is instrumented with. Its +[callstack sampler](https://perfetto.dev/docs/getting-started/cpu-profiling) interrupts the +app at a fixed frequency, records the native and Java call stacks of the running threads, +and writes them to a binary `.pftrace` file (a serialized +[Perfetto protobuf](https://perfetto.dev/docs/reference/trace-packet-proto)). +Starting with Android 15, apps can request such traces at +runtime via `ProfilingManager` without root or `adb`, which is what makes on-device +continuous profiling possible. + +Useful Perfetto references: + +- Perfetto docs: https://perfetto.dev/docs/ +- CPU profiling with Perfetto: https://perfetto.dev/docs/getting-started/cpu-profiling +- Trace format (`TracePacket` proto): https://perfetto.dev/docs/reference/trace-packet-proto +- Perfetto UI (to open a downloaded `.pftrace`): https://ui.perfetto.dev/ + +## Pipeline overview + +Profile chunks travel the standard ingestion path described in +[general/pipeline.md](../../general/pipeline.md) — SDK envelope, +[Relay](https://develop.sentry.dev/ingestion/relay/) (Sentry's ingestion proxy), Kafka, a +monolith processing task, then storage and a read API. Read that first; the rest of this +document covers only where Perfetto deviates from it. + +The deviations are: + +- The envelope item carries **JSON and raw binary in one payload**, subdivided by a + `meta_length` header rather than base64-encoding the trace ([details](#envelope-format-and-the-meta_length-header)). +- Relay **converts** the Perfetto trace into the existing Sample v2 profile format, and + additionally **keeps the raw `.pftrace`** in the object store so it can be downloaded + later ([details](#relay-getsentryrelay)). + +```mermaid +flowchart TD + subgraph device["Android device — sentry-java"] + PM[android.os.ProfilingManager] + PP[PerfettoProfiler] + PCP[PerfettoContinuousProfiler] + PC[ProfileChunk] + ENV["Envelope item
[JSON metadata][raw .pftrace]
header: meta_length"] + PM --> PP --> PCP --> PC --> ENV + end + + subgraph relay["Relay (processing mode)"] + SPLIT[Split payload at meta_length] + CONV[Convert Perfetto → Sample v2] + OS1[Upload raw .pftrace to object store] + KAFKA[["Kafka topic: profiles
ProfileChunkKafkaMessage
(Sample v2 + attachment stored_id)"]] + SPLIT --> CONV --> KAFKA + SPLIT --> OS1 + end + + subgraph monolith["Monolith — getsentry/sentry"] + TASK[process_profile_task] + SYM[Symbolicate / deobfuscate] + VR[vroomrs: parse + normalize] + OS2[(Object store)] + SNUBA[(Snuba: function metrics)] + DB[(ProfileChunkAttachment row)] + TASK --> SYM --> VR + VR --> OS2 + VR --> SNUBA + TASK --> DB + end + + ENV -->|envelope| relay + KAFKA --> TASK + OS1 -.stored_id.-> DB + VROOM[getsentry/vroom
serve + merge flamegraphs] + OS2 --> VROOM + SNUBA --> VROOM +``` + +## SDK (getsentry/sentry-java) + +On API 35+, [`AndroidOptionsInitializer`](../../../sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java) +wires up `PerfettoContinuousProfiler` automatically. On older devices the SDK falls back +to the legacy `Debug`-based [`AndroidContinuousProfiler`](../../../sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java), +gated by the `enableLegacyProfiling` option (manifest key +`io.sentry.profiling.enable-legacy-profiling`, defaults to `true`). Only **continuous +profiling** is supported on the Perfetto path — transaction-based and app-start profiling +are not. + +### Capturing chunks + +Continuous profiling emits a stream of independent [`ProfileChunk`](../../../sentry/src/main/java/io/sentry/ProfileChunk.java)s +rather than one profile per transaction. `PerfettoContinuousProfiler` drives a chained +loop: each chunk runs for `MAX_CHUNK_DURATION_MILLIS` (60s) via `PerfettoProfiler`, which +calls `ProfilingManager.requestProfiling(PROFILING_TYPE_STACK_SAMPLING, …)` at +`PROFILING_FREQUENCY_HZ` (101 Hz). When a chunk's trace file is ready, a new chunk starts, +so profiling runs continuously. + +A chunk keeps a stable `profilerId` across the session and a per-chunk `chunkId`. When the +OS produces the trace file, the profiler builds a `ProfileChunk` tagged with the Perfetto +content type: + +```kotlin +ProfileChunk.Builder(profilerId, chunkId, measurements, traceFile, timestamp, ProfileChunk.PLATFORM_ANDROID) + .setContentType(ProfileChunk.CONTENT_TYPE_PERFETTO) // "application/x-perfetto-trace" + .build() +``` + +The chunk is captured via `scopes.captureProfileChunk(...)` and sent as its own envelope +with item type [`SentryItemType.ProfileChunk`](../../../sentry/src/main/java/io/sentry/SentryItemType.java) +(wire name `profile_chunk`). + +### Envelope format and the `meta_length` header + +A legacy chunk base64-encodes its trace into the `ProfileChunk` JSON. A Perfetto chunk is +much larger, so [`SentryClient`](../../../sentry/src/main/java/io/sentry/SentryClient.java) instead +routes it through the new `SentryEnvelopeItem.fromPerfettoProfileChunk(...)` factory, which +avoids base64 by sending the raw binary alongside the JSON. + +The trick is a single envelope **item** whose payload concatenates the JSON metadata and +the raw `.pftrace` bytes with **no delimiter**: + +```text +[ProfileChunk JSON bytes][raw .pftrace binary bytes] +``` + +A new `meta_length` property on the [envelope item header](../../../sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java) +tells the server where the JSON ends and the binary begins. The standard envelope item +structure (header line + newline + payload) is unchanged; `meta_length` simply subdivides +the payload: + +```text +{"type":"profile_chunk","content_type":"application/x-perfetto-trace","filename":"…","length":,"meta_length":} + +``` + +- `length` — total payload size (JSON + binary), as for any envelope item. +- `meta_length` — byte length of the JSON prefix. It is only known after the payload is + serialized, so the header computes it lazily (via a `Callable`) and omits the + field entirely for non-Perfetto items, keeping the change backward compatible. + +## Relay (getsentry/relay) + +In processing mode Relay: + +1. **Splits** the compound item payload at `meta_length` into `(metadata JSON, raw profile)` + and reads `content_type: "perfetto"` from the metadata. +2. **Converts** the binary Perfetto trace into the existing **Sample v2** profile JSON + format (`relay_profiling::expand_perfetto(...)`, backed by a checked-in subset of the + Perfetto protobuf definitions). +3. **Uploads** the raw `.pftrace` blob to object store (usecase `profiles`, keyed per + org/project, with an attachment-retention TTL). +4. **Produces** a `ProfileChunkKafkaMessage` to the `profiles` Kafka topic. The message + carries the expanded Sample v2 JSON as `payload` plus an `attachments` array, where each + attachment records: + - `name` (e.g. `profile.perfetto`), + - `content_type` (e.g. `application/x-perfetto-trace`), + - `stored_id` — the object store key of the uploaded raw blob. + +```json +{ + "organization_id": 1, + "project_id": 42, + "received": 1720000000, + "retention_days": 30, + "payload": "", + "attachments": [ + { + "name": "profile.perfetto", + "content_type": "application/x-perfetto-trace", + "stored_id": "" + } + ] +} +``` + +The monolith later uses `stored_id` to fetch the raw trace back. + +## Monolith (getsentry/sentry) + +`process_profile_task` (in `src/sentry/profiles/task.py`) consumes the `profiles` topic. +Because Relay already converted the trace to Sample v2, the task treats a Perfetto chunk +like any other: deobfuscate, hand it to `vroomrs` to parse and normalize +(`vroomrs.profile_chunk_from_json_str(...)`), compress and store it, and emit function +metrics to Snuba. + +The Perfetto-specific step is the last one: for each attachment on the message the task +persists a lightweight **`ProfileChunkAttachment`** row — `project_id`, `profiler_id`, +`chunk_id`, `name`, `content_type`, and the `stored_id` object store key. The row exists so +the raw trace can be downloaded by ID without exposing the `stored_id`. + +Flamegraphs themselves are served by `getsentry/vroom`, which reads the stored chunks and +the Snuba-indexed metadata and merges several chunks into one flamegraph. The endpoint +lives in the monolith and passes the request through. + +### Perfetto format dispatch (vroom / vroomrs) + +Older Android SDKs emit the legacy Android trace format tagged as a "faulty" `version=2`, +and the pipeline historically keyed off the platform rather than the version. To +distinguish legacy from Sample v2 chunks, `ProfileChunk` carries a dedicated `version` +field, and both `vroom` and `vroomrs` now dispatch on it instead of the platform: + +- Version `""` or `2.android-trace` → legacy Android trace format. +- Any other version → Sample v2. + +## Downloading a Perfetto profile + +The monolith exposes two feature-gated endpoints: + +- **List attachments** — `GET /organizations/{org}/profiling/chunk-attachments/` + (`sentry-api-0-organization-profiling-chunk-attachments`). Requires a `project` and + `profiler_id`; resolves the visible `chunk_id`s (same logic as the flamegraph) and returns + the matching `ProfileChunkAttachment` metadata. +- **Download** — `GET /projects/{org}/{project}/profiling/chunks/{profiler_id}/{chunk_id}/attachments/{attachment_id}/?download` + (`sentry-api-0-project-profiling-chunk-attachment`). The `?download` param is required; it + streams the raw blob back from object store via the stored `stored_id`. Access requires + the org's configured attachments role, analogous to generic event attachments. + +In the flamegraph UI, a toolbar button (added for continuous profiles when the feature is +enabled and at least one attachment exists) lists and provides a way to download these traces. + +## References + +- SDK: [sentry-java#5251](https://github.com/getsentry/sentry-java/pull/5251) — Android `ProfilingManager` (Perfetto) support +- Relay: [#5659](https://github.com/getsentry/relay/pull/5659), [#5932](https://github.com/getsentry/relay/pull/5932), [#6099](https://github.com/getsentry/relay/pull/6099), [#6102](https://github.com/getsentry/relay/pull/6102) — Perfetto parsing, pipeline, and object-store routing +- vroom: [#672](https://github.com/getsentry/vroom/pull/672) — version dispatch for Android trace profiles +- vroomrs: [#93](https://github.com/getsentry/vroomrs/pull/93) — accept Android profiles in Sample v2 format +- Monolith: [sentry#118029](https://github.com/getsentry/sentry/pull/118029) (chunk attachments + endpoints), [sentry#118071](https://github.com/getsentry/sentry/pull/118071) (flamegraph download button) diff --git a/develop-docs/general/pipeline.md b/develop-docs/general/pipeline.md new file mode 100644 index 00000000000..6cb0d87f696 --- /dev/null +++ b/develop-docs/general/pipeline.md @@ -0,0 +1,121 @@ +# Ingestion pipeline + +This document describes the path data takes from an SDK to a rendered view in Sentry. It +covers the parts of different payload types, like errors, transactions, logs, replays and +profile chunks. + +## Per data category + +Every payload takes the same four hops — SDK, Relay, a consumer in the monolith, and a read +API — but the topics, processing tasks, and stores differ per category. The diagrams below +show three of them; the hops themselves are described further down. + +### Errors + +```mermaid +flowchart LR + SDK["SDK
captures + batches"] -->|envelope| RELAY + RELAY["Relay
authenticate, normalize,
route"] -->|ingest-events| KAFKA[["Kafka"]] + RELAY -.->|attachments,
minidumps| OS[("Object store")] + KAFKA --> TASK["save_event task"] + TASK --> SYM["Symbolicator
symbolicate, deobfuscate"] + SYM --> TASK + TASK --> NS[("Nodestore
full event body")] + TASK --> SNUBA[("Snuba
searchable columns")] + TASK --> PG[("Postgres
Group / GroupHash rows")] + NS --> READ["Read path
monolith API"] + SNUBA --> READ + PG --> READ + OS --> READ +``` + +### Transactions + +```mermaid +flowchart LR + SDK["SDK
captures spans"] -->|envelope| RELAY + RELAY["Relay
normalize, dynamic sampling,
metric extraction"] -->|ingest-transactions| KAFKA[["Kafka"]] + KAFKA --> CONSUMER["Transaction consumer"] + CONSUMER --> SNUBA[("Snuba
transactions + spans")] + CONSUMER --> NS[("Nodestore
full transaction body")] + SNUBA --> READ["Read path
monolith API"] + NS --> READ +``` + +### Profile chunks + +```mermaid +flowchart LR + SDK["SDK
captures profile chunks"] -->|envelope| RELAY + RELAY["Relay
convert Perfetto → Sample v2"] -->|profiles| KAFKA[["Kafka"]] + RELAY -.->|raw .pftrace blob| OS[("Object store")] + KAFKA --> TASK["process_profile_task"] + TASK --> VRS["vroomrs
parse + normalize"] + VRS --> OS + VRS --> SNUBA[("Snuba
function metrics")] + TASK --> PG[("Postgres
ProfileChunkAttachment rows")] + OS --> VROOM["vroom
serve + merge flamegraphs"] + SNUBA --> VROOM + VROOM --> READ["Read path
monolith API"] + PG --> READ +``` + +## The hops + +### 1. SDK + +The SDK captures data and wraps it in an [envelope](https://develop.sentry.dev/sdk/data-model/envelopes/): +a JSON header followed by one or more items, each with its own header declaring a `type`, +a `length`, and optionally a `content_type`. The envelope is POSTed to the project's +`/api/{project_id}/envelope/` endpoint. + +The item `type` is what routes the payload through everything downstream, so adding a new +kind of data means adding an item type, not a new endpoint. Item payloads are usually JSON; +binary payloads are allowed and are preferable to base64-encoding a large blob into JSON. + +### 2. Relay + +[Relay](https://github.com/getsentry/relay) is Sentry's ingestion proxy — it sits between +the SDK and the rest of the infrastructure and is the first service to inspect a payload. +See the [Relay chapter in develop docs](https://develop.sentry.dev/ingestion/relay/) for +the full picture. + +Relay authenticates the DSN, applies quotas and rate limits, filters and normalizes the +payload, and forwards it. Two behaviours matter when designing a new payload type: + +- Relay may **convert** a payload into a different format before publishing it, so the + format the SDK sends and the format the backend consumes are not necessarily the same. + Whatever Relay publishes is the contract every downstream service depends on. +- Relay runs in two modes. Only **processing mode** (the one Sentry operates) talks to + Kafka and the object store; a self-hosted Relay in proxy mode just forwards envelopes + upstream. + +Relay publishes to a **Kafka topic per data category**. Payloads too large to sit +comfortably in a Kafka message are uploaded to the **object store** instead, and the +message carries a reference to the stored blob rather than the bytes themselves. Event +attachments (minidumps, screenshots, view hierarchies) work this way, and so does the raw +`.pftrace` blob of a Perfetto profile chunk: Relay uploads the trace and puts only its +`stored_id` object store key on the Kafka message. + +### 3. Consumers and processing + +Each topic is consumed by the monolith ([getsentry/sentry](https://github.com/getsentry/sentry)), +which runs a processing task per message. This is where the work that needs Sentry-side +state happens — symbolication and deobfuscation against uploaded debug files, enrichment, +normalization, and quota accounting. + +A task typically writes to more than one store: + +- **Object store** — the payload itself, compressed. Cheap to keep, not queryable. +- **Snuba** — the columns that need to be searched, aggregated, or listed. +- **Postgres** — small metadata rows that the API needs to resolve a request, for example + a row per stored blob so it can be fetched by ID instead of by exposing its storage key. + +### 4. Read path + +The monolith serves the API endpoints. For some categories it does the work itself; for +others it authorizes the request and proxies it to a dedicated service that owns the +heavy read logic. Either way the endpoint is the public surface, and the storage keys and +internal services stay behind it. + +See [feature/profiling/perfetto.md](../feature/profiling/perfetto.md) for a worked example. diff --git a/gradle.properties b/gradle.properties index eee4b292bff..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.43.1 +versionName=8.53.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ee39d75ede..bb4d18c7a0e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,20 +1,23 @@ [versions] +animalsniffer = "2.0.1" apollo = "2.5.9" androidxLifecycle = "2.2.0" androidxNavigation = "2.4.2" androidxTestCore = "1.7.0" androidxCompose = "1.6.3" -asyncProfiler = "4.2" +asyncProfiler = "4.4" +camerax = "1.4.0" composeCompiler = "1.5.14" coroutines = "1.6.1" espresso = "3.7.0" feign = "11.6" -jacoco = "0.8.7" +gummyBears = "0.12.0" +java8Signature = "1.0" jackson = "2.18.3" jetbrainsCompose = "1.6.11" -kotlin = "2.2.0" -kotlinSpring7 = "2.2.0" +kotlin = "2.3.21" kotlin-compatible-version = "1.9" +ksp = "2.3.9" ktorClient = "3.0.0" logback = "1.2.9" log4j2 = "2.20.0" @@ -22,34 +25,39 @@ nopen = "1.0.1" # see https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html#kotlin-compatibility # see https://developer.android.com/jetpack/androidx/releases/compose-kotlin okhttp = "4.9.2" -otel = "1.60.1" -otelInstrumentation = "2.26.0" -otelInstrumentationAlpha = "2.26.0-alpha" +openfeature = "1.18.2" +otel = "1.63.0" +otelAlpha = "1.63.0-alpha" +otelInstrumentation = "2.29.0" +otelInstrumentationAlpha = "2.29.0-alpha" # check https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/dependencyManagement/build.gradle.kts#L49 for release version above to find a compatible version -otelSemanticConventions = "1.40.0" -otelSemanticConventionsAlpha = "1.40.0-alpha" +otelSemanticConventions = "1.42.0" +otelSemanticConventionsAlpha = "1.42.0-alpha" retrofit = "2.9.0" +room2 = "2.8.4" +room3 = "3.0.0-rc01" +sagp = "6.13.0" +sqlite = "2.6.2" +sqliteRc = "2.7.0-rc01" # Required by Room3 3.0.0-rc* slf4j = "1.7.30" +spotless = "8.8.0" springboot2 = "2.7.18" springboot3 = "3.5.0" -springboot4 = "4.0.0" +springboot4 = "4.1.0" +sqldelight = "2.3.2" + # Android -targetSdk = "36" -compileSdk = "36" +targetSdk = "37" +compileSdk = "37" minSdk = "21" -spotless = "8.4.0" -gummyBears = "0.12.0" -camerax = "1.4.0" -openfeature = "1.18.2" [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" } -kotlin-spring7 = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlinSpring7" } -kotlin-jvm-spring7 = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinSpring7" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } buildconfig = { id = "com.github.gmazzo.buildconfig", version = "5.6.5" } dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } dokka-javadoc = { id = "org.jetbrains.dokka-javadoc", version = "2.0.0" } @@ -58,18 +66,18 @@ errorprone = { id = "net.ltgt.errorprone", version = "3.0.1" } gradle-versions = { id = "com.github.ben-manes.versions", version = "0.42.0" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.8" } -jacoco-android = { id = "com.mxalbert.gradle.jacoco-android", version = "0.2.0" } -kover = { id = "org.jetbrains.kotlinx.kover", version = "0.7.3" } vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.30.0" } springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" } springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } gretty = { id = "org.gretty", version = "4.0.0" } -animalsniffer = { id = "ru.vyarus.animalsniffer", version = "2.0.1" } -sentry = { id = "io.sentry.android.gradle", version = "6.6.0"} +animalsniffer = { id = "ru.vyarus.animalsniffer", version.ref = "animalsniffer" } +sentry = { id = "io.sentry.android.gradle", version.ref = "sagp"} shadow = { id = "com.gradleup.shadow", version = "9.4.1" } [libraries] +animalsniffer-gradle-plugin = { module = "ru.vyarus:gradle-animalsniffer-plugin", version.ref = "animalsniffer" } apache-httpclient = { module = "org.apache.httpcomponents.client5:httpclient5", version = "5.0.4" } apollo2-coroutines = { module = "com.apollographql.apollo:apollo-coroutines-support", version.ref = "apollo" } apollo2-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" } @@ -94,7 +102,14 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } -androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.5.2" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room2" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" } +androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } +androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteRc" } +androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteRc" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } @@ -116,7 +131,6 @@ jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" jetbrains-annotations = { module = "org.jetbrains:annotations", version = "23.0.0" } kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } -kotlin-test-junit-spring7 = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlinSpring7" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClient" } @@ -139,7 +153,10 @@ otel-exporter-otlp = { module = "io.opentelemetry:opentelemetry-exporter-otlp", otel-exporter-logging = { module = "io.opentelemetry:opentelemetry-exporter-logging", version.ref = "otel" } otel-extension-autoconfigure = { module = "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure", version.ref = "otel" } otel-extension-autoconfigure-spi = { module = "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi", version.ref = "otel" } +otel-bom = { module = "io.opentelemetry:opentelemetry-bom", version.ref = "otel" } +otel-alpha-bom = { module = "io.opentelemetry:opentelemetry-bom-alpha", version.ref = "otelAlpha" } otel-instrumentation-bom = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom", version.ref = "otelInstrumentation" } +otel-instrumentation-alpha-bom = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha", version.ref = "otelInstrumentationAlpha" } otel-javaagent = { module = "io.opentelemetry.javaagent:opentelemetry-javaagent", version.ref = "otelInstrumentation" } otel-javaagent-tooling = { module = "io.opentelemetry.javaagent:opentelemetry-javaagent-tooling", version.ref = "otelInstrumentationAlpha" } otel-javaagent-extension-api = { module = "io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api", version.ref = "otelInstrumentationAlpha" } @@ -152,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.14.2" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.16.3" } servlet-api = { module = "javax.servlet:javax.servlet-api", version = "3.1.0" } servlet-jakarta-api = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.1.0" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } @@ -207,10 +224,12 @@ springboot4-starter-jdbc = { module = "org.springframework.boot:spring-boot-star springboot4-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot4" } springboot4-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot4" } springboot4-starter-kafka = { module = "org.springframework.boot:spring-boot-starter-kafka", version.ref = "springboot4" } +sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } timber = { module = "com.jakewharton.timber:timber", version = "4.7.1" } # Animalsniffer signature gummy-bears-api21 = { module = "com.toasttab.android:gummy-bears-api-21", version.ref = "gummyBears" } +java8-signature = { module = "org.codehaus.mojo.signature:java18", version.ref = "java8Signature" } # tomcat libraries tomcat-catalina = { module = "org.apache.tomcat:tomcat-catalina", version = "9.0.108" } @@ -219,6 +238,7 @@ tomcat-catalina-jakarta = { module = "org.apache.tomcat:tomcat-catalina", versio tomcat-embed-jasper-jakarta = { module = "org.apache.tomcat.embed:tomcat-embed-jasper", version = "11.0.22" } # test libraries +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version = "1.4.1" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version = "1.9.5" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" } androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidxTestCore" } @@ -238,6 +258,7 @@ camerax-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "ca camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } camerax-view = { module = "androidx.camera:camera-view", version.ref = "camerax" } +google-truth = { module = "com.google.truth:truth", version = "1.4.5" } hsqldb = { module = "org.hsqldb:hsqldb", version = "2.6.1" } javafaker = { module = "com.github.javafaker:javafaker", version = "1.0.2" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } @@ -249,3 +270,7 @@ msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } + +[bundles] +androidx-room2 = ["androidx-room-runtime", "androidx-room-ktx"] +androidx-sqlite-drivers = ["androidx-sqlite-bundled", "androidx-sqlite-framework"] diff --git a/gradle/wrapper/gradle-wrapper.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 249549f8366..65bf072f0a0 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -184,6 +184,30 @@ public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/In public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } +public final class io/sentry/android/core/AppStartExtension : io/sentry/IAppStartExtender { + public fun (Lio/sentry/android/core/performance/AppStartMetrics;)V + public fun clear ()V + public fun extendAppStart ()V + public fun finishExtendedAppStart ()V + public fun finishTransaction (Lio/sentry/SentryDate;)V + public fun getExtendedAppStartSpan ()Lio/sentry/ISpan; + public fun getExtendedEndTime ()Lio/sentry/SentryDate; + public fun isActive ()Z + public fun isExtended ()Z + public fun setData (Ljava/lang/String;Ljava/lang/Object;)V + public fun setExtendAppStartListener (Lio/sentry/android/core/AppStartExtension$ExtendAppStartListener;)V +} + +public abstract interface class io/sentry/android/core/AppStartExtension$ExtendAppStartListener { + public abstract fun onExtendAppStartRequested ()Lio/sentry/android/core/AppStartExtension$ExtendedAppStart; +} + +public final class io/sentry/android/core/AppStartExtension$ExtendedAppStart { + public final field span Lio/sentry/ISpan; + public final field transaction Lio/sentry/ITransaction; + public fun (Lio/sentry/ITransaction;Lio/sentry/ISpan;)V +} + public final class io/sentry/android/core/AppState : java/io/Closeable { public fun addAppStateListener (Lio/sentry/android/core/AppState$AppStateListener;)V public fun close ()V @@ -269,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 @@ -338,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; @@ -367,6 +412,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader; public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector; public fun getNativeSdkName ()Ljava/lang/String; + public fun getNdkAppHangTimeoutIntervalMillis ()J public fun getNdkHandlerStrategy ()I public fun getScreenshot ()Lio/sentry/android/core/SentryScreenshotOptions; public fun getStartupCrashDurationThresholdMillis ()J @@ -388,10 +434,12 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableAutoTraceIdGeneration ()Z public fun isEnableFramesTracking ()Z public fun isEnableNdk ()Z + public fun isEnableNdkAppHangTracking ()Z public fun isEnableNetworkEventBreadcrumbs ()Z public fun isEnablePerformanceV2 ()Z public fun isEnableRootCheck ()Z public fun isEnableScopeSync ()Z + public fun isEnableStandaloneAppStartTracing ()Z public fun isEnableSystemEventBreadcrumbs ()Z public fun isEnableSystemEventBreadcrumbsExtras ()Z public fun isReportHistoricalAnrs ()Z @@ -419,15 +467,18 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableAutoTraceIdGeneration (Z)V public fun setEnableFramesTracking (Z)V public fun setEnableNdk (Z)V + public fun setEnableNdkAppHangTracking (Z)V public fun setEnableNetworkEventBreadcrumbs (Z)V public fun setEnablePerformanceV2 (Z)V public fun setEnableRootCheck (Z)V public fun setEnableScopeSync (Z)V + public fun setEnableStandaloneAppStartTracing (Z)V public fun setEnableSystemEventBreadcrumbs (Z)V public fun setEnableSystemEventBreadcrumbsExtras (Z)V public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V public fun setNativeHandlerStrategy (Lio/sentry/android/core/NdkHandlerStrategy;)V public fun setNativeSdkName (Ljava/lang/String;)V + public fun setNdkAppHangTimeoutIntervalMillis (J)V public fun setReportHistoricalAnrs (Z)V public fun setReportHistoricalTombstones (Z)V public fun setTombstoneEnabled (Z)V @@ -525,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 @@ -737,14 +790,22 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static final field staticLock Lio/sentry/util/AutoClosableReentrantLock; public fun ()V public fun addActivityLifecycleTimeSpans (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)V + public fun canExtendAppStart ()Z public fun clear ()V public fun createProcessInitSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getActivityLifecycleTimeSpans ()Ljava/util/List; + public fun getAppStartBaggageHeader ()Ljava/lang/String; public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler; + public fun getAppStartEndTime ()Lio/sentry/SentryDate; + public fun getAppStartExtension ()Lio/sentry/android/core/AppStartExtension; public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler; + public fun getAppStartReason ()Ljava/lang/String; public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision; + public fun getAppStartSentryTraceHeader ()Ljava/lang/String; public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTimeSpanForHeadless ()Lio/sentry/android/core/performance/TimeSpan; public fun getAppStartTimeSpanWithFallback (Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTraceId ()Lio/sentry/protocol/SentryId; public fun getAppStartType ()Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; public fun getApplicationOnCreateTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getClassLoadedUptimeMs ()J @@ -765,12 +826,19 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static fun onContentProviderPostCreate (Landroid/content/ContentProvider;)V public fun registerLifecycleCallbacks (Landroid/app/Application;)V public fun setAppLaunchedInForeground (Z)V + public fun setAppStartBaggageHeader (Ljava/lang/String;)V public fun setAppStartContinuousProfiler (Lio/sentry/IContinuousProfiler;)V + public fun setAppStartEndTime (Lio/sentry/SentryDate;)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V + public fun setAppStartSentryTraceHeader (Ljava/lang/String;)V + public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V + public fun setCachedStartInfo (Landroid/app/ApplicationStartInfo;)V public fun setClassLoadedUptimeMs (J)V + public fun setHeadlessAppStartListener (Lio/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener;)V public fun shouldSendStartMeasurements ()Z + public fun shouldSendStartMeasurements (Z)Z } public final class io/sentry/android/core/performance/AppStartMetrics$AppStartType : java/lang/Enum { @@ -781,6 +849,10 @@ public final class io/sentry/android/core/performance/AppStartMetrics$AppStartTy public static fun values ()[Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; } +public abstract interface class io/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener { + public abstract fun onHeadlessAppStart ()V +} + public class io/sentry/android/core/performance/TimeSpan : java/lang/Comparable { public fun ()V public fun compareTo (Lio/sentry/android/core/performance/TimeSpan;)I diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index abcca4f8833..0e3708a89bf 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -1,12 +1,11 @@ import net.ltgt.gradle.errorprone.errorprone import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -35,13 +34,23 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + + kotlin { compilerOptions.jvmTarget = JVM_1_8 } testOptions { animationsDisabled = true unitTests.apply { isReturnDefaultValues = true isIncludeAndroidResources = true + // Robolectric loads the android-all jar into each test JVM, which needs more heap + // than the default. + all { + it.minHeapSize = "256m" + it.maxHeapSize = "2g" + } } } @@ -74,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 { @@ -106,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/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 9d748e5a27a..f416df6a988 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -9,6 +9,8 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import io.sentry.Baggage; +import io.sentry.BaggageHeader; import io.sentry.FullyDisplayedReporter; import io.sentry.IScope; import io.sentry.IScopes; @@ -18,6 +20,7 @@ import io.sentry.Instrumenter; import io.sentry.Integration; import io.sentry.NoOpTransaction; +import io.sentry.PropagationContext; import io.sentry.SentryDate; import io.sentry.SentryLevel; import io.sentry.SentryNanotimeDate; @@ -33,6 +36,7 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.MeasurementValue; +import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -40,7 +44,7 @@ import java.io.Closeable; import java.io.IOException; import java.lang.ref.WeakReference; -import java.util.Date; +import java.util.Collections; import java.util.Map; import java.util.WeakHashMap; import java.util.concurrent.Future; @@ -55,12 +59,22 @@ public final class ActivityLifecycleIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { static final String UI_LOAD_OP = "ui.load"; + static final String STANDALONE_APP_START_OP = "app.start"; + private static final String STANDALONE_APP_START_NAME = "App Start"; static final String APP_START_WARM = "app.start.warm"; static final String APP_START_COLD = "app.start.cold"; static final String TTID_OP = "ui.load.initial_display"; static final String TTFD_OP = "ui.load.full_display"; + static final String APP_START_EXTENDED_OP = "app.start.extended"; + static final String APP_START_EXTENDED_DESC = "Extended App Start"; static final long TTFD_TIMEOUT_MILLIS = 25000; + // If a headless app start and the following activity's ui.load are more than this far apart, they + // are treated as unrelated and not connected into the same trace. + static final long APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS = TimeUnit.MINUTES.toNanos(1); private static final String TRACE_ORIGIN = "auto.ui.activity"; + static final String APP_START_SCREEN_DATA = "app.vitals.start.screen"; + static final String APP_START_REASON_DATA = "app.vitals.start.reason"; + static final String APP_START_TRACE_ORIGIN = "auto.app.start"; private final @NotNull Application application; private final @NotNull BuildInfoProvider buildInfoProvider; @@ -77,11 +91,12 @@ public final class ActivityLifecycleIntegration private @Nullable FullyDisplayedReporter fullyDisplayedReporter = null; private @Nullable ISpan appStartSpan; + private @Nullable ITransaction appStartTransaction; private final @NotNull WeakHashMap ttidSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap ttfdSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap activitySpanHelpers = new WeakHashMap<>(); - private @NotNull SentryDate lastPausedTime = new SentryNanotimeDate(new Date(0), 0); + private @NotNull SentryDate lastPausedTime = new SentryNanotimeDate(0, 0); private @Nullable Future ttfdAutoCloseFuture = null; // WeakHashMap isn't thread safe but ActivityLifecycleCallbacks is only called from the @@ -124,6 +139,14 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions timeToFullDisplaySpanEnabled = this.options.isEnableTimeToFullDisplayTracing(); application.registerActivityLifecycleCallbacks(this); + + if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + metrics.setHeadlessAppStartListener(this::onHeadlessAppStart); + metrics.getAppStartExtension().setExtendAppStartListener(this::onExtendAppStartRequested); + addIntegrationToSdkVersion("StandaloneAppStart"); + } + this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); addIntegrationToSdkVersion("ActivityLifecycle"); } @@ -135,6 +158,9 @@ private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options @Override public void close() throws IOException { application.unregisterActivityLifecycleCallbacks(this); + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + metrics.setHeadlessAppStartListener(null); + metrics.getAppStartExtension().setExtendAppStartListener(null); if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration removed."); @@ -239,33 +265,111 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); setSpanOrigin(transactionOptions); - // we can only bind to the scope if there's no running transaction - ITransaction transaction = - scopes.startTransaction( - new TransactionContext( - activityName, - TransactionNameSource.COMPONENT, - UI_LOAD_OP, - appStartSamplingDecision), - transactionOptions); + // Guards the headless-start check below with !isExtensionActive so the eager extension's + // stored trace id isn't mistaken for a finished headless start. + final boolean isExtensionActive = + AppStartMetrics.getInstance().getAppStartExtension().isActive(); + + final @Nullable SentryId storedAppStartTraceId = + AppStartMetrics.getInstance().getAppStartTraceId(); + final boolean isFollowingHeadlessAppStart = + !isExtensionActive && (storedAppStartTraceId != null); + + final boolean isAppStart = + !(firstActivityCreated || appStartTime == null || coldStart == null); + final boolean createStandaloneAppStart = + isAppStart + && options.isEnableStandaloneAppStartTracing() + && !isFollowingHeadlessAppStart + && !isExtensionActive; + + if (createStandaloneAppStart) { + final TransactionOptions appStartTransactionOptions = new TransactionOptions(); + appStartTransactionOptions.setBindToScope(false); + appStartTransactionOptions.setStartTimestamp(appStartTime); + appStartTransactionOptions.setAppStartTransaction(appStartSamplingDecision != null); + appStartTransactionOptions.setOrigin(APP_START_TRACE_ORIGIN); + + appStartTransaction = + scopes.startTransaction( + new TransactionContext( + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + appStartSamplingDecision), + appStartTransactionOptions); + appStartTransaction.setData(APP_START_SCREEN_DATA, activityName); + final @Nullable String appStartReason = AppStartMetrics.getInstance().getAppStartReason(); + if (appStartReason != null) { + appStartTransaction.setData(APP_START_REASON_DATA, appStartReason); + } + } + + // Continue either the foreground app.start above or an earlier headless app.start. + final @Nullable String continueSentryTrace; + final @Nullable String continueBaggage; + if (createStandaloneAppStart) { + continueSentryTrace = appStartTransaction.toSentryTrace().getValue(); + final @Nullable BaggageHeader baggageHeader = appStartTransaction.toBaggageHeader(null); + continueBaggage = baggageHeader == null ? null : baggageHeader.getValue(); + } else if (isExtensionActive + || (isFollowingHeadlessAppStart && isWithinAppStartContinuationWindow(ttidStartTime))) { + continueSentryTrace = AppStartMetrics.getInstance().getAppStartSentryTraceHeader(); + continueBaggage = AppStartMetrics.getInstance().getAppStartBaggageHeader(); + } else { + continueSentryTrace = null; + continueBaggage = null; + } + + if (isExtensionActive && isAppStart) { + // Only the launch activity sets the screen, so a later activity can't overwrite it. A + // screen also keeps the processor from classifying the eager app.start as headless. + AppStartMetrics.getInstance() + .getAppStartExtension() + .setData(APP_START_SCREEN_DATA, activityName); + } + + final @Nullable TransactionContext continuedContext = + continueSentryTrace == null + ? null + : continueUiLoadTrace(continueSentryTrace, continueBaggage, activityName); + + final ITransaction transaction; + if (continuedContext != null) { + transaction = scopes.startTransaction(continuedContext, transactionOptions); + } else { + transaction = + scopes.startTransaction( + new TransactionContext( + activityName, + TransactionNameSource.COMPONENT, + UI_LOAD_OP, + appStartSamplingDecision), + transactionOptions); + } + + if (isFollowingHeadlessAppStart || isExtensionActive) { + // Consume the stored app-start trace so a later activity doesn't reuse it. + AppStartMetrics.getInstance().setAppStartTraceId(null); + AppStartMetrics.getInstance().setAppStartSentryTraceHeader(null); + AppStartMetrics.getInstance().setAppStartBaggageHeader(null); + } final SpanOptions spanOptions = new SpanOptions(); setSpanOrigin(spanOptions); - // in case appStartTime isn't available, we don't create a span for it. - if (!(firstActivityCreated || appStartTime == null || coldStart == null)) { - // start specific span for app start - appStartSpan = - transaction.startChild( - getAppStartOp(coldStart), - getAppStartDesc(coldStart), - appStartTime, - Instrumenter.SENTRY, - spanOptions); - - // in case there's already an end time (e.g. due to deferred SDK init) - // we can finish the app-start span - finishAppStartSpan(); + if (isAppStart) { + if (!createStandaloneAppStart && !options.isEnableStandaloneAppStartTracing()) { + appStartSpan = + transaction.startChild( + getAppStartOp(coldStart), + getAppStartDesc(coldStart), + appStartTime, + Instrumenter.SENTRY, + spanOptions); + + finishAppStartSpan(); + } } final @NotNull ISpan ttidSpan = transaction.startChild( @@ -316,6 +420,61 @@ private void setSpanOrigin(final @NotNull SpanOptions spanOptions) { spanOptions.setOrigin(TRACE_ORIGIN); } + /** + * Whether the ui.load starting at {@code uiLoadStartTime} is close enough in time to the headless + * app start to belong to the same trace. If they are more than {@link + * #APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS} apart, they are treated as unrelated. When + * the headless end time is unknown, we keep the previous behaviour and continue the trace. + */ + private boolean isWithinAppStartContinuationWindow(final @NotNull SentryDate uiLoadStartTime) { + final @Nullable SentryDate appStartEndTime = AppStartMetrics.getInstance().getAppStartEndTime(); + if (appStartEndTime == null) { + return true; + } + return uiLoadStartTime.diff(appStartEndTime) <= APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS; + } + + /** + * Builds a {@link TransactionContext} for the ui.load transaction that shares the standalone + * app.start trace (same traceId and sampleRand) while staying a sibling (no parentSpanId), rather + * than a child. The continued baggage keeps sampling decisions on the same sampleRand. Returns + * null if the trace cannot be continued, so callers can fall back. + */ + private @Nullable TransactionContext continueUiLoadTrace( + final @NotNull String sentryTrace, + final @Nullable String baggage, + final @NotNull String activityName) { + if (options == null || !options.isTracingEnabled()) { + return null; + } + final @NotNull PropagationContext propagationContext = + PropagationContext.fromHeaders( + options.getLogger(), + sentryTrace, + baggage == null ? null : Collections.singletonList(baggage), + options); + final @Nullable Boolean parentSampled = propagationContext.isSampled(); + final @NotNull Baggage continuedBaggage = propagationContext.getBaggage(); + final @Nullable TracesSamplingDecision parentSamplingDecision = + parentSampled == null + ? null + : new TracesSamplingDecision( + parentSampled, + continuedBaggage.getSampleRate(), + propagationContext.getSampleRand()); + final @NotNull TransactionContext context = + new TransactionContext( + propagationContext.getTraceId(), + propagationContext.getSpanId(), + null, + parentSamplingDecision, + continuedBaggage); + context.setName(activityName); + context.setTransactionNameSource(TransactionNameSource.COMPONENT); + context.setOperation(UI_LOAD_OP); + return context; + } + @VisibleForTesting void applyScope(final @NotNull IScope scope, final @NotNull ITransaction transaction) { scope.withTransaction( @@ -440,8 +599,7 @@ public void onActivityPostCreated( final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnCreateSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnCreateSpan(getAppStartParent(activity)); } } @@ -479,11 +637,11 @@ public void onActivityStarted(final @NotNull Activity activity) { public void onActivityPostStarted(final @NotNull Activity activity) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnStartSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnStartSpan(getAppStartParent(activity)); // Needed to handle hybrid SDKs helper.saveSpanToAppStartMetrics(); } + finishAppStartSpan(); } @Override @@ -559,6 +717,9 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid // memory leak finishSpan(appStartSpan, SpanStatus.CANCELLED); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.CANCELLED); + } // we finish the ttidSpan as cancelled in case it isn't completed yet final ISpan ttidSpan = ttidSpanMap.get(activity); @@ -575,6 +736,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // set it to null in case its been just finished as cancelled appStartSpan = null; + appStartTransaction = null; ttidSpanMap.remove(activity); ttfdSpanMap.remove(activity); } @@ -592,7 +754,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { private void clear() { firstActivityCreated = false; - lastPausedTime = new SentryNanotimeDate(new Date(0), 0); + lastPausedTime = new SentryNanotimeDate(0, 0); activitySpanHelpers.clear(); } @@ -637,22 +799,23 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); final @NotNull TimeSpan appStartTimeSpan = appStartMetrics.getAppStartTimeSpan(); final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); + final @Nullable SentryDate firstFrameEndDate = + options != null ? options.getDateProvider().now() : null; // and we need to set the end time of the app start here, after the first frame is drawn. if (appStartTimeSpan.hasStarted() && appStartTimeSpan.hasNotStopped()) { - appStartTimeSpan.stop(); + stopTimeSpanAtDate(appStartTimeSpan, firstFrameEndDate); } if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { - sdkInitTimeSpan.stop(); + stopTimeSpanAtDate(sdkInitTimeSpan, firstFrameEndDate); } - finishAppStartSpan(); + finishAppStartSpan(firstFrameEndDate); // Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization // with first frame drawn try (final @NotNull ISentryLifecycleToken ignored = fullyDisplayedLock.acquire()) { - if (options != null && ttidSpan != null) { - final SentryDate endDate = options.getDateProvider().now(); - final long durationNanos = endDate.diff(ttidSpan.getStartDate()); + if (options != null && ttidSpan != null && firstFrameEndDate != null) { + final long durationNanos = firstFrameEndDate.diff(ttidSpan.getStartDate()); final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); ttidSpan.setMeasurement( MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY, durationMillis, MILLISECOND); @@ -664,10 +827,10 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); ttfdSpan.setMeasurement( MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); - finishSpan(ttfdSpan, endDate); + finishSpan(ttfdSpan, firstFrameEndDate); } - finishSpan(ttidSpan, endDate); + finishSpan(ttidSpan, firstFrameEndDate); } else { finishSpan(ttidSpan); if (fullyDisplayedCalled) { @@ -677,6 +840,17 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I } } + private void stopTimeSpanAtDate( + final @NotNull TimeSpan timeSpan, final @Nullable SentryDate endDate) { + final @Nullable SentryDate startDate = timeSpan.getStartTimestamp(); + if (endDate != null && startDate != null) { + final long durationMillis = TimeUnit.NANOSECONDS.toMillis(endDate.diff(startDate)); + timeSpan.setStoppedAt(timeSpan.getStartUptimeMs() + durationMillis); + } else { + timeSpan.stop(); + } + } + private void onFullFrameDrawn(final @NotNull ISpan ttidSpan, final @NotNull ISpan ttfdSpan) { cancelTtfdAutoClose(); // Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization @@ -779,6 +953,16 @@ WeakHashMap getTtfdSpanMap() { } } + private @Nullable ISpan getAppStartParent(final @NotNull Activity activity) { + if (appStartTransaction != null) { + return appStartTransaction; + } + if (appStartSpan != null) { + return appStartSpan; + } + return activitiesWithOngoingTransactions.get(activity); + } + private @NotNull String getAppStartOp(final boolean coldStart) { if (coldStart) { return APP_START_COLD; @@ -788,12 +972,166 @@ WeakHashMap getTtfdSpanMap() { } private void finishAppStartSpan() { + finishAppStartSpan(null); + } + + private void finishAppStartSpan(final @Nullable SentryDate endDate) { final @Nullable SentryDate appStartEndTime = - AppStartMetrics.getInstance() - .getAppStartTimeSpanWithFallback(options) - .getProjectedStopTimestamp(); + endDate != null + ? endDate + : AppStartMetrics.getInstance() + .getAppStartTimeSpanWithFallback(options) + .getProjectedStopTimestamp(); if (performanceEnabled && appStartEndTime != null) { finishSpan(appStartSpan, appStartEndTime); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.OK, appStartEndTime); + } + // Finish the eager extended transaction at the natural first-frame end. waitForChildren keeps + // it open until the extended span finishes; no-op if the app start was not extended. + AppStartMetrics.getInstance().getAppStartExtension().finishTransaction(appStartEndTime); + } + } + + private void onHeadlessAppStart() { + if (scopes == null || options == null || !performanceEnabled) { + return; + } + + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + // Profilers are stopped for headless starts; clear the decision so it doesn't + // leak to a later ui.load transaction if an activity eventually opens. + metrics.setAppStartSamplingDecision(null); + + // For headless starts, appLaunchedInForeground is false, so we can't use + // getAppStartTimeSpanWithFallback (which gates on foreground). + final @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpanForHeadless(); + + if (!appStartTimeSpan.hasStarted() || !appStartTimeSpan.hasStopped()) { + return; + } + + final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp(); + final @Nullable SentryDate endTime = appStartTimeSpan.getProjectedStopTimestamp(); + if (startTime == null || endTime == null) { + return; + } + + // Persist the end time so a later ui.load can tell whether it is close enough to continue this + // trace; without it the continuation window is unbounded. + metrics.setAppStartEndTime(endTime); + + final @NotNull AppStartExtension extension = metrics.getAppStartExtension(); + if (extension.isActive()) { + extension.finishTransaction(endTime); + return; + } + if (!metrics.shouldSendStartMeasurements(true)) { + return; + } + + final @NotNull ITransaction transaction = + createStandaloneAppStartTransaction(startTime, null, false); + transaction.finish(SpanStatus.OK, endTime); + } + + /** + * Creates the standalone {@code app.start} transaction (not bound to the scope) and persists its + * trace headers so a later {@code ui.load} can share the same trace. Shared by the headless path + * and the eager extension path. When {@code holdOpenForExtension} is true, the transaction waits + * for its children and gets a deadline so it stays open until the extended span finishes. + */ + private @NotNull ITransaction createStandaloneAppStartTransaction( + final @NotNull SentryDate startTime, + final @Nullable TracesSamplingDecision samplingDecision, + final boolean holdOpenForExtension) { + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + + final TransactionOptions txnOptions = new TransactionOptions(); + txnOptions.setBindToScope(false); + txnOptions.setStartTimestamp(startTime); + txnOptions.setOrigin(APP_START_TRACE_ORIGIN); + txnOptions.setAppStartTransaction(samplingDecision != null); + if (holdOpenForExtension) { + txnOptions.setWaitForChildren(true); + final long deadlineTimeoutMillis = options.getDeadlineTimeout(); + txnOptions.setDeadlineTimeout(deadlineTimeoutMillis <= 0 ? null : deadlineTimeoutMillis); + // Persist the end time (covering every finish path: user finish, first frame, deadline) so a + // later ui.load can tell whether it is close enough to continue this trace; without it the + // continuation window is unbounded. + txnOptions.setTransactionFinishedCallback( + finishedTransaction -> + AppStartMetrics.getInstance() + .setAppStartEndTime(finishedTransaction.getFinishDate())); } + + final @NotNull TransactionContext txnContext = + new TransactionContext( + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + samplingDecision); + + final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); + final @Nullable String appStartReason = metrics.getAppStartReason(); + if (appStartReason != null) { + transaction.setData(APP_START_REASON_DATA, appStartReason); + } + metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId()); + // Persist trace headers so a later ui.load can share traceId and sampleRand. + metrics.setAppStartSentryTraceHeader(transaction.toSentryTrace().getValue()); + final @Nullable BaggageHeader baggageHeader = transaction.toBaggageHeader(null); + metrics.setAppStartBaggageHeader(baggageHeader == null ? null : baggageHeader.getValue()); + return transaction; + } + + /** + * Handles {@code Sentry.extendAppStart()}: eagerly creates the standalone app.start transaction + * and the extended child span (we have scopes here), then hands both to the {@link + * AppStartExtension}, which owns them. The transaction is held open ({@code waitForChildren}) + * until the user calls {@code Sentry.finishExtendedAppStart()} or the deadline forces it. + * Standalone-only: this is only registered as a listener when standalone app start tracing is + * enabled. + */ + private @Nullable AppStartExtension.ExtendedAppStart onExtendAppStartRequested() { + if (scopes == null + || options == null + || !performanceEnabled + || !options.isEnableStandaloneAppStartTracing()) { + return null; + } + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + + final @NotNull TimeSpan appStartTimeSpan = + metrics.getAppStartTimeSpan().hasStarted() + ? metrics.getAppStartTimeSpan() + : metrics.getSdkInitTimeSpan(); + final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp(); + if (startTime == null) { + return null; + } + + // The app start sampling decision was pre-rolled on the previous run so the app start + // profiler could start before Sentry.init. It forces the trace sampling of the eager + // app.start transaction created below (no re-roll, staying consistent with whether the + // profiler actually started) and lets it bind the app start profiler. It's single-use: + // we clear it so the first ui.load can't also claim it. + final @Nullable TracesSamplingDecision samplingDecision = metrics.getAppStartSamplingDecision(); + metrics.setAppStartSamplingDecision(null); + + final @NotNull ITransaction transaction = + createStandaloneAppStartTransaction(startTime, samplingDecision, true); + + final SpanOptions spanOptions = new SpanOptions(); + setSpanOrigin(spanOptions); + final @NotNull ISpan extendedSpan = + transaction.startChild( + APP_START_EXTENDED_OP, + APP_START_EXTENDED_DESC, + AndroidDateUtils.getCurrentSentryDateTime(), + Instrumenter.SENTRY, + spanOptions); + + return new AppStartExtension.ExtendedAppStart(transaction, extendedSpan); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java index 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 5704cf7d7d4..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)); @@ -198,6 +204,7 @@ static void initializeIntegrationsAndProcessors( } final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + options.setAppStartExtender(appStartMetrics.getAppStartExtension()); if (options.getModulesLoader() instanceof NoOpModulesLoader) { options.setModulesLoader(new AssetsModulesLoader(context, options)); @@ -293,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, @@ -302,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) { @@ -335,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/AppStartExtension.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java new file mode 100644 index 00000000000..3583474cfa7 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java @@ -0,0 +1,184 @@ +package io.sentry.android.core; + +import io.sentry.IAppStartExtender; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ISpan; +import io.sentry.ITransaction; +import io.sentry.Sentry; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SpanStatus; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.util.AutoClosableReentrantLock; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class AppStartExtension implements IAppStartExtender { + + public static final class ExtendedAppStart { + public final @NotNull ITransaction transaction; + public final @NotNull ISpan span; + + public ExtendedAppStart(final @NotNull ITransaction transaction, final @NotNull ISpan span) { + this.transaction = transaction; + this.span = span; + } + } + + public interface ExtendAppStartListener { + @Nullable + ExtendedAppStart onExtendAppStartRequested(); + } + + private final @NotNull AppStartMetrics metrics; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + + private @Nullable ExtendAppStartListener extendAppStartListener; + // We hold onto both the span and its transaction because they mean different things and finish + // at different times: + // + // - extendedSpan is what the app developer works with: they get it from + // getExtendedAppStartSpan(), add their own child spans to it, and finish it by calling + // finishExtendedAppStart(). Its end time is what extends the app start measurement. + // + // - extendedTransaction is the standalone "app.start" transaction that actually gets sent to + // Sentry. It carries the span and the screen name. The SDK asks it to finish at the first + // frame (or headless end), but because it uses waitForChildren it stays open until the span + // finishes (or the deadline is hit). + // + // A span doesn't expose its transaction, and pulling the span back out of the transaction would + // be fragile, so we just keep a reference to each. + private @Nullable ISpan extendedSpan; + private @Nullable ITransaction extendedTransaction; + + public AppStartExtension(final @NotNull AppStartMetrics metrics) { + this.metrics = metrics; + } + + public void setExtendAppStartListener(final @Nullable ExtendAppStartListener listener) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + this.extendAppStartListener = listener; + } + } + + @Override + public void extendAppStart() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (extendedSpan != null) { + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log(SentryLevel.WARNING, "App start is already being extended."); + return; + } + if (!metrics.canExtendAppStart()) { + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Cannot extend app start: the app start window has already passed."); + return; + } + final @Nullable ExtendAppStartListener listener = extendAppStartListener; + if (listener != null) { + final @Nullable ExtendedAppStart extended = listener.onExtendAppStartRequested(); + if (extended != null) { + this.extendedTransaction = extended.transaction; + this.extendedSpan = extended.span; + } + } + } + } + + /** + * Sets data on the owned (eager) transaction if it is still open. Used to attach the screen name + * once the first activity is known, since the transaction is created in {@code onCreate} before + * any activity exists. + */ + public void setData(final @NotNull String key, final @Nullable Object value) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (extendedTransaction != null && !extendedTransaction.isFinished()) { + extendedTransaction.setData(key, value); + } + } + } + + @Override + public void finishExtendedAppStart() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ISpan span = extendedSpan; + if (span != null && !span.isFinished()) { + span.finish(SpanStatus.OK); + } + } + } + + @Override + public @Nullable ISpan getExtendedAppStartSpan() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ISpan span = extendedSpan; + // Mirrors getExtendedEndTime(): the finish date is set before isFinished() flips. + if (span != null && span.getFinishDate() == null) { + return span; + } + return null; + } + } + + public boolean isActive() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return extendedTransaction != null && !extendedTransaction.isFinished(); + } + } + + /** + * Whether this app start was extended at all, regardless of finish or deadline state. Used by the + * event processor to decide whether to apply the never-shorten vital logic. + */ + public boolean isExtended() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return extendedSpan != null; + } + } + + public void finishTransaction(final @NotNull SentryDate endTimestamp) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ITransaction transaction = extendedTransaction; + if (transaction != null && !transaction.isFinished()) { + final @Nullable ISpan span = extendedSpan; + final @Nullable SentryDate spanEnd = span == null ? null : span.getFinishDate(); + final @NotNull SentryDate end = + spanEnd != null && spanEnd.isAfter(endTimestamp) ? spanEnd : endTimestamp; + transaction.finish(SpanStatus.OK, end); + } + } + } + + public @Nullable SentryDate getExtendedEndTime() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ISpan span = extendedSpan; + if (span == null) { + return null; + } + // A deadline timeout would report an artificially inflated duration; suppress the vital + // instead. + if (span.getStatus() == SpanStatus.DEADLINE_EXCEEDED) { + return null; + } + // Read the finish date, not isFinished(): finishing the extended span completes the + // waitForChildren transaction and runs the event processor re-entrantly before the span's + // finished flag is set, but the finish timestamp is already in place. Null until finished. + return span.getFinishDate(); + } + } + + public void clear() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + extendedSpan = null; + extendedTransaction = null; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java 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/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 7671935bb05..83f892573e4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -27,6 +27,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -56,7 +57,8 @@ public DefaultAndroidEventProcessor( // noinspection Convert2MethodRef // some device info performs disk I/O, but it's result is cached, let's pre-cache it @Nullable Future deviceInfoUtil; - final @NotNull ExecutorService executorService = Executors.newSingleThreadExecutor(); + final @NotNull ExecutorService executorService = + Executors.newSingleThreadExecutor(new DeviceInfoCacheThreadFactory()); try { deviceInfoUtil = executorService.submit(() -> DeviceInfoUtil.getInstance(this.context, options)); @@ -425,4 +427,13 @@ private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { public @Nullable Long getOrder() { return 8000L; } + + private static final class DeviceInfoCacheThreadFactory implements ThreadFactory { + @Override + public @NotNull Thread newThread(final @NotNull Runnable r) { + final Thread ret = new Thread(r, "SentryDeviceInfoCache"); + ret.setDaemon(true); + return ret; + } + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index f3b17c5854a..63b88c0e440 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -257,14 +257,19 @@ private void setDeviceIO( @SuppressWarnings("NewApi") @NotNull private TimeZone getTimeZone() { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N) { + // Only use the costly Calendar API on Android 13+ (API Level 33+) when the locale contains a + // Unicode timezone extension (for example "en-US-u-tz-usnyc"), because Calendar honors that + // extension. For all other cases, use the process default timezone directly for performance. + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { LocaleList locales = context.getResources().getConfiguration().getLocales(); if (!locales.isEmpty()) { Locale locale = locales.get(0); - return Calendar.getInstance(locale).getTimeZone(); + if (locale.getUnicodeLocaleType("tz") != null) { + return Calendar.getInstance(locale).getTimeZone(); + } } } - return Calendar.getInstance().getTimeZone(); + return TimeZone.getDefault(); } @SuppressWarnings("JdkObsolete") diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java index 482d90c6e6c..ab95ae32daa 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java @@ -10,8 +10,10 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.FileUtils; import io.sentry.util.Objects; import java.io.Closeable; +import java.io.File; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -67,6 +69,12 @@ private void startOutboxSender( final @NotNull IScopes scopes, final @NotNull SentryOptions options, final @NotNull String path) { + // Create the outbox dir here (on the executor) so the observer can watch it for envelopes + // written by hybrid SDKs, instead of blocking Sentry.init on the mkdirs. + if (!FileUtils.createDirectory(new File(path))) { + options.getLogger().log(SentryLevel.ERROR, "Failed to create outbox dir %s", path); + } + final OutboxSender outboxSender = new OutboxSender( scopes, diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java 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 e16d4b312fc..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 @@ -35,8 +35,14 @@ final class ManifestMetadataReader { static final String ANR_ATTACH_THREAD_DUMPS = "io.sentry.anr.attach-thread-dumps"; static final String ANR_REPORT_HISTORICAL = "io.sentry.anr.report-historical"; + static final String NDK_APP_HANG_TRACKING_ENABLE = "io.sentry.ndk.app-hang.enable"; + + static final String NDK_APP_HANG_TIMEOUT_INTERVAL_MILLIS = + "io.sentry.ndk.app-hang.timeout-interval-millis"; + static final String TOMBSTONE_ENABLE = "io.sentry.tombstone.enable"; static final String TOMBSTONE_ATTACH_RAW = "io.sentry.tombstone.attach-raw"; + static final String TOMBSTONE_REPORT_HISTORICAL = "io.sentry.tombstone.report-historical"; static final String AUTO_INIT = "io.sentry.auto-init"; static final String NDK_ENABLE = "io.sentry.ndk.enable"; @@ -108,8 +114,13 @@ final class ManifestMetadataReader { static final String ENABLE_PERFORMANCE_V2 = "io.sentry.performance-v2.enable"; + static final String ENABLE_STANDALONE_APP_START_TRACING = + "io.sentry.standalone-app-start-tracing.enable"; + static final String ENABLE_APP_START_PROFILING = "io.sentry.profiling.enable-app-start"; + static final String ENABLE_LEGACY_PROFILING = "io.sentry.profiling.enable-legacy-profiling"; + static final String ENABLE_SCOPE_PERSISTENCE = "io.sentry.enable-scope-persistence"; static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.session-replay.session-sample-rate"; @@ -229,6 +240,12 @@ static void applyMetadata( readBool(metadata, logger, TOMBSTONE_ENABLE, options.isTombstoneEnabled())); options.setAttachRawTombstone( readBool(metadata, logger, TOMBSTONE_ATTACH_RAW, options.isAttachRawTombstone())); + options.setReportHistoricalTombstones( + readBool( + metadata, + logger, + TOMBSTONE_REPORT_HISTORICAL, + options.isReportHistoricalTombstones())); // use enableAutoSessionTracking as fallback options.setEnableAutoSessionTracking( @@ -261,6 +278,20 @@ static void applyMetadata( options.setReportHistoricalAnrs( readBool(metadata, logger, ANR_REPORT_HISTORICAL, options.isReportHistoricalAnrs())); + options.setEnableNdkAppHangTracking( + readBool( + metadata, + logger, + NDK_APP_HANG_TRACKING_ENABLE, + options.isEnableNdkAppHangTracking())); + + options.setNdkAppHangTimeoutIntervalMillis( + readLong( + metadata, + logger, + NDK_APP_HANG_TIMEOUT_INTERVAL_MILLIS, + options.getNdkAppHangTimeoutIntervalMillis())); + final @Nullable String dsn = readString(metadata, logger, DSN, options.getDsn()); final boolean enabled = readBool(metadata, logger, ENABLE_SENTRY, options.isEnabled()); @@ -502,10 +533,20 @@ static void applyMetadata( options.setEnablePerformanceV2( readBool(metadata, logger, ENABLE_PERFORMANCE_V2, options.isEnablePerformanceV2())); + options.setEnableStandaloneAppStartTracing( + readBool( + metadata, + logger, + ENABLE_STANDALONE_APP_START_TRACING, + options.isEnableStandaloneAppStartTracing())); + options.setEnableAppStartProfiling( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); + options.setEnableLegacyProfiling( + readBool(metadata, logger, ENABLE_LEGACY_PROFILING, options.isEnableLegacyProfiling())); + options.setEnableScopePersistence( readBool( metadata, logger, ENABLE_SCOPE_PERSISTENCE, options.isEnableScopePersistence())); @@ -743,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; } @@ -753,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; } @@ -763,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 { @@ -785,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; } @@ -796,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/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index f7b51cce620..d758470baf7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -1,13 +1,16 @@ package io.sentry.android.core; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD; +import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM; +import static io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP; import static io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP; import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.ISentryLifecycleToken; import io.sentry.MeasurementUnit; +import io.sentry.SentryDate; import io.sentry.SentryEvent; import io.sentry.SpanContext; import io.sentry.SpanDataConvention; @@ -27,6 +30,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -84,23 +88,64 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { // the app start measurement is only sent once and only if the transaction has // the app.start span, which is automatically created by the SDK. if (hasAppStartSpan(transaction)) { - if (appStartMetrics.shouldSendStartMeasurements()) { + // For headless starts, appLaunchedInForeground is false, so only headless standalone app + // start transactions bypass the foreground check, not the duplicate-send guard. + final @Nullable SpanContext traceContext = transaction.getContexts().getTrace(); + final boolean isStandaloneAppStartTxn = + traceContext != null && STANDALONE_APP_START_OP.equals(traceContext.getOperation()); + final boolean isHeadlessStandaloneAppStartTxn = + traceContext != null + && isStandaloneAppStartTxn + && !traceContext.getData().containsKey(APP_START_SCREEN_DATA); + + if (appStartMetrics.shouldSendStartMeasurements(isHeadlessStandaloneAppStartTxn)) { final @NotNull TimeSpan appStartTimeSpan = - appStartMetrics.getAppStartTimeSpanWithFallback(options); - final long appStartUpDurationMs = appStartTimeSpan.getDurationMs(); + isHeadlessStandaloneAppStartTxn + ? appStartMetrics.getAppStartTimeSpanForHeadless() + : appStartMetrics.getAppStartTimeSpanWithFallback(options); + final long naturalDurationMs = appStartTimeSpan.getDurationMs(); + + final long appStartUpDurationMs; + final boolean shouldAttachAppStartSpans; + final boolean reportAppStartMeasurement; + final @NotNull AppStartExtension extension = appStartMetrics.getAppStartExtension(); + if (extension.isExtended()) { + final @Nullable SentryDate extendedEnd = extension.getExtendedEndTime(); + if (extendedEnd != null && appStartTimeSpan.hasStarted()) { + // Measure to the extended end, but never shorter than the natural first-frame + // duration. + final long extendedDurationMs = + TimeUnit.NANOSECONDS.toMillis(extendedEnd.nanoTimestamp()) + - appStartTimeSpan.getStartTimestampMs(); + appStartUpDurationMs = Math.max(naturalDurationMs, extendedDurationMs); + shouldAttachAppStartSpans = appStartUpDurationMs != 0; + reportAppStartMeasurement = shouldAttachAppStartSpans; + } else { + // Deadline (null) or no valid start: attach the spans but suppress the measurement so + // it isn't inflated. + appStartUpDurationMs = 0; + shouldAttachAppStartSpans = appStartTimeSpan.hasStarted(); + reportAppStartMeasurement = false; + } + } else { + appStartUpDurationMs = naturalDurationMs; + shouldAttachAppStartSpans = appStartUpDurationMs != 0; + reportAppStartMeasurement = shouldAttachAppStartSpans; + } - // if appStartUpDurationMs is 0, metrics are not ready to be sent - if (appStartUpDurationMs != 0) { - final MeasurementValue value = - new MeasurementValue( - (float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName()); + if (shouldAttachAppStartSpans) { + if (reportAppStartMeasurement) { + final MeasurementValue value = + new MeasurementValue( + (float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName()); - final String appStartKey = - appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD - ? MeasurementValue.KEY_APP_START_COLD - : MeasurementValue.KEY_APP_START_WARM; + final String appStartKey = + appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD + ? MeasurementValue.KEY_APP_START_COLD + : MeasurementValue.KEY_APP_START_WARM; - transaction.getMeasurements().put(appStartKey, value); + transaction.getMeasurements().put(appStartKey, value); + } attachAppStartSpans(appStartMetrics, transaction); appStartMetrics.onAppStartSpansSent(); @@ -216,9 +261,7 @@ private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { } final @Nullable SpanContext context = txn.getContexts().getTrace(); - return context != null - && (context.getOperation().equals(APP_START_COLD) - || context.getOperation().equals(APP_START_WARM)); + return context != null && context.getOperation().equals(STANDALONE_APP_START_OP); } private void attachAppStartSpans( @@ -245,6 +288,16 @@ private void attachAppStartSpans( } } + // For standalone app start transactions, the transaction root IS the app start span + if (parentSpanId == null) { + final @NotNull String txnOp = traceContext.getOperation(); + if (STANDALONE_APP_START_OP.equals(txnOp)) { + parentSpanId = traceContext.getSpanId(); + } + } + + final boolean isStandalone = STANDALONE_APP_START_OP.equals(traceContext.getOperation()); + // Process init final @NotNull TimeSpan processInitTimeSpan = appStartMetrics.createProcessInitSpan(); if (processInitTimeSpan.hasStarted() @@ -252,7 +305,11 @@ private void attachAppStartSpans( txn.getSpans() .add( timeSpanToSentrySpan( - processInitTimeSpan, parentSpanId, traceId, APP_METRICS_PROCESS_INIT_OP)); + processInitTimeSpan, + parentSpanId, + traceId, + APP_METRICS_PROCESS_INIT_OP, + isStandalone)); } // Content Providers @@ -263,7 +320,11 @@ private void attachAppStartSpans( txn.getSpans() .add( timeSpanToSentrySpan( - contentProvider, parentSpanId, traceId, APP_METRICS_CONTENT_PROVIDER_OP)); + contentProvider, + parentSpanId, + traceId, + APP_METRICS_CONTENT_PROVIDER_OP, + isStandalone)); } } @@ -272,7 +333,8 @@ private void attachAppStartSpans( if (appOnCreate.hasStopped()) { txn.getSpans() .add( - timeSpanToSentrySpan(appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP)); + timeSpanToSentrySpan( + appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP, isStandalone)); } } @@ -281,14 +343,17 @@ private static SentrySpan timeSpanToSentrySpan( final @NotNull TimeSpan span, final @Nullable SpanId parentSpanId, final @NotNull SentryId traceId, - final @NotNull String operation) { + final @NotNull String operation, + final boolean isStandaloneAppStart) { final Map defaultSpanData = new HashMap<>(2); defaultSpanData.put(SpanDataConvention.THREAD_ID, AndroidThreadChecker.mainThreadSystemId); defaultSpanData.put(SpanDataConvention.THREAD_NAME, "main"); - defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); - defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + if (!isStandaloneAppStart) { + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + } return new SentrySpan( span.getStartTimestampSecs(), diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java new file mode 100644 index 00000000000..9b4433e255e --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java @@ -0,0 +1,88 @@ +package io.sentry.android.core; + +import static io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE; + +import io.sentry.IOptionsObserver; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.cache.CacheUtils; +import io.sentry.cache.PersistingOptionsObserver; +import io.sentry.protocol.SdkVersion; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Persists the app generation that produced the options cache. + * + *

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

For example: + * + *

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

This observer must be registered after {@link PersistingOptionsObserver}. Options observers + * are notified one at a time, so the first callback to this observer writes the generation marker + * only after the preceding observer has persisted the complete options snapshot. + */ +final class PersistingOptionsCacheGenerationObserver implements IOptionsObserver { + static final String APP_LAST_UPDATE_TIME_FILENAME = "app-last-update-time.json"; + + private final @NotNull SentryOptions options; + private final long lastUpdateTime; + + PersistingOptionsCacheGenerationObserver( + final @NotNull SentryOptions options, final long lastUpdateTime) { + this.options = options; + this.lastUpdateTime = lastUpdateTime; + } + + @Override + public void setRelease(final @Nullable String release) { + CacheUtils.store( + options, Long.toString(lastUpdateTime), OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME); + } + + static @Nullable Long read(final @NotNull SentryOptions options) { + final String value = + CacheUtils.read(options, OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME, String.class, null); + if (value == null) { + return null; + } + try { + return Long.valueOf(value); + } catch (NumberFormatException e) { + options.getLogger().log(SentryLevel.ERROR, e, "Failed to read options cache generation."); + return null; + } + } + + @Override + public void setProguardUuid(final @Nullable String proguardUuid) {} + + @Override + public void setSdkVersion(final @Nullable SdkVersion sdkVersion) {} + + @Override + public void setEnvironment(final @Nullable String environment) {} + + @Override + public void setDist(final @Nullable String dist) {} + + @Override + public void setTags(final @NotNull Map tags) {} + + @Override + public void setReplayErrorSampleRate(final @Nullable Double replayErrorSampleRate) {} +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 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 0d249f73790..ab18a5827b9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -5,11 +5,11 @@ import android.content.Context; import android.os.Process; import android.os.SystemClock; +import android.os.Trace; import io.sentry.ILogger; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; -import io.sentry.OptionsContainer; import io.sentry.Sentry; import io.sentry.SentryLevel; import io.sentry.SentryOptions; @@ -96,9 +96,12 @@ public static void init( @NotNull final Context context, @NotNull ILogger logger, @NotNull Sentry.OptionsConfiguration configuration) { + // Started before acquiring the lock so it stays balanced with the endSection() in the finally + // even if acquire() throws. + Trace.beginSection("SentryAndroid.init"); try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { Sentry.init( - OptionsContainer.create(SentryAndroidOptions.class), + new SentryAndroidOptionsContainer(), options -> { final io.sentry.util.LoadClass classLoader = new io.sentry.util.LoadClass(); final boolean isTimberUpstreamAvailable = @@ -220,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/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index bb9ec17aabd..615db97a28d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -37,6 +37,22 @@ public final class SentryAndroidOptions extends SentryOptions { /** Enable or disable ANR on Debug mode Default is disabled Used by AnrIntegration */ private boolean anrReportInDebug = false; + /** + * Enable or disable in-process, heartbeat-based app-hang detection in sentry-native. Default is + * disabled. When enabled, sentry-native's background watchdog captures an app-hang event if no + * heartbeat is received within {@link #ndkAppHangTimeoutIntervalMillis} on the monitored thread. + * + *

This is intended for downstream/hybrid SDKs that emit the heartbeat by calling the native + * {@code sentry_app_hang_heartbeat()} from their main thread. It is independent of the JVM-based + * {@link #anrEnabled} ANR detection. + */ + private boolean enableNdkAppHangTracking = false; + + /** + * The app-hang detection timeout interval in millis used by sentry-native. Default is 5000 = 5s. + */ + private long ndkAppHangTimeoutIntervalMillis = 5000; + /** * Enable or disable automatic breadcrumbs for Activity lifecycle. Using * Application.ActivityLifecycleCallbacks @@ -246,6 +262,8 @@ public interface BeforeCaptureCallback { private boolean enablePerformanceV2 = true; + private boolean enableStandaloneAppStartTracing = false; + private @Nullable SentryFrameMetricsCollector frameMetricsCollector; private boolean enableTombstone = false; @@ -336,6 +354,50 @@ public void setAnrReportInDebug(boolean anrReportInDebug) { this.anrReportInDebug = anrReportInDebug; } + /** + * Checks if heartbeat-based app-hang detection in sentry-native is enabled. Default is disabled. + * + * @return true if enabled or false otherwise + */ + @ApiStatus.Experimental + public boolean isEnableNdkAppHangTracking() { + return enableNdkAppHangTracking; + } + + /** + * Enables or disables heartbeat-based app-hang detection in sentry-native. Default is disabled. + * Requires the NDK integration to be present and emitting heartbeats via the native {@code + * sentry_app_hang_heartbeat()}. + * + * @param enableNdkAppHangTracking true for enabled and false for disabled + */ + @ApiStatus.Experimental + public void setEnableNdkAppHangTracking(boolean enableNdkAppHangTracking) { + this.enableNdkAppHangTracking = enableNdkAppHangTracking; + } + + /** + * Returns the app-hang detection timeout interval in millis used by sentry-native. Default is + * 5000 = 5s. + * + * @return the timeout in millis + */ + @ApiStatus.Experimental + public long getNdkAppHangTimeoutIntervalMillis() { + return ndkAppHangTimeoutIntervalMillis; + } + + /** + * Sets the app-hang detection timeout interval in millis used by sentry-native. Default is 5000 = + * 5s. + * + * @param ndkAppHangTimeoutIntervalMillis the timeout interval in millis + */ + @ApiStatus.Experimental + public void setNdkAppHangTimeoutIntervalMillis(long ndkAppHangTimeoutIntervalMillis) { + this.ndkAppHangTimeoutIntervalMillis = ndkAppHangTimeoutIntervalMillis; + } + /** * Sets Tombstone reporting (ApplicationExitInfo.REASON_CRASH_NATIVE) to enabled or disabled. * @@ -677,6 +739,53 @@ public void setEnablePerformanceV2(final boolean enablePerformanceV2) { this.enablePerformanceV2 = enablePerformanceV2; } + /** + * @return true if standalone app start tracing is enabled. See {@link + * #setEnableStandaloneAppStartTracing(boolean)} for more details. + */ + @ApiStatus.Experimental + public boolean isEnableStandaloneAppStartTracing() { + return enableStandaloneAppStartTracing; + } + + /** + * Enables or disables standalone app start tracing. + * + *

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

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

    + *
  • With an Activity: the SDK sends an "App Start" transaction with operation {@code + * app.start}, plus a separate {@code ui.load} transaction for the Activity. Both + * transactions share the same trace ID. + *
  • Headless app start: for launches started by something like a broadcast receiver, service, + * or content provider without an Activity, the SDK sends only the standalone app-start + * transaction. + *
      + *
    • On devices running Android 15 (API level 35) or newer, the SDK can use {@code + * ApplicationStartInfo} to classify cold versus warm starts and anchor the end time + * at the {@code Application.onCreate} start. + *
    • On devices running older Android versions, headless launches are treated as cold + * once {@code Application.onCreate} finishes without an Activity. The end time falls + * back to the best SDK/plugin timing available. + *
    • With {@code Application.onCreate} instrumentation, the SDK can add an {@code + * application.load} phase span and use the exact {@code Application.onCreate} end + * time. Without that instrumentation, the standalone transaction is still sent, but + * it may only include the {@code process.load} phase span. + *
    + *
  • If an Activity opens after a headless start, its {@code ui.load} transaction reuses the + * app-start trace ID. + *
+ * + * @param enableStandaloneAppStartTracing true if enabled or false otherwise + */ + @ApiStatus.Experimental + public void setEnableStandaloneAppStartTracing(final boolean enableStandaloneAppStartTracing) { + this.enableStandaloneAppStartTracing = enableStandaloneAppStartTracing; + } + @ApiStatus.Internal public @Nullable SentryFrameMetricsCollector getFrameMetricsCollector() { return frameMetricsCollector; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java new file mode 100644 index 00000000000..678f7ab29b2 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java @@ -0,0 +1,16 @@ +package io.sentry.android.core; + +import io.sentry.OptionsContainer; +import org.jetbrains.annotations.NotNull; + +/** + * Direct OptionsContainer for SentryAndroidOptions that avoids reflective + * getDeclaredConstructor().newInstance() on the Android startup path. + */ +final class SentryAndroidOptionsContainer extends OptionsContainer { + + @Override + public @NotNull SentryAndroidOptions createInstance() { + return new SentryAndroidOptions(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/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/SpanFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java index a83454d29b7..074a4a6ea51 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java @@ -13,7 +13,6 @@ import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.protocol.MeasurementValue; import io.sentry.util.AutoClosableReentrantLock; -import java.util.Date; import java.util.Iterator; import java.util.SortedSet; import java.util.TreeSet; @@ -33,7 +32,7 @@ public class SpanFrameMetricsCollector // grow indefinitely in case of a long running span private static final int MAX_FRAMES_COUNT = 3600; private static final long ONE_SECOND_NANOS = TimeUnit.SECONDS.toNanos(1); - private static final SentryNanotimeDate EMPTY_NANO_TIME = new SentryNanotimeDate(new Date(0), 0); + private static final SentryNanotimeDate EMPTY_NANO_TIME = new SentryNanotimeDate(0, 0); private final boolean enabled; protected final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java index c32b05892f9..7090985a38b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -256,8 +256,10 @@ private static ViewHierarchyNode viewToNode(@NotNull final View view) { node.setType(className); try { - final String identifier = ViewUtils.getResourceId(view); - node.setIdentifier(identifier); + final @Nullable String identifier = ViewUtils.getResourceIdOrNull(view); + if (identifier != null) { + node.setIdentifier(identifier); + } } catch (Throwable e) { // ignored } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/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/gestures/AndroidViewGestureTargetLocator.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java index c85fb80dc35..5f6187cd39a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -1,6 +1,5 @@ package io.sentry.android.core.internal.gestures; -import android.content.res.Resources; import android.view.View; import android.widget.AbsListView; import android.widget.ScrollView; @@ -42,13 +41,12 @@ && isViewScrollable(view, isAndroidXAvailable.getValue())) { } private UiElement createUiElement(final @NotNull View targetView) { - try { - final String resourceName = ViewUtils.getResourceId(targetView); - @Nullable String className = ClassUtil.getClassName(targetView); - return new UiElement(targetView, className, resourceName, null, ORIGIN); - } catch (Resources.NotFoundException ignored) { + final @Nullable String resourceName = ViewUtils.getResourceIdOrNull(targetView); + if (resourceName == null) { return null; } + @Nullable String className = ClassUtil.getClassName(targetView); + return new UiElement(targetView, className, resourceName, null, ORIGIN); } private static boolean isViewTappable(final @NotNull View view) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index 8caffedad94..61a32b675db 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -244,6 +244,21 @@ private void startTracing(final @NotNull UiElement target, final @NotNull Gestur } } + // if there's already a transaction bound to the Scope (e.g. started manually by the user), we + // skip starting a new UI transaction: it would never be bound to the Scope in applyScope, would + // gather no children, and would be dropped as an idle transaction without children + final @Nullable ITransaction[] boundTransaction = {null}; + scopes.configureScope(scope -> boundTransaction[0] = scope.getTransaction()); + if (boundTransaction[0] != null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Transaction won't be created for view with id: %s since there's already a transaction bound to the Scope.", + viewIdentifier); + return; + } + // we can only bind to the scope if there's no running transaction final String name = getActivityName(activity) + "." + viewIdentifier; final String op = UI_ACTION + "." + getGestureType(eventType); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java index 501a05a5007..78c73713bd4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -1,13 +1,14 @@ package io.sentry.android.core.internal.gestures; import android.content.res.Resources; +import android.graphics.Matrix; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.gestures.UiElement; -import java.util.LinkedList; +import java.util.ArrayDeque; import java.util.List; import java.util.Queue; import org.jetbrains.annotations.ApiStatus; @@ -17,30 +18,53 @@ @ApiStatus.Internal public final class ViewUtils { - private static final int[] coordinates = new int[2]; - /** - * Verifies if the given touch coordinates are within the bounds of the given view. + * Verifies if the given touch coordinates, expressed in the view's own local coordinate space, + * are within the bounds of the given view. * * @param view the view to check if the touch coordinates are within its bounds - * @param x - the x coordinate of a {@link MotionEvent} - * @param y - the y coordinate of {@link MotionEvent} + * @param localX - the x coordinate of the touch, relative to the view's top-left corner + * @param localY - the y coordinate of the touch, relative to the view's top-left corner * @return true if the touch coordinates are within the bounds of the view, false otherwise */ private static boolean touchWithinBounds( - final @Nullable View view, final float x, final float y) { + final @Nullable View view, final float localX, final float localY) { if (view == null) { return false; } - view.getLocationOnScreen(coordinates); - int vx = coordinates[0]; - int vy = coordinates[1]; + final int w = view.getWidth(); + final int h = view.getHeight(); - int w = view.getWidth(); - int h = view.getHeight(); + return !(localX < 0 || localX > w || localY < 0 || localY > h); + } - return !(x < vx || x > vx + w || y < vy || y > vy + h); + /** + * Maps a touch point expressed in the parent's local coordinate space into the child's local + * coordinate space. This mirrors how {@link ViewGroup} dispatches touch events to its children + * and lets us hit-test the whole tree with a single downward traversal, instead of calling {@link + * View#getLocationOnScreen(int[])} (which walks up to the root) for every view. + */ + private static @NotNull ViewWithLocation mapToChild( + final @NotNull View child, + final float parentX, + final float parentY, + final int parentScrollX, + final int parentScrollY) { + float childX = parentX + parentScrollX - child.getLeft(); + float childY = parentY + parentScrollY - child.getTop(); + + final @Nullable Matrix matrix = child.getMatrix(); + if (matrix != null && !matrix.isIdentity()) { + final Matrix inverse = new Matrix(); + if (matrix.invert(inverse)) { + final float[] point = {childX, childY}; + inverse.mapPoints(point); + childX = point[0]; + childY = point[1]; + } + } + return new ViewWithLocation(child, childX, childY); } /** @@ -48,8 +72,8 @@ private static boolean touchWithinBounds( * given {@code viewTargetSelector}. * * @param decorView - the root view of this window - * @param x - the x coordinate of a {@link MotionEvent} - * @param y - the y coordinate of {@link MotionEvent} + * @param x - the x coordinate of a {@link MotionEvent}, relative to the decor view + * @param y - the y coordinate of {@link MotionEvent}, relative to the decor view * @param targetType - the type of target to find * @return the {@link View} that contains the touch coordinates and complements the {@code * viewTargetSelector} @@ -62,25 +86,35 @@ private static boolean touchWithinBounds( final UiElement.Type targetType) { final List locators = options.getGestureTargetLocators(); - final Queue queue = new LinkedList<>(); - queue.add(decorView); + final Queue queue = new ArrayDeque<>(); + // The touch coordinates from the MotionEvent are already relative to the decor view, i.e. in + // its local coordinate space. + queue.add(new ViewWithLocation(decorView, x, y)); @Nullable UiElement target = null; - while (queue.size() > 0) { - final View view = queue.poll(); + while (!queue.isEmpty()) { + final ViewWithLocation current = queue.poll(); + final View view = current.view; - if (!touchWithinBounds(view, x, y)) { + if (!touchWithinBounds(view, current.x, current.y)) { // if the touch is not hitting the view, skip traversal of its children continue; } if (view instanceof ViewGroup) { final ViewGroup viewGroup = (ViewGroup) view; + final int scrollX = viewGroup.getScrollX(); + final int scrollY = viewGroup.getScrollY(); for (int i = 0; i < viewGroup.getChildCount(); i++) { - queue.add(viewGroup.getChildAt(i)); + final @Nullable View child = viewGroup.getChildAt(i); + if (child != null) { + queue.add(mapToChild(child, current.x, current.y, scrollX, scrollY)); + } } } + // Locators receive the original decor-view-relative coordinates, as the Compose locator + // hit-tests against window coordinates. for (int i = 0; i < locators.size(); i++) { final GestureTargetLocator locator = locators.get(i); final @Nullable UiElement newTarget = locator.locate(view, x, y, targetType); @@ -96,6 +130,18 @@ private static boolean touchWithinBounds( return target; } + private static final class ViewWithLocation { + final @NotNull View view; + final float x; + final float y; + + ViewWithLocation(final @NotNull View view, final float x, final float y) { + this.view = view; + this.x = x; + this.y = y; + } + } + /** * Retrieves the human-readable view id based on {@code view.getContext().getResources()}, falls * back to a hexadecimal id representation in case the view id is not available in the resources. @@ -104,32 +150,37 @@ private static boolean touchWithinBounds( * @return human-readable view id */ static String getResourceIdWithFallback(final @NotNull View view) { - final int viewId = view.getId(); - try { - return getResourceId(view); - } catch (Resources.NotFoundException e) { + final @Nullable String resourceId = getResourceIdOrNull(view); + if (resourceId == null) { // fall back to hex representation of the id - return "0x" + Integer.toString(viewId, 16); + return "0x" + Integer.toString(view.getId(), 16); } + return resourceId; } /** - * Retrieves the human-readable view id based on {@code view.getContext().getResources()}. + * Retrieves the human-readable view id based on {@code view.getContext().getResources()}, or + * {@code null} when the view has no resource-backed id. Returning {@code null} rather than + * throwing avoids exception-driven control flow on hot, main-thread paths such as view-hierarchy + * snapshots and gesture target resolution. * * @param view - the view whose id is being retrieved - * @return human-readable view id - * @throws Resources.NotFoundException in case the view id was not found + * @return human-readable view id, or {@code null} if it cannot be resolved */ - public static String getResourceId(final @NotNull View view) throws Resources.NotFoundException { + public static @Nullable String getResourceIdOrNull(final @NotNull View view) { final int viewId = view.getId(); if (viewId == View.NO_ID || isViewIdGenerated(viewId)) { - throw new Resources.NotFoundException(); + return null; } final Resources resources = view.getContext().getResources(); - if (resources != null) { + if (resources == null) { + return ""; + } + try { return resources.getResourceEntryName(viewId); + } catch (Resources.NotFoundException e) { + return null; } - return ""; } private static boolean isViewIdGenerated(int id) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java index f5ce8a745ce..08432d2f41d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java @@ -45,6 +45,12 @@ public class ThreadDumpParser { private static final Pattern BEGIN_UNMANAGED_NATIVE_THREAD_RE = Pattern.compile("\"(.*)\" (.*) ?sysTid=(\\d+)"); + // e.g. "----- pid 12345 at 2024-01-01 10:00:00.000000000+0000 -----" + private static final Pattern PID_RE = Pattern.compile("----- pid (\\d+) at .*"); + + // e.g. " | sysTid=12345 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8" + private static final Pattern SYS_TID_RE = Pattern.compile("\\s*\\|\\s*sysTid=(\\d+).*"); + // For reference, see native_stack_dump.cc and tombstone_proto_to_text.cpp in Android sources // Groups // 0:entire regex @@ -104,6 +110,11 @@ public class ThreadDumpParser { private final boolean isBackground; + // the process id parsed from the thread dump header; on Linux/Android the main thread's kernel + // thread id (sysTid) always equals the process id, so we use it to reliably detect the main + // thread + private @Nullable Long processId; + private final @NotNull SentryStackTraceFactory stackTraceFactory; private final @NotNull Map debugImages; @@ -139,6 +150,7 @@ public void parse(final @NotNull Lines lines) { final Matcher beginManagedThreadRe = BEGIN_MANAGED_THREAD_RE.matcher(""); final Matcher beginUnmanagedNativeThreadRe = BEGIN_UNMANAGED_NATIVE_THREAD_RE.matcher(""); + final Matcher pidRe = PID_RE.matcher(""); while (lines.hasNext()) { final Line line = lines.next(); @@ -156,10 +168,14 @@ public void parse(final @NotNull Lines lines) { if (thread != null) { threads.add(thread); } + } else if (matches(pidRe, text)) { + processId = getLong(pidRe, 1, null); } else { artContextParser.parseLine(text); } } + + markThreads(); } private SentryThread parseThread(final @NotNull Lines lines) { @@ -185,7 +201,11 @@ private SentryThread parseThread(final @NotNull Lines lines) { return null; } sentryThread.setId(tid); - sentryThread.setName(beginManagedThreadRe.group(1)); + final String name = beginManagedThreadRe.group(1); + sentryThread.setName(name); + if ("main".equals(name)) { + sentryThread.setMain(true); + } final String state = beginManagedThreadRe.group(5); // sanitizing thread that have more details after their actual state, e.g. // "Native (still starting up)" <- we just need "Native" here @@ -205,19 +225,18 @@ private SentryThread parseThread(final @NotNull Lines lines) { } sentryThread.setId(sysTid); sentryThread.setName(beginUnmanagedNativeThreadRe.group(1)); - } - - final String threadName = sentryThread.getName(); - if (threadName != null) { - final boolean isMain = threadName.equals("main"); - sentryThread.setMain(isMain); - // since it's an ANR, the crashed thread will always be main - sentryThread.setCrashed(isMain); - sentryThread.setCurrent(isMain && !isBackground); + if (sysTid.equals(processId)) { + sentryThread.setMain(true); + } } // thread stacktrace final SentryStackTrace stackTrace = parseStacktrace(lines, sentryThread); + final List frames = stackTrace.getFrames(); + if (frames == null || frames.isEmpty()) { + // skip threads without a stacktrace, they are not actionable + return null; + } sentryThread.setStacktrace(stackTrace); return sentryThread; } @@ -238,6 +257,7 @@ private SentryStackTrace parseStacktrace( final Matcher waitingToLockRe = WAITING_TO_LOCK_RE.matcher(""); final Matcher waitingToLockUnknownRe = WAITING_TO_LOCK_UNKNOWN_RE.matcher(""); final Matcher blankRe = BLANK_RE.matcher(""); + final Matcher sysTidRe = SYS_TID_RE.matcher(""); while (lines.hasNext()) { final Line line = lines.next(); @@ -246,7 +266,12 @@ private SentryStackTrace parseStacktrace( break; } final String text = line.text; - if (matches(javaRe, text)) { + if (matches(sysTidRe, text)) { + final Long sysTid = getLong(sysTidRe, 1, null); + if (sysTid != null && sysTid.equals(processId)) { + thread.setMain(true); + } + } else if (matches(javaRe, text)) { final SentryStackFrame frame = new SentryStackFrame(); final String packageName = javaRe.group(1); final String className = javaRe.group(2); @@ -365,6 +390,24 @@ private SentryStackTrace parseStacktrace( return stackTrace; } + private void markThreads() { + for (final @NotNull SentryThread thread : threads) { + if (Boolean.TRUE.equals(thread.isMain())) { + // the OS may have renamed the main thread to the (truncated) process name; normalize it + // back to "main" so downstream consumers see a consistent name + thread.setName("main"); + + // since it's an ANR, the crashed thread will always be main + thread.setCrashed(true); + thread.setCurrent(!isBackground); + } else { + thread.setCrashed(false); + thread.setCurrent(false); + thread.setMain(false); + } + } + } + private boolean matches(final @NotNull Matcher matcher, final @NotNull String text) { matcher.reset(text); return matcher.matches(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java index 1f142b52c9a..c3966615899 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java @@ -114,6 +114,15 @@ private List createThreads( // the backend currently requires a stack-trace in exception exc.setStacktrace(stacktrace); } + + // thread id always equals the process id, + // so we use it to reliably detect the main thread + if (tombstone.pid == threadEntryValue.id) { + // the OS may provide a (truncated) process name; normalize it + // back to "main" so downstream consumers see a consistent name + thread.setName("main"); + thread.setMain(true); + } threads.add(thread); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java index f2612b4aa84..0629b7a4908 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java @@ -112,7 +112,14 @@ public void onDraw() { // OnDrawListeners cannot be removed within onDraw, so we remove it with a // GlobalLayoutListener view.getViewTreeObserver() - .addOnGlobalLayoutListener(() -> view.getViewTreeObserver().removeOnDrawListener(this)); + .addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + view.getViewTreeObserver().removeOnGlobalLayoutListener(this); + view.getViewTreeObserver().removeOnDrawListener(FirstDrawDoneListener.this); + } + }); mainThreadHandler.postAtFrontOfQueue(callback); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/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 241ab1e4cca..2c0ae246558 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java @@ -14,12 +14,14 @@ import android.view.Window; import androidx.annotation.RequiresApi; import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SentryUUID; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.SentryFramesDelayResult; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.lang.ref.WeakReference; import java.lang.reflect.Field; @@ -45,7 +47,8 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLi private final @NotNull Set trackedWindows = new CopyOnWriteArraySet<>(); private final @NotNull ILogger logger; - private @Nullable Handler handler; + private volatile @Nullable Handler handler; + private final @NotNull AutoClosableReentrantLock handlerLock = new AutoClosableReentrantLock(); private @Nullable WeakReference currentWindow; private final @NotNull Map listenerMap = new ConcurrentHashMap<>(); @@ -53,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; @@ -88,7 +91,7 @@ public SentryFrameMetricsCollector( } @SuppressWarnings("deprecation") - @SuppressLint({"NewApi", "PrivateApi"}) + @SuppressLint({"NewApi", "PrivateApi", "DiscouragedPrivateApi"}) public SentryFrameMetricsCollector( final @NotNull Context context, final @NotNull ILogger logger, @@ -113,12 +116,8 @@ public SentryFrameMetricsCollector( } isAvailable = true; - HandlerThread handlerThread = - new HandlerThread("io.sentry.android.core.internal.util.SentryFrameMetricsCollector"); - handlerThread.setUncaughtExceptionHandler( - (thread, e) -> logger.log(SentryLevel.ERROR, "Error during frames measurements.", e)); - handlerThread.start(); - handler = new Handler(handlerThread.getLooper()); + // The frame metrics HandlerThread is started lazily on the first startCollection() call. + // Starting it here would block the main thread on HandlerThread.getLooper() during SDK init. // We have to register the lifecycle callback, even if no profile is started, otherwise when we // start a profile, we wouldn't have the current activity and couldn't get the frameMetrics. @@ -127,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( () -> { @@ -139,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) -> { @@ -166,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; } @@ -218,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) { @@ -281,12 +286,34 @@ public void onActivityDestroyed(@NotNull Activity activity) {} if (!isAvailable) { return null; } + ensureHandlerThreadStarted(); final String uid = SentryUUID.generateSentryId(); listenerMap.put(uid, listener); trackCurrentWindow(); return uid; } + /** + * Lazily starts the background HandlerThread used to receive frame metrics. Deferred out of the + * constructor because {@link HandlerThread#getLooper()} blocks the caller (the main thread during + * SDK init) until the thread is ready, and the handler is only needed once collection starts. + */ + private void ensureHandlerThreadStarted() { + if (handler != null) { + return; + } + try (final @NotNull ISentryLifecycleToken ignored = handlerLock.acquire()) { + if (handler == null) { + final HandlerThread handlerThread = + new HandlerThread("io.sentry.android.core.internal.util.SentryFrameMetricsCollector"); + handlerThread.setUncaughtExceptionHandler( + (thread, e) -> logger.log(SentryLevel.ERROR, "Error during frames measurements.", e)); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + } + } + } + public void stopCollection(final @Nullable String listenerId) { if (!isAvailable) { return; 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 746805fcfdc..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 @@ -10,23 +10,26 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.os.MessageQueue; import android.os.SystemClock; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; import io.sentry.IContinuousProfiler; import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; import io.sentry.NoOpLogger; +import io.sentry.SentryDate; import io.sentry.TracesSamplingDecision; +import io.sentry.android.core.AppStartExtension; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.internal.util.FirstDrawDoneListener; +import io.sentry.protocol.SentryId; import io.sentry.util.AutoClosableReentrantLock; -import io.sentry.util.LazyEvaluator; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -49,6 +52,10 @@ */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { + public interface HeadlessAppStartListener { + void onHeadlessAppStart(); + } + public enum AppStartType { UNKNOWN, COLD, @@ -62,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; @@ -81,9 +81,19 @@ public enum AppStartType { private @Nullable IContinuousProfiler appStartContinuousProfiler = null; private @Nullable TracesSamplingDecision appStartSamplingDecision = null; private boolean isCallbackRegistered = false; - private boolean shouldSendStartMeasurements = true; + private volatile boolean shouldSendStartMeasurements = true; private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); + private final AtomicBoolean headlessAppStartCheckPending = new AtomicBoolean(false); + private final AtomicBoolean headlessAppStartListenerInvoked = new AtomicBoolean(false); + private volatile @Nullable HeadlessAppStartListener headlessAppStartListener; + // Captures a headless app.start so a later ui.load can share its trace. + private @Nullable SentryId appStartTraceId; + private @Nullable String appStartSentryTraceHeader; + private @Nullable String appStartBaggageHeader; + private @Nullable SentryDate appStartEndTime; + private @Nullable ApplicationStartInfo cachedStartInfo; + private final @NotNull AppStartExtension appStartExtension = new AppStartExtension(this); public static @NotNull AppStartMetrics getInstance() { if (instance == null) { @@ -152,13 +162,123 @@ public void setAppStartType(final @NotNull AppStartType appStartType) { return appStartType; } + /** + * The reason the OS started the process, mapped from {@link ApplicationStartInfo#getReason()}. + * Only available on API 35+ (when {@link #cachedStartInfo} was resolved); returns {@code null} + * otherwise or for an unmapped reason. + */ + public @Nullable String getAppStartReason() { + if (cachedStartInfo == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + return null; + } + switch (cachedStartInfo.getReason()) { + case ApplicationStartInfo.START_REASON_ALARM: + return "alarm"; + case ApplicationStartInfo.START_REASON_BACKUP: + return "backup"; + case ApplicationStartInfo.START_REASON_BOOT_COMPLETE: + return "boot_complete"; + case ApplicationStartInfo.START_REASON_BROADCAST: + return "broadcast"; + case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: + return "content_provider"; + case ApplicationStartInfo.START_REASON_JOB: + return "job"; + case ApplicationStartInfo.START_REASON_LAUNCHER: + return "launcher"; + case ApplicationStartInfo.START_REASON_LAUNCHER_RECENTS: + return "launcher_recents"; + case ApplicationStartInfo.START_REASON_PUSH: + return "push"; + case ApplicationStartInfo.START_REASON_SERVICE: + return "service"; + case ApplicationStartInfo.START_REASON_START_ACTIVITY: + return "start_activity"; + case ApplicationStartInfo.START_REASON_OTHER: + return "other"; + default: + return null; + } + } + + /** + * Whether {@link ApplicationStartInfo#getReason()} indicates the OS spawned the app process + * because of an intentional user interaction. + * + * @return true if the user actively launched the app, false if the app was launched in + * background, and null if unknown. + */ + @RequiresApi(api = Build.VERSION_CODES.VANILLA_ICE_CREAM) + private static @Nullable Boolean isForegroundStartReason(final int reason) { + switch (reason) { + case ApplicationStartInfo.START_REASON_LAUNCHER: + case ApplicationStartInfo.START_REASON_LAUNCHER_RECENTS: + case ApplicationStartInfo.START_REASON_START_ACTIVITY: + return true; + case ApplicationStartInfo.START_REASON_ALARM: + case ApplicationStartInfo.START_REASON_BACKUP: + case ApplicationStartInfo.START_REASON_BOOT_COMPLETE: + case ApplicationStartInfo.START_REASON_BROADCAST: + case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: + case ApplicationStartInfo.START_REASON_JOB: + case ApplicationStartInfo.START_REASON_PUSH: + case ApplicationStartInfo.START_REASON_SERVICE: + return false; + case ApplicationStartInfo.START_REASON_OTHER: + default: + return null; + } + } + public boolean isAppLaunchedInForeground() { - return appLaunchedInForeground.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) { + this.headlessAppStartListener = listener; + if (listener != null + && isCallbackRegistered + && activeActivitiesCounter.get() == 0 + && !firstDrawDone.get()) { + scheduleHeadlessAppStartCheckOnMain(); + } + } + + public @Nullable SentryId getAppStartTraceId() { + return appStartTraceId; + } + + public void setAppStartTraceId(final @Nullable SentryId traceId) { + this.appStartTraceId = traceId; + } + + public @Nullable String getAppStartSentryTraceHeader() { + return appStartSentryTraceHeader; + } + + public void setAppStartSentryTraceHeader(final @Nullable String appStartSentryTraceHeader) { + this.appStartSentryTraceHeader = appStartSentryTraceHeader; + } + + public @Nullable String getAppStartBaggageHeader() { + return appStartBaggageHeader; + } + + public void setAppStartBaggageHeader(final @Nullable String appStartBaggageHeader) { + this.appStartBaggageHeader = appStartBaggageHeader; + } + + public @Nullable SentryDate getAppStartEndTime() { + return appStartEndTime; + } + + public void setAppStartEndTime(final @Nullable SentryDate appStartEndTime) { + this.appStartEndTime = appStartEndTime; } /** @@ -186,16 +306,32 @@ public void onAppStartSpansSent() { shouldSendStartMeasurements = false; contentProviderOnCreates.clear(); activityLifecycles.clear(); + appStartExtension.clear(); + } + + public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) { + return shouldSendStartMeasurements && (ignoreForegroundCheck || isAppLaunchedInForeground()); } public boolean shouldSendStartMeasurements() { - return shouldSendStartMeasurements && appLaunchedInForeground.getValue(); + return shouldSendStartMeasurements(false); } public long getClassLoadedUptimeMs() { return CLASS_LOADED_UPTIME_MS; } + /** + * Returns a valid app start time span, bypassing the foreground check. Tries appStartSpan first, + * falls back to sdkInitTimeSpan. Used for headless starts where appLaunchedInForeground is false. + */ + public @NotNull TimeSpan getAppStartTimeSpanForHeadless() { + if (appStartSpan.hasStarted() && appStartSpan.hasStopped()) { + return appStartSpan; + } + return sdkInitTimeSpan; + } + /** * @return the app start time span if it was started and perf-2 is enabled, falls back to the sdk * init time span otherwise @@ -204,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(); @@ -225,6 +361,21 @@ public long getClassLoadedUptimeMs() { return new TimeSpan(); } + public @NotNull AppStartExtension getAppStartExtension() { + return appStartExtension; + } + + /** + * Whether the app start can still be extended: measurements haven't been sent yet, no activity + * has been created, and the first frame hasn't been drawn. The foreground check is ignored so + * headless app starts (broadcast/service) can also be extended. + */ + public boolean canExtendAppStart() { + return shouldSendStartMeasurements(true) + && activeActivitiesCounter.get() == 0 + && !firstDrawDone.get(); + } + @TestOnly void setFirstIdle(final long firstIdle) { this.firstIdle = firstIdle; @@ -252,12 +403,21 @@ public void clear() { } appStartContinuousProfiler = null; appStartSamplingDecision = null; - appLaunchedInForeground.resetValue(); + appLaunchedInForeground = null; isCallbackRegistered = false; shouldSendStartMeasurements = true; firstDrawDone.set(false); activeActivitiesCounter.set(0); firstIdle = -1; + headlessAppStartCheckPending.set(false); + headlessAppStartListenerInvoked.set(false); + headlessAppStartListener = null; + appStartTraceId = null; + appStartSentryTraceHeader = null; + appStartBaggageHeader = null; + appStartEndTime = null; + cachedStartInfo = null; + appStartExtension.clear(); } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -292,6 +452,12 @@ public void setClassLoadedUptimeMs(final long classLoadedUptimeMs) { CLASS_LOADED_UPTIME_MS = classLoadedUptimeMs; } + @TestOnly + @ApiStatus.Internal + public void setCachedStartInfo(final @Nullable ApplicationStartInfo cachedStartInfo) { + this.cachedStartInfo = cachedStartInfo; + } + /** * Called by instrumentation * @@ -335,62 +501,98 @@ 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) { final @Nullable ActivityManager activityManager = (ActivityManager) application.getSystemService(Context.ACTIVITY_SERVICE); if (activityManager != null) { - final List historicalProcessStartReasons = - activityManager.getHistoricalProcessStartReasons(1); - if (!historicalProcessStartReasons.isEmpty()) { - final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0); - if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) { - if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { - appStartType = AppStartType.COLD; - } else { - appStartType = AppStartType.WARM; + try { + final List historicalProcessStartReasons = + activityManager.getHistoricalProcessStartReasons(1); + if (!historicalProcessStartReasons.isEmpty()) { + final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0); + cachedStartInfo = info; + if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) { + if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { + appStartType = AppStartType.COLD; + } else { + appStartType = AppStartType.WARM; + } + appLaunchedInForeground = isForegroundStartReason(info.getReason()); } } + } catch (RuntimeException ignored) { + // getHistoricalProcessStartReasons may throw different kinds of exceptions, namely: + // - SecurityException when called from an isolated process + // - IllegalArgumentException when called with a wrong userId + // - others + // See impl: + // https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java;l=10866-10893 + Log.w("AppStartMetrics", ignored); // no logger instance here, so we just Log } } } + // Fallback, if no matching ApplicationStartInfo is available + if (appLaunchedInForeground == null) { + appLaunchedInForeground = ContextUtils.isForegroundImportance(); + } - if (appStartType == AppStartType.UNKNOWN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (appStartType == AppStartType.UNKNOWN || headlessAppStartListener != null) { + scheduleHeadlessAppStartCheckOnMain(); + } + } + + private void scheduleHeadlessAppStartCheckOnMain() { + if (!headlessAppStartCheckPending.compareAndSet(false, true)) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Looper.getMainLooper() .getQueue() .addIdleHandler( - new MessageQueue.IdleHandler() { - @Override - public boolean queueIdle() { - firstIdle = SystemClock.uptimeMillis(); - checkCreateTimeOnMain(); - return false; - } + () -> { + firstIdle = SystemClock.uptimeMillis(); + headlessAppStartCheckPending.set(false); + handleHeadlessAppStartIfNeededOnMain(); + return false; }); - } else if (appStartType == AppStartType.UNKNOWN) { - // We post on the main thread a task to post a check on the main thread. On Pixel devices - // (possibly others) the first task posted on the main thread is called before the - // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate - // callback is called before the application one. + } else { final Handler handler = new Handler(Looper.getMainLooper()); handler.post( - new Runnable() { - @Override - public void run() { - // not technically correct, but close enough for pre-M - firstIdle = SystemClock.uptimeMillis(); - handler.post(() -> checkCreateTimeOnMain()); - } + () -> { + firstIdle = SystemClock.uptimeMillis(); + handler.post( + () -> { + headlessAppStartCheckPending.set(false); + handleHeadlessAppStartIfNeededOnMain(); + }); }); } } - private void checkCreateTimeOnMain() { - // if no activity has ever been created, app was launched in background + /** + * Checks whether startup reached an Activity after the main looper had a chance to create one. If + * not, handles the headless app start path. Must be called on the main thread. + */ + private void handleHeadlessAppStartIfNeededOnMain() { if (activeActivitiesCounter.get() == 0) { - appLaunchedInForeground.setValue(false); + // SDK init happened after Application.onCreate (e.g. deferred/late init inside an Activity): + // we missed the Activity's onActivityCreated, but a foreground process means it was a real + // launch, not a headless start. Gated on the listener so only the standalone-app-start path + // (which is what could emit a headless transaction) is affected. + if (headlessAppStartListener != null && ContextUtils.isForegroundImportance()) { + return; + } + + appLaunchedInForeground = false; + + // Headless starts have no Activity signal for the pre-API 35 warm/cold heuristic. + // If ApplicationStartInfo did not resolve the type, classify the process start as cold. + if (appStartType == AppStartType.UNKNOWN) { + appStartType = AppStartType.COLD; + } // we stop the app start profilers, as they are useless and likely to timeout if (appStartProfiler != null && appStartProfiler.isRunning()) { @@ -401,6 +603,56 @@ private void checkCreateTimeOnMain() { appStartContinuousProfiler.close(true); appStartContinuousProfiler = null; } + + final @Nullable HeadlessAppStartListener listener = headlessAppStartListener; + if (listener != null && headlessAppStartListenerInvoked.compareAndSet(false, true)) { + resolveHeadlessAppStartEndTime(); + listener.onHeadlessAppStart(); + } + } + } + + private void resolveHeadlessAppStartEndTime() { + // Priority 1: Gradle plugin instrumented onApplicationPostCreate + if (applicationOnCreate.hasStopped()) { + final long stopUptimeMs = + applicationOnCreate.getStartUptimeMs() + applicationOnCreate.getDurationMs(); + stopHeadlessAppStartAt(stopUptimeMs); + return; + } + + // Priority 2: API 35+ ApplicationStartInfo (cached from registerLifecycleCallbacks) + if (cachedStartInfo != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + try { + final @NotNull Map timestamps = cachedStartInfo.getStartupTimestamps(); + final @Nullable Long onCreateStartNanos = + timestamps.get(ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); + if (onCreateStartNanos != null) { + // The framework captures this timestamp with SystemClock.uptimeNanos() right *before* + // invoking Application.onCreate (see ActivityThread.handleBindApplication), so it marks + // the onCreate start, not its end. Without plugin instrumentation there is no onCreate + // end signal, so this is the best available lower bound for the app start end time. + // Same clock base as TimeSpan, so it can be used directly without re-anchoring. + final long onCreateStartUptimeMs = TimeUnit.NANOSECONDS.toMillis(onCreateStartNanos); + stopHeadlessAppStartAt(onCreateStartUptimeMs); + return; + } + } catch (Throwable ignored) { + // Best effort: never let optional startup timestamp enrichment break app startup. + } + } + + // Priority 3: Process init end time (CLASS_LOADED_UPTIME_MS) + stopHeadlessAppStartAt(CLASS_LOADED_UPTIME_MS); + } + + private void stopHeadlessAppStartAt(final long stopUptimeMs) { + if (appStartSpan.hasStarted()) { + if (appStartSpan.hasNotStopped()) { + appStartSpan.setStoppedAt(stopUptimeMs); + } + } else if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { + sdkInitTimeSpan.setStoppedAt(stopUptimeMs); } } @@ -413,10 +665,16 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) { final long nowUptimeMs = SystemClock.uptimeMillis(); - // If the app (process) was launched more than 1 minute ago, consider it a warm start + // If the app (process) was launched more than 1 minute ago, consider it a warm start. + // NOTE: meaningless in standalone app start mode, where a headless start is already its own + // standalone transaction and therefore cannot be re-classified as warm. final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); - if (!appLaunchedInForeground.getValue() - || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { + // An active extension explicitly keeps the launch alive: resetting the span here would make + // the extended vital measure from the activity while the eager app.start transaction stays + // anchored at process start. + if ((!isAppLaunchedInForeground() + || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) + && !appStartExtension.isActive()) { appStartType = AppStartType.WARM; shouldSendStartMeasurements = true; appStartSpan.reset(); @@ -435,7 +693,7 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved } } } - appLaunchedInForeground.setValue(true); + appLaunchedInForeground = true; } @Override @@ -472,12 +730,16 @@ public void onActivityStopped(@NonNull Activity activity) { public void onActivityDestroyed(@NonNull Activity activity) { CurrentActivityHolder.getInstance().clearActivity(activity); - final int remainingActivities = activeActivitiesCounter.decrementAndGet(); + int remainingActivities = activeActivitiesCounter.decrementAndGet(); + if (remainingActivities < 0) { + activeActivitiesCounter.set(0); + remainingActivities = 0; + } // if the app is moving into background // as the next 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 9e94d7b9905..c79d418efe5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -4,9 +4,11 @@ import android.app.Activity import android.app.ActivityManager import android.app.ActivityManager.RunningAppProcessInfo import android.app.Application +import android.app.ApplicationStartInfo import android.content.Context import android.os.Build import android.os.Bundle +import android.os.Handler import android.os.Looper import android.view.View import android.view.ViewTreeObserver @@ -21,22 +23,26 @@ import io.sentry.Scopes import io.sentry.Sentry import io.sentry.SentryDate import io.sentry.SentryDateProvider +import io.sentry.SentryExecutorService import io.sentry.SentryNanotimeDate +import io.sentry.SentryTraceHeader import io.sentry.SentryTracer import io.sentry.Span +import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.SpanStatus.OK import io.sentry.TraceContext +import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.TransactionFinishedCallback import io.sentry.TransactionOptions import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.core.performance.AppStartMetrics.AppStartType import io.sentry.protocol.MeasurementValue +import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource import io.sentry.test.DeferredExecutorService import io.sentry.test.getProperty -import java.util.Date import java.util.concurrent.Future import java.util.concurrent.TimeUnit import kotlin.test.AfterTest @@ -52,6 +58,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.ArgumentCaptor +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor @@ -64,6 +71,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager @@ -83,6 +91,9 @@ class ActivityLifecycleIntegrationTest { // start it var transaction: SentryTracer = mock() val buildInfo = mock() + val createdTransactions = mutableListOf() + val capturedContexts = mutableListOf() + val capturedOptions = mutableListOf() fun getSut( apiVersion: Int = Build.VERSION_CODES.Q, @@ -102,8 +113,13 @@ class ActivityLifecycleIntegrationTest { val contextCaptor = argumentCaptor() whenever(scopes.startTransaction(contextCaptor.capture(), optionCaptor.capture())) .thenAnswer { - val t = SentryTracer(contextCaptor.lastValue, scopes, optionCaptor.lastValue) + val context = contextCaptor.lastValue + val options = optionCaptor.lastValue + val t = SentryTracer(context, scopes, options) transaction = t + createdTransactions.add(t) + capturedContexts.add(context) + capturedOptions.add(options) return@thenAnswer t } whenever(buildInfo.sdkInfoVersion).thenReturn(apiVersion) @@ -225,6 +241,527 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + fun `Standalone app start transaction op is app start`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + verify(fixture.scopes, times(2)).startTransaction(any(), any()) + + val contexts = fixture.capturedContexts + val appStartContext = contexts.single { + it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("App Start", appStartContext.name) + assertEquals(TransactionNameSource.COMPONENT, appStartContext.transactionNameSource) + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("Activity", appStartTransaction.getData("app.vitals.start.screen")) + assertTrue(contexts.any { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP }) + assertFalse( + contexts.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) + fun `Standalone app start transaction carries app start reason when available`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + val startInfo = + mock().apply { + whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER) + } + AppStartMetrics.getInstance().setCachedStartInfo(startInfo) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("launcher", appStartTransaction.getData("app.vitals.start.reason")) + } + + @Test + fun `Standalone app start transaction has no app start reason when unavailable`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertNull(appStartTransaction.getData("app.vitals.start.reason")) + } + + @Test + fun `extendAppStart eagerly creates a standalone app start transaction with the extended span`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertTrue( + appStartTransaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_EXTENDED_OP + } + ) + assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) + assertNotNull(AppStartMetrics.getInstance().appStartExtension.extendedAppStartSpan) + } + + @Test + fun `extended app start continues the trace into ui load without a second app start transaction`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransactions = + fixture.createdTransactions.filter { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals(1, appStartTransactions.size) + assertEquals("Activity", appStartTransactions.single().getData("app.vitals.start.screen")) + val uiLoadTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.UI_LOAD_OP + } + assertEquals( + appStartTransactions.single().spanContext.traceId, + uiLoadTransaction.spanContext.traceId, + ) + } + + @Test + fun `extended app start trace is not reused by a later activity`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val firstActivity = mock() + sut.onActivityCreated(firstActivity, fixture.bundle) + val appStartTraceId = + fixture.createdTransactions + .single { it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP } + .spanContext + .traceId + + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + AppStartMetrics.getInstance().onAppStartSpansSent() + + val secondActivity = mock() + sut.onActivityPaused(firstActivity) + sut.onActivityCreated(secondActivity, fixture.bundle) + + assertNotEquals(appStartTraceId, fixture.createdTransactions.last().spanContext.traceId) + } + + @Test + fun `extended app start screen is not overwritten by a later activity`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val firstActivity = mock() + sut.onActivityCreated(firstActivity, fixture.bundle) + + sut.onActivityPaused(firstActivity) + sut.onActivityCreated(mock(), fixture.bundle) + + val appStart = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("Activity", appStart.getData("app.vitals.start.screen")) + } + + @Test + fun `extended standalone app start transaction stays open until finishExtendedAppStart`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + + appStartTransaction.finish(SpanStatus.OK) + assertFalse(appStartTransaction.isFinished) + + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + assertTrue(appStartTransaction.isFinished) + } + + @Test + fun `extended headless app start transaction stays open until finishExtendedAppStart`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + driveHeadlessAppStart() + + val transaction = fixture.createdTransactions.single() + assertTrue( + transaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_EXTENDED_OP + } + ) + assertFalse(transaction.isFinished) + + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + assertTrue(transaction.isFinished) + } + + @Test + fun `extended headless app start persists the app start end time`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + driveHeadlessAppStart() + + assertNotNull(AppStartMetrics.getInstance().getAppStartEndTime()) + } + + @Test + fun `finished eager extended app start persists the app start end time`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + assertNull(AppStartMetrics.getInstance().getAppStartEndTime()) + + AppStartMetrics.getInstance().appStartExtension.finishTransaction(SentryNanotimeDate()) + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + + assertNotNull(AppStartMetrics.getInstance().getAppStartEndTime()) + } + + @Test + fun `activity long after the eager extended app start finished starts a fresh trace`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + // the eager extension starts at launch and finishes before any activity exists + setAppStartTime(date = SentryNanotimeDate(1, 0)) + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + val appStartTraceId = fixture.capturedContexts.single().traceId + AppStartMetrics.getInstance() + .appStartExtension + .extendedAppStartSpan!! + .finish(SpanStatus.OK, SentryNanotimeDate(2, 0)) + AppStartMetrics.getInstance().appStartExtension.finishTransaction(SentryNanotimeDate(2, 0)) + + // the first activity opens more than a minute after the extension finished + setAppStartTime(date = SentryNanotimeDate(TimeUnit.MINUTES.toMillis(2), 0)) + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val uiLoadContext = + fixture.capturedContexts.last { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP } + // too far apart: the ui.load gets its own fresh trace, not the finished app.start one + assertNotEquals(appStartTraceId, uiLoadContext.traceId) + // stored continuation state is still consumed so nothing reuses it + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `extended headless app start does not create a duplicate when the extension already finished`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + AppStartMetrics.getInstance().onAppStartSpansSent() + val transactionsBefore = fixture.createdTransactions.size + + driveHeadlessAppStart() + + assertEquals(transactionsBefore, fixture.createdTransactions.size) + } + + @Test + fun `extendAppStart is a no-op when standalone tracing is disabled`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + assertFalse(AppStartMetrics.getInstance().appStartExtension.isActive) + assertNull(AppStartMetrics.getInstance().appStartExtension.extendedAppStartSpan) + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `extended app start transaction is owned by the extension and survives activity destroy`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) + + sut.onActivityDestroyed(activity) + assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) + fun `Headless standalone app start transaction carries app start reason when available`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + val startInfo = + mock().apply { + whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_BROADCAST) + } + AppStartMetrics.getInstance().setCachedStartInfo(startInfo) + + driveHeadlessAppStart() + + val transaction = fixture.createdTransactions.single() + assertEquals("broadcast", transaction.getData("app.vitals.start.reason")) + } + + @Test + fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.UNKNOWN) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.STANDALONE_APP_START_OP, + fixture.capturedContexts.single().operation, + ) + assertEquals("App Start", fixture.capturedContexts.single().name) + } + + @Test + fun `HeadlessAppStartListener is not registered when standalone flag is off`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `HeadlessAppStartListener is not registered when performance is disabled`() { + val sut = fixture.getSut { it.isEnableStandaloneAppStartTracing = true } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `close clears HeadlessAppStartListener`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + sut.close() + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `onHeadlessAppStart creates standalone App Start transaction and stashes trace id`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + assertEquals("auto.app.start", options.origin) + assertFalse(options.isBindToScope) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun `onHeadlessAppStart creates standalone App Start transaction on API 23`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessSdkInitAppStart() + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + fun `onHeadlessAppStart creates standalone App Start transaction when appStartType is WARM`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.WARM) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + } + + @Test + fun `onHeadlessAppStart does nothing when appStartTimeSpan is incomplete`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + AppStartMetrics.getInstance().appStartTimeSpan.reset() + AppStartMetrics.getInstance().sdkInitTimeSpan.reset() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + @Test fun `Activity transaction uses custom deadline timeout when autoTransactionDeadlineTimeoutMillis is set to positive value`() { val sut = fixture.getSut() @@ -383,6 +920,8 @@ class ActivityLifecycleIntegrationTest { it.idleTimeout = 100 } ) + // the transaction idle timeout is scheduled on the dedicated timer executor + fixture.options.timerExecutorService = SentryExecutorService() sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, fixture.bundle) @@ -528,6 +1067,27 @@ class ActivityLifecycleIntegrationTest { assertTrue(span.isFinished) } + @Test + fun `When Activity is destroyed, sets standalone appStartTransaction status to cancelled and finish it`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + sut.onActivityDestroyed(activity) + + val appStartTransaction = + fixture.createdTransactions[ + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP)] + assertEquals(SpanStatus.CANCELLED, appStartTransaction.status) + assertTrue(appStartTransaction.isFinished) + } + @Test fun `When Activity is destroyed, sets appStartSpan to null`() { val sut = fixture.getSut() @@ -713,7 +1273,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(false) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) fixture.options.dateProvider = SentryDateProvider { date } @@ -738,7 +1298,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(false) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) val activity = mock() @@ -761,8 +1321,8 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(false) - val date = SentryNanotimeDate(Date(1), 0) - val date2 = SentryNanotimeDate(Date(2), 2) + val date = SentryNanotimeDate(1, 0) + val date2 = SentryNanotimeDate(2, 2) setAppStartTime(date) val activity = mock() @@ -788,7 +1348,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut { it.tracesSampleRate = 1.0 } sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(true) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) val activity = mock() @@ -807,8 +1367,8 @@ class ActivityLifecycleIntegrationTest { sut.setFirstActivityCreated(false) // usually set by SentryPerformanceProvider - val date = SentryNanotimeDate(Date(1), 0) - val date2 = SentryNanotimeDate(Date(2), 2) + val date = SentryNanotimeDate(1, 0) + val date2 = SentryNanotimeDate(2, 2) val activity = mock() // Activity onCreate date will be used @@ -833,7 +1393,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually set by SentryPerformanceProvider - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) val activity = mock() @@ -857,7 +1417,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually set by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) val appStartMetrics = AppStartMetrics.getInstance() appStartMetrics.appStartType = AppStartType.WARM @@ -882,6 +1442,274 @@ class ActivityLifecycleIntegrationTest { assertNull(appStartSpan) } + @Test + fun `launcher activity emits ui load and standalone App Start sharing trace id`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + val firstFrameDate = SentryNanotimeDate(1499, 0) + fixture.options.dateProvider = SentryDateProvider { firstFrameDate } + setAppStartTime(SentryNanotimeDate(1, 0)) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(2, fixture.capturedContexts.size) + val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val uiLoadTransaction = fixture.createdTransactions[uiLoadIndex] + val appStartTransaction = fixture.createdTransactions[appStartIndex] + + assertEquals(uiLoadTransaction.spanContext.traceId, appStartTransaction.spanContext.traceId) + assertEquals("auto.app.start", fixture.capturedOptions[appStartIndex].origin) + assertEquals("auto.ui.activity", fixture.capturedOptions[uiLoadIndex].origin) + assertFalse(fixture.capturedOptions[appStartIndex].isBindToScope) + assertFalse( + uiLoadTransaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + + sut.onActivityPostCreated(activity, fixture.bundle) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + sut.onActivityPostStarted(activity) + + assertTrue(appStartTransaction.children.any { it.operation == "activity.load" }) + + sut.onActivityResumed(activity) + runFirstDraw(fixture.createView()) + + val ttidSpan = + uiLoadTransaction.children.single { it.operation == ActivityLifecycleIntegration.TTID_OP } + assertTrue(ttidSpan.isFinished) + assertTrue(appStartTransaction.isFinished) + assertEquals(ttidSpan.finishDate, appStartTransaction.finishDate) + assertEquals( + ttidSpan.measurements[MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY]!!.value, + AppStartMetrics.getInstance().appStartTimeSpan.durationMs, + ) + } + + @Test + fun `launcher activity attaches lifecycle spans before finishing stopped standalone App Start`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + val appStartEndDate = SentryNanotimeDate(499, 0) + setAppStartTime(SentryNanotimeDate(1, 0), appStartEndDate) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityCreated(activity, fixture.bundle) + + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val appStartTransaction = fixture.createdTransactions[appStartIndex] + assertFalse(appStartTransaction.isFinished) + + sut.onActivityPostCreated(activity, fixture.bundle) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + sut.onActivityPostStarted(activity) + + val activityLoadSpans = appStartTransaction.children.filter { it.operation == "activity.load" } + assertEquals(2, activityLoadSpans.size) + assertTrue(activityLoadSpans.all { it.isFinished }) + assertTrue(appStartTransaction.isFinished) + assertEquals(appStartEndDate.nanoTimestamp(), appStartTransaction.finishDate!!.nanoTimestamp()) + } + + @Test + fun `activity following a headless start reuses trace id and does not emit second standalone`() { + val storedTraceId = SentryId() + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + // headless start always stores the trace header alongside the trace id; the ui.load txn + // continues that trace via continueTrace, sharing the trace id. + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + assertEquals(storedTraceId, context.traceId) + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `activity within a minute of the headless start continues the same trace`() { + val storedTraceId = SentryId() + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + // headless start ended right before the activity opens + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(0, 0) + sut.register(fixture.scopes, fixture.options) + setAppStartTime(date = SentryNanotimeDate(1, 0)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + assertEquals(storedTraceId, context.traceId) + } + + @Test + fun `activity more than a minute after the headless start starts a fresh trace`() { + val storedTraceId = SentryId() + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + // headless start ended at epoch, but the activity opens more than a minute later + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(0, 0) + sut.register(fixture.scopes, fixture.options) + setAppStartTime(date = SentryNanotimeDate(TimeUnit.MINUTES.toMillis(2), 0)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + // too far apart: the ui.load gets its own fresh trace, not the stored one + assertNotEquals(storedTraceId, context.traceId) + // stored continuation state is still consumed so nothing reuses it + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `onHeadlessAppStart stores sentry-trace and baggage headers for continuation`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + + driveHeadlessAppStart() + + val transaction = fixture.createdTransactions.single() + val metrics = AppStartMetrics.getInstance() + val sentryTraceHeader = metrics.appStartSentryTraceHeader + val baggageHeader = metrics.appStartBaggageHeader + assertNotNull(sentryTraceHeader) + assertNotNull(baggageHeader) + // sentry-trace carries the standalone app.start trace id so a later ui.load txn can continue it + assertTrue(sentryTraceHeader.startsWith(transaction.spanContext.traceId.toString())) + } + + @Test + fun `launcher activity shares standalone App Start trace and sampleRand as a sibling`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + // the app-start sampling decision carries the sampleRand the whole trace should share + AppStartMetrics.getInstance() + .setAppStartSamplingDecision(TracesSamplingDecision(true, 1.0, 0.42)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(2, fixture.capturedContexts.size) + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) + // app.start is created first so it roots the trace; ui.load shares it + assertTrue(appStartIndex < uiLoadIndex) + + val appStartContext = fixture.capturedContexts[appStartIndex] + val uiLoadContext = fixture.capturedContexts[uiLoadIndex] + assertEquals(appStartContext.traceId, uiLoadContext.traceId) + // both share the same sampleRand + assertEquals(0.42, appStartContext.baggage?.sampleRand) + assertEquals(0.42, uiLoadContext.baggage?.sampleRand) + // siblings, not parent/child: ui.load has no parent span id + assertNull(uiLoadContext.parentSpanId) + } + + @Test + fun `activity following a headless start shares stored trace and sampleRand as a sibling and clears headers`() { + val sut = fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + // 1) a headless start emits the standalone app.start and stores its trace headers + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + driveHeadlessAppStart() + val appStartTransaction = fixture.createdTransactions.single() + + // 2) an activity opens and shares the stored trace instead of emitting a second standalone + setAppStartTime() + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val uiLoadContext = + fixture.capturedContexts.last { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP } + assertFalse( + fixture.capturedContexts.drop(1).any { + it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + ) + assertEquals(appStartTransaction.spanContext.traceId, uiLoadContext.traceId) + // siblings, not parent/child: ui.load has no parent span id + assertNull(uiLoadContext.parentSpanId) + + // stored continuation state is consumed + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + assertNull(AppStartMetrics.getInstance().appStartSentryTraceHeader) + assertNull(AppStartMetrics.getInstance().appStartBaggageHeader) + } + + @Test + fun `standalone flag off launcher activity emits single ui load with nested app start cold child`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.UI_LOAD_OP, + fixture.capturedContexts.single().operation, + ) + assertTrue( + fixture.createdTransactions.single().children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD + } + ) + } + @Test fun `When SentryPerformanceProvider is disabled, app start time span is still created`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) @@ -890,7 +1718,7 @@ class ActivityLifecycleIntegrationTest { // usually done by SentryPerformanceProvider, if disabled it's done by // SentryAndroid.init - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.WARM @@ -916,7 +1744,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.WARM AppStartMetrics.getInstance().sdkInitTimeSpan.setStoppedAt(1234) @@ -940,7 +1768,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.WARM @@ -975,7 +1803,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(true) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime() val activity = mock() @@ -1147,6 +1975,8 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true + // the timeout has to be really scheduled for cancelling it to be observable + fixture.options.executorService = DeferredExecutorService() sut.register(fixture.scopes, fixture.options) val activity = mock() val activity2 = mock() @@ -1489,14 +2319,14 @@ class ActivityLifecycleIntegrationTest { @Test fun `When sentry is initialized mid activity lifecycle, last paused time should be used in favor of app start time`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) - val now = SentryNanotimeDate(Date(1234), 456) + val now = SentryNanotimeDate(1234, 456) fixture.options.tracesSampleRate = 1.0 fixture.options.dateProvider = SentryDateProvider { now } sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(5678), 910) + val startDate = SentryNanotimeDate(5678, 910) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.COLD @@ -1520,7 +2350,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.scopes, fixture.options) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) assertTrue(sut.activitySpanHelpers.isEmpty()) @@ -1537,8 +2367,8 @@ class ActivityLifecycleIntegrationTest { fun `Creates activity lifecycle spans`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) - val startDate = SentryNanotimeDate(Date(2), 0) + val appStartDate = SentryNanotimeDate(1, 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -1577,7 +2407,7 @@ class ActivityLifecycleIntegrationTest { fun `Creates activity lifecycle spans even when no app start span is available`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val startDate = SentryNanotimeDate(Date(2), 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -1635,8 +2465,8 @@ class ActivityLifecycleIntegrationTest { fun `Creates activity lifecycle spans on API lower than 29`() { val sut = fixture.getSut(apiVersion = Build.VERSION_CODES.P) fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) - val startDate = SentryNanotimeDate(Date(2), 0) + val appStartDate = SentryNanotimeDate(1, 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -1688,8 +2518,8 @@ class ActivityLifecycleIntegrationTest { fun `Does not add activity lifecycle spans when firstActivityCreated is true`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) - val startDate = SentryNanotimeDate(Date(2), 0) + val appStartDate = SentryNanotimeDate(1, 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -1710,7 +2540,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is false and app start span has stopped, restart app start to current date`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) + val appStartDate = SentryNanotimeDate(1, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() setAppStartTime(appStartDate) @@ -1737,8 +2567,61 @@ class ActivityLifecycleIntegrationTest { shadowOf(Looper.getMainLooper()).idle() } + private fun driveHeadlessAppStart() { + // A headless start (broadcast/service) runs in a non-foreground-importance process. The + // foreground guard in AppStartMetrics suppresses the headless path for foreground processes + // (deferred init inside an Activity), so headless scenarios must simulate background + // importance. + mockStatic(ContextUtils::class.java).use { contextUtils -> + contextUtils.`when` { ContextUtils.isForegroundImportance() }.thenReturn(false) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + } + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + shadowOf(Looper.getMainLooper()).idle() + } + + private fun prepareHeadlessAppStart( + appStartType: AppStartType = AppStartType.COLD, + startUptimeMs: Long = 100, + endUptimeMs: Long = 200, + ) { + AppStartMetrics.getInstance().apply { + this.appStartType = appStartType + setClassLoadedUptimeMs(endUptimeMs) + appStartTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + sdkInitTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + } + } + + private fun prepareHeadlessSdkInitAppStart(startUptimeMs: Long = 100, endUptimeMs: Long = 200) { + AppStartMetrics.getInstance().apply { + appStartTimeSpan.reset() + sdkInitTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + setClassLoadedUptimeMs(endUptimeMs) + } + } + + private fun transactionIndexForOperation(operation: String): Int { + val index = fixture.capturedContexts.indexOfFirst { it.operation == operation } + assertTrue(index >= 0) + return index + } + private fun setAppStartTime( - date: SentryDate = SentryNanotimeDate(Date(1), 0), + date: SentryDate = SentryNanotimeDate(1, 0), stopDate: SentryDate? = null, ) { // set by SentryPerformanceProvider so forcing it here @@ -1762,3 +2645,5 @@ class ActivityLifecycleIntegrationTest { } } } + +private open class SecondAppStartActivity : Activity() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt index 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/AndroidProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt index 66bda9bce0b..f402af24f80 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt @@ -81,8 +81,6 @@ class AndroidProfilerTest { override fun close(timeoutMillis: Long) {} override fun isClosed() = false - - override fun prewarm() = Unit } val options = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt index 0829e4dc796..b37a6bbdee5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt @@ -89,8 +89,6 @@ class AndroidTransactionProfilerTest { override fun close(timeoutMillis: Long) {} override fun isClosed() = false - - override fun prewarm() = Unit } val options = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt new file mode 100644 index 00000000000..7fbbca4a3a5 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt @@ -0,0 +1,259 @@ +package io.sentry.android.core + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ISpan +import io.sentry.ITransaction +import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate +import io.sentry.SpanStatus +import io.sentry.android.core.performance.AppStartMetrics +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.N]) +class AppStartExtensionTest { + + private val metrics = mock() + + private fun extension(windowOpen: Boolean = true): AppStartExtension { + whenever(metrics.canExtendAppStart()).thenReturn(windowOpen) + return AppStartExtension(metrics) + } + + /** Simulates the integration's listener: hands a transaction + span back to the extension. */ + private fun AppStartExtension.registerHandOver( + txn: ITransaction = mock(), + span: ISpan = mock(), + ): Pair { + setExtendAppStartListener { AppStartExtension.ExtendedAppStart(txn, span) } + return txn to span + } + + @Test + fun `extendAppStart fires the listener when the window is open`() { + val ext = extension(windowOpen = true) + val calls = AtomicInteger() + ext.setExtendAppStartListener { + calls.incrementAndGet() + null + } + ext.extendAppStart() + assertEquals(1, calls.get()) + } + + @Test + fun `extendAppStart does not fire the listener when the window is closed`() { + val ext = extension(windowOpen = false) + val calls = AtomicInteger() + ext.setExtendAppStartListener { + calls.incrementAndGet() + null + } + ext.extendAppStart() + assertEquals(0, calls.get()) + } + + @Test + fun `extendAppStart is inert when no listener is registered`() { + val ext = extension(windowOpen = true) + ext.extendAppStart() + assertNull(ext.extendedAppStartSpan) + assertFalse(ext.isActive) + } + + @Test + fun `extendAppStart is ignored when already extending`() { + val ext = extension(windowOpen = true) + val calls = AtomicInteger() + val txn = mock() + val span = mock() + ext.setExtendAppStartListener { + calls.incrementAndGet() + AppStartExtension.ExtendedAppStart(txn, span) + } + ext.extendAppStart() + ext.extendAppStart() + assertEquals(1, calls.get()) + } + + @Test + fun `getExtendedAppStartSpan returns null when no extension is active`() { + assertNull(extension().extendedAppStartSpan) + } + + @Test + fun `getExtendedAppStartSpan returns the span while extending`() { + val ext = extension(windowOpen = true) + val (_, span) = ext.registerHandOver() + ext.extendAppStart() + assertSame(span, ext.extendedAppStartSpan) + } + + @Test + fun `finishExtendedAppStart finishes the extended span`() { + val ext = extension(windowOpen = true) + val (_, span) = ext.registerHandOver() + ext.extendAppStart() + ext.finishExtendedAppStart() + verify(span).finish(SpanStatus.OK) + } + + @Test + fun `finishExtendedAppStart does not finish an already finished span`() { + val ext = extension(windowOpen = true) + val span = mock() + whenever(span.isFinished).thenReturn(true) + ext.registerHandOver(span = span) + ext.extendAppStart() + ext.finishExtendedAppStart() + verify(span, never()).finish(any()) + } + + @Test + fun `isActive reflects the transaction state`() { + val ext = extension(windowOpen = true) + assertFalse(ext.isActive) + val (txn, _) = ext.registerHandOver() + ext.extendAppStart() + assertTrue(ext.isActive) + whenever(txn.isFinished).thenReturn(true) + assertFalse(ext.isActive) + } + + @Test + fun `isExtended stays true once extended, even after the transaction finishes`() { + val ext = extension(windowOpen = true) + assertFalse(ext.isExtended) + val (txn, _) = ext.registerHandOver() + ext.extendAppStart() + assertTrue(ext.isExtended) + whenever(txn.isFinished).thenReturn(true) + assertFalse(ext.isActive) + assertTrue(ext.isExtended) + } + + @Test + fun `finishTransaction finishes the transaction at the given timestamp`() { + val ext = extension(windowOpen = true) + val (txn, _) = ext.registerHandOver() + ext.extendAppStart() + val endTimestamp = SentryNanotimeDate() + ext.finishTransaction(endTimestamp) + verify(txn).finish(SpanStatus.OK, endTimestamp) + } + + @Test + fun `finishTransaction does not finish an already finished transaction`() { + val ext = extension(windowOpen = true) + val txn = mock() + whenever(txn.isFinished).thenReturn(true) + ext.registerHandOver(txn = txn) + ext.extendAppStart() + ext.finishTransaction(SentryNanotimeDate()) + verify(txn, never()).finish(any(), any()) + } + + @Test + fun `finishTransaction ends at the extended span end when it finished after the given timestamp`() { + // Headless: the extended span can finish (in onCreate) before finishTransaction runs (at idle) + // with a finish date later than the headless end. The transaction must end there so it contains + // the extended span and its duration matches the app start vital. + val ext = extension(windowOpen = true) + val txn = mock() + val span = mock() + val spanEnd = SentryLongDate(2_000_000_000L) + whenever(span.finishDate).thenReturn(spanEnd) + ext.registerHandOver(txn = txn, span = span) + ext.extendAppStart() + ext.finishTransaction(SentryLongDate(1_000_000_000L)) + verify(txn).finish(SpanStatus.OK, spanEnd) + } + + @Test + fun `getExtendedEndTime is null while the span is unfinished`() { + val ext = extension(windowOpen = true) + ext.registerHandOver() + ext.extendAppStart() + assertNull(ext.extendedEndTime) + } + + @Test + fun `getExtendedEndTime is null when the extension finished via deadline`() { + val ext = extension(windowOpen = true) + val span = mock() + whenever(span.isFinished).thenReturn(true) + whenever(span.status).thenReturn(SpanStatus.DEADLINE_EXCEEDED) + whenever(span.finishDate).thenReturn(SentryNanotimeDate()) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertNull(ext.extendedEndTime) + } + + @Test + fun `getExtendedEndTime returns the finish date on a user finish`() { + val ext = extension(windowOpen = true) + val finishDate = SentryNanotimeDate() + val span = mock() + whenever(span.isFinished).thenReturn(true) + whenever(span.status).thenReturn(SpanStatus.OK) + whenever(span.finishDate).thenReturn(finishDate) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertSame(finishDate, ext.extendedEndTime) + } + + @Test + fun `getExtendedEndTime returns the finish date even when the span still reports unfinished`() { + // Reproduces the waitForChildren reentrancy: finishing the extended span completes the + // transaction and runs the event processor before the span's isFinished() flips, while the + // finish timestamp is already set. getExtendedEndTime() must read the finish date, not the + // flag. + val ext = extension(windowOpen = true) + val finishDate = SentryNanotimeDate() + val span = mock() + whenever(span.isFinished).thenReturn(false) + whenever(span.status).thenReturn(SpanStatus.OK) + whenever(span.finishDate).thenReturn(finishDate) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertSame(finishDate, ext.extendedEndTime) + } + + @Test + fun `clear clears the extension state`() { + val ext = extension(windowOpen = true) + ext.registerHandOver() + ext.extendAppStart() + assertTrue(ext.isActive) + ext.clear() + assertFalse(ext.isActive) + assertNull(ext.extendedAppStartSpan) + } + + @Test + fun `getExtendedAppStartSpan returns null once the finish date is set even if still unfinished`() { + // Same waitForChildren reentrancy as getExtendedEndTime: the finish timestamp is set before the + // span's isFinished() flips, so the span must not be handed out for new children anymore. + val ext = extension(windowOpen = true) + val span = mock() + whenever(span.isFinished).thenReturn(false) + whenever(span.finishDate).thenReturn(SentryNanotimeDate()) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertNull(ext.extendedAppStartSpan) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/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/DeviceInfoUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt index 6d90d6be538..faf993e1610 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt @@ -2,16 +2,22 @@ package io.sentry.android.core import android.content.Context import android.content.Intent +import android.content.res.Configuration import android.os.BatteryManager +import android.os.Build +import android.os.LocaleList import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.internal.util.CpuInfoUtils +import java.util.Locale +import java.util.TimeZone import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.runner.RunWith +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) class DeviceInfoUtilTest { @@ -47,6 +53,32 @@ class DeviceInfoUtilTest { assertNotNull(deviceInfo.memorySize) } + @Test + fun `sets default timezone`() { + val deviceInfoUtil = DeviceInfoUtil.getInstance(context, SentryAndroidOptions()) + val deviceInfo = deviceInfoUtil.collectDeviceInformation(false, false) + + assertEquals(TimeZone.getDefault(), deviceInfo.timezone) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `preserves timezone from locale unicode extension`() { + val defaultTimeZone = TimeZone.getDefault() + try { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + val configuration = Configuration(context.resources.configuration) + configuration.setLocales(LocaleList(Locale.forLanguageTag("en-US-u-tz-usnyc"))) + val localizedContext = context.createConfigurationContext(configuration) + val deviceInfoUtil = DeviceInfoUtil(localizedContext, SentryAndroidOptions()) + val deviceInfo = deviceInfoUtil.collectDeviceInformation(false, false) + + assertEquals("America/New_York", deviceInfo.timezone?.id) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + @Test fun `does include cpu data`() { CpuInfoUtils.getInstance().setCpuMaxFrequencies(listOf(1024)) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt index 97276d67566..0b13f4ca4d8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt @@ -14,6 +14,8 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -122,4 +124,19 @@ class EnvelopeFileObserverIntegrationTest { verify(fixture.logger) .log(eq(SentryLevel.DEBUG), eq("EnvelopeFileObserverIntegration installed.")) } + + @Test + fun `register creates the outbox dir when it does not exist yet`() { + val outboxDir = File(file, "outbox") + assertFalse(outboxDir.exists()) + + fixture.getSut { it.executorService = ImmediateExecutorService() } + val integration = + object : EnvelopeFileObserverIntegration() { + override fun getPath(options: SentryOptions): String = outboxDir.absolutePath + } + integration.register(fixture.scopes, fixture.scopes.options) + + assertTrue(outboxDir.isDirectory) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt 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 d8ac959601a..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 @@ -288,6 +288,56 @@ class ManifestMetadataReaderTest { assertEquals(false, fixture.options.isAttachAnrThreadDump) } + @Test + fun `applyMetadata reads app hang tracking enabled to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.NDK_APP_HANG_TRACKING_ENABLE to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(true, fixture.options.isEnableNdkAppHangTracking) + } + + @Test + fun `applyMetadata reads app hang tracking enabled to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(false, fixture.options.isEnableNdkAppHangTracking) + } + + @Test + fun `applyMetadata reads app hang timeout interval to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.NDK_APP_HANG_TIMEOUT_INTERVAL_MILLIS to 1000) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(1000.toLong(), fixture.options.ndkAppHangTimeoutIntervalMillis) + } + + @Test + fun `applyMetadata reads app hang timeout interval to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(5000.toLong(), fixture.options.ndkAppHangTimeoutIntervalMillis) + } + @Test fun `applyMetadata reads tombstone attach raw to options`() { // Arrange @@ -313,6 +363,56 @@ class ManifestMetadataReaderTest { assertEquals(false, fixture.options.isAttachRawTombstone) } + @Test + fun `applyMetadata reads tombstone enable to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.TOMBSTONE_ENABLE to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(true, fixture.options.isTombstoneEnabled) + } + + @Test + fun `applyMetadata reads tombstone enable to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(false, fixture.options.isTombstoneEnabled) + } + + @Test + fun `applyMetadata reads tombstone report historical to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.TOMBSTONE_REPORT_HISTORICAL to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(true, fixture.options.isReportHistoricalTombstones) + } + + @Test + fun `applyMetadata reads tombstone report historical to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(false, fixture.options.isReportHistoricalTombstones) + } + @Test fun `applyMetadata reads anr report historical to options`() { // Arrange @@ -1492,6 +1592,36 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isEnablePerformanceV2) } + @Test + fun `applyMetadata reads standalone app start tracing flag to options`() { + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to true) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertTrue(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing false to options`() { + fixture.options.isEnableStandaloneAppStartTracing = true + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to false) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing flag to options and keeps default if not found`() { + val context = fixture.getContext() + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + @Test fun `applyMetadata reads startupProfiling flag to options`() { // Arrange @@ -1519,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/NetworkBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt index 4f6ba7fc5f0..711f5f7fe0b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt @@ -5,7 +5,6 @@ import android.net.Network import android.net.NetworkCapabilities import android.os.Build import io.sentry.Breadcrumb -import io.sentry.DateUtils import io.sentry.IScopes import io.sentry.ISentryExecutorService import io.sentry.SentryDateProvider @@ -54,8 +53,9 @@ class NetworkBreadcrumbsIntegrationTest { executorService = executor isEnableNetworkEventBreadcrumbs = enableNetworkEventBreadcrumbs dateProvider = SentryDateProvider { - val nowNanos = TimeUnit.MILLISECONDS.toNanos(nowMs ?: System.currentTimeMillis()) - SentryNanotimeDate(DateUtils.nanosToDate(nowNanos), nowNanos) + val nowMillis = nowMs ?: System.currentTimeMillis() + val nowNanos = TimeUnit.MILLISECONDS.toNanos(nowMillis) + SentryNanotimeDate(nowMillis, nowNanos) } } return NetworkBreadcrumbsIntegration(context, buildInfo) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt new file mode 100644 index 00000000000..2f76e73108f --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt @@ -0,0 +1,223 @@ +package io.sentry.android.core + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IConnectionStatusProvider +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ProfileLifecycle +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.TracesSampler +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector +import io.sentry.test.DeferredExecutorService +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class PerfettoContinuousProfilerTest { + private lateinit var context: Context + private val fixture = Fixture() + private lateinit var mocks: ProfilerMocks + + private class Fixture { + private val mockDsn = "http://key@localhost/proj" + val executor = DeferredExecutorService() + val mockedSentry = mockStatic(Sentry::class.java) + val mockLogger = mock() + val mockTracesSampler = mock() + val mockPerfettoProfiler = mock() + val frameMetricsCollector: SentryFrameMetricsCollector = mock() + + val scopes: IScopes = mock() + + val options = + spy(SentryAndroidOptions()).apply { + dsn = mockDsn + profilesSampleRate = 1.0 + isDebug = true + setLogger(mockLogger) + } + + val mockTraceFile = + java.io.File.createTempFile("test-trace", ".pftrace").apply { + writeBytes(byteArrayOf(0x50, 0x65, 0x72, 0x66)) + deleteOnExit() + } + + init { + whenever(mockTracesSampler.sampleSessionProfile(any())).thenReturn(true) + whenever(mockPerfettoProfiler.start(any())).thenReturn(true) + doAnswer { invocation -> + val listener = invocation.getArgument>(0) + listener.accept(mockTraceFile) + null + } + .whenever(mockPerfettoProfiler) + .endAndCollect(any()) + } + + fun getSut( + optionConfig: ((options: SentryAndroidOptions) -> Unit) = {} + ): PerfettoContinuousProfiler { + options.executorService = executor + optionConfig(options) + whenever(scopes.options).thenReturn(options) + return PerfettoContinuousProfiler( + mockLogger, + frameMetricsCollector, + { options.executorService }, + { mockPerfettoProfiler }, + ) + } + } + + @BeforeTest + fun `set up`() { + context = ApplicationProvider.getApplicationContext() + Sentry.setCurrentScopes(fixture.scopes) + fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + mocks = + ProfilerMocks(fixture.executor, fixture.mockTracesSampler, fixture.mockLogger, fixture.scopes) + } + + @AfterTest + fun clear() { + fixture.mockedSentry.close() + } + + // -- Shared tests (see ContinuousProfilerTestCases.kt) -- + + @Test + fun `isRunning reflects profiler status`() = fixture.getSut().testIsRunningReflectsStatus(mocks) + + @Test + fun `stopProfiler stops the profiler after chunk is finished`() = + fixture.getSut().testStopProfilerStopsAfterChunkFinished(mocks) + + @Test + fun `profiler multiple starts are accepted in trace mode`() = + fixture.getSut().testMultipleStartsAcceptedInTraceMode(mocks) + + @Test + fun `profiler logs a warning on start if not sampled`() = + fixture.getSut().testLogsWarningIfNotSampled(mocks) + + @Test + fun `profiler evaluates sessionSampleRate only the first time`() = + fixture.getSut().testEvaluatesSessionSampleRateOnlyOnce(mocks) + + @Test + fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() = + fixture.getSut().testReevaluateSamplingOnNextStart(mocks) + + @Test + fun `profiler ignores profilesSampleRate`() { + val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + } + + @Test + fun `profiler stops and restart for each chunk`() = + fixture.getSut().testStopsAndRestartsForEachChunk(mocks) + + @Test + fun `profiler sends chunk on each restart`() = fixture.getSut().testSendsChunkOnRestart(mocks) + + @Test fun `profiler sends another chunk on stop`() = fixture.getSut().testSendsChunkOnStop(mocks) + + @Test + fun `close without terminating stops all profiles after chunk is finished`() = + fixture.getSut().testCloseWithoutTerminatingStopsAfterChunk(mocks) + + @Test + fun `profiler does not send chunks after close`() = + fixture.getSut().testDoesNotSendChunksAfterClose(mocks) + + @Test fun `profiler stops when rate limited`() = fixture.getSut().testStopsWhenRateLimited(mocks) + + @Test + fun `profiler does not start when rate limited`() = + fixture.getSut().testDoesNotStartWhenRateLimited(mocks) + + @Test + fun `profiler does not start when offline`() = + fixture + .getSut { + it.connectionStatusProvider = mock { provider -> + whenever(provider.connectionStatus) + .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) + } + } + .testDoesNotStartWhenOffline(mocks) + + @Test + fun `manual profiler can be started again after a full start-stop cycle`() = + fixture.getSut().testCanBeStartedAgainAfterStopCycle(mocks) + + // -- Perfetto-specific tests -- + + @Test + fun `async chunk callback does not restart when stop requested while pending`() { + val profiler = fixture.getSut() + + // Defer the endAndCollect listener to simulate the OS delivering the trace asynchronously, + // after the chunk timer already captured the (then-true) restart decision. + var pendingListener: java.util.function.Consumer? = null + doAnswer { invocation -> + pendingListener = invocation.getArgument(0) + null + } + .whenever(fixture.mockPerfettoProfiler) + .endAndCollect(any()) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + + // Chunk timer fires: stopInternal(true) captures shouldRestart=true and calls endAndCollect, + // but the listener is held pending instead of firing inline. + fixture.executor.runAll() + assertFalse(profiler.isRunning) + assertNotNull(pendingListener) + + // A stop is requested while the async callback is still pending. + profiler.stopProfiler(ProfileLifecycle.MANUAL) + + // The OS now delivers the trace. The callback must honor the late stop and not restart. + pendingListener!!.accept(fixture.mockTraceFile) + fixture.executor.runAll() + assertFalse( + profiler.isRunning, + "profiler must not restart when a stop was requested while the callback was pending", + ) + } + + @Test + fun `profiler multiple starts are ignored in manual mode`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + verify(fixture.mockLogger) + .log( + eq(SentryLevel.WARNING), + eq("Unexpected call to startProfiler(MANUAL) while profiler already running. Skipping."), + ) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt new file mode 100644 index 00000000000..0746d36dfff --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt @@ -0,0 +1,268 @@ +package io.sentry.android.core + +import android.content.Context +import android.os.ProfilingManager +import android.os.ProfilingResult +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ILogger +import io.sentry.test.DeferredExecutorService +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import java.util.function.Consumer +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class PerfettoProfilerTest { + + private lateinit var context: Context + private val mockLogger = mock() + private val executor = DeferredExecutorService() + + private lateinit var capturedCallback: Consumer + + private val mockProfilingManager = + mock().also { manager -> + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + capturedCallback = invocation.getArgument(5) as Consumer + null + } + .whenever(manager) + .requestProfiling(any(), any(), any(), any(), any(), any()) + } + + @BeforeTest + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + private fun getSut(profilingManager: ProfilingManager? = mockProfilingManager): PerfettoProfiler { + return PerfettoProfiler(mockLogger, executor, profilingManager) + } + + private fun createTraceFile(): File { + return File.createTempFile("test-trace", ".pftrace").apply { + writeBytes(byteArrayOf(0x50, 0x65, 0x72, 0x66)) + deleteOnExit() + } + } + + private fun mockResult( + errorCode: Int = ProfilingResult.ERROR_NONE, + filePath: String? = null, + errorMessage: String? = null, + ): ProfilingResult { + return mock().also { + whenever(it.errorCode).thenReturn(errorCode) + whenever(it.resultFilePath).thenReturn(filePath) + whenever(it.errorMessage).thenReturn(errorMessage) + } + } + + @Test + fun `start returns true on first call`() { + val profiler = getSut() + assertTrue(profiler.start(60000)) + } + + @Test + fun `start returns false when already started`() { + val profiler = getSut() + assertTrue(profiler.start(60000)) + assertFalse(profiler.start(60000)) + } + + @Test + fun `start returns false when ProfilingManager is null`() { + val profiler = getSut(profilingManager = null) + assertFalse(profiler.start(60000)) + } + + @Test + fun `endAndCollect calls listener with null when never started`() { + val profiler = getSut() + val result = AtomicReference(File("sentinel")) + profiler.endAndCollect { result.set(it) } + assertNull(result.get()) + } + + @Test + fun `endAndCollect calls listener synchronously when result already available`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + val result = AtomicReference() + profiler.endAndCollect { result.set(it) } + + assertEquals(traceFile.absolutePath, result.get()?.absolutePath) + } + + @Test + fun `endAndCollect calls listener when result arrives later`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference() + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + assertEquals(traceFile.absolutePath, result.get()?.absolutePath) + } + + @Test + fun `endAndCollect calls listener with null on error result`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept( + mockResult(errorCode = ProfilingResult.ERROR_UNKNOWN, errorMessage = "unknown error") + ) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } + + @Test + fun `endAndCollect calls listener with null on rate limit error`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept(mockResult(errorCode = ProfilingResult.ERROR_FAILED_RATE_LIMIT_PROCESS)) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } + + @Test + fun `timeout fires listener with null when OS never responds`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + profiler.endAndCollect { result.set(it) } + + assertEquals("sentinel", result.get()?.name) + + executor.runAll() + + assertNull(result.get()) + } + + @Test + fun `timeout is no-op when result already arrived`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val callCount = AtomicInteger(0) + val result = AtomicReference() + profiler.endAndCollect { + callCount.incrementAndGet() + result.set(it) + } + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + assertEquals(1, callCount.get()) + assertEquals(traceFile.absolutePath, result.get()?.absolutePath) + + executor.runAll() + + assertEquals(1, callCount.get()) + } + + @Test + fun `listener is called exactly once when result and endAndCollect race`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val callCount = AtomicInteger(0) + val latch = CountDownLatch(1) + + val resultThread = Thread { + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + latch.countDown() + } + + profiler.endAndCollect { callCount.incrementAndGet() } + resultThread.start() + + assertTrue(latch.await(5, TimeUnit.SECONDS)) + + executor.runAll() + + assertEquals(1, callCount.get()) + } + + @Test + fun `trace file is deleted when result arrives after the timeout`() { + val traceFile = createTraceFile() + val profiler = getSut() + profiler.start(60000) + + val callCount = AtomicInteger(0) + profiler.endAndCollect { callCount.incrementAndGet() } + + executor.runAll() + assertEquals(1, callCount.get()) + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + + assertEquals(1, callCount.get()) + assertFalse(traceFile.exists()) + } + + @Test + fun `endAndCollect calls listener with null when result file path is null`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept(mockResult(filePath = null)) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } + + @Test + fun `endAndCollect calls listener with null when trace file does not exist`() { + val profiler = getSut() + profiler.start(60000) + + val result = AtomicReference(File("sentinel")) + + capturedCallback.accept(mockResult(filePath = "/non/existent/path.pftrace")) + profiler.endAndCollect { result.set(it) } + + assertNull(result.get()) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index e2fed5bb003..1dc00f09f95 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -4,7 +4,10 @@ import android.content.ContentProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Hint import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.ITransaction import io.sentry.MeasurementUnit +import io.sentry.SentryLongDate import io.sentry.SentryTracer import io.sentry.SpanContext import io.sentry.SpanDataConvention @@ -13,7 +16,9 @@ import io.sentry.SpanStatus import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD +import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM +import io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP import io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP import io.sentry.android.core.performance.ActivityLifecycleTimeSpan import io.sentry.android.core.performance.AppStartMetrics @@ -87,7 +92,7 @@ class PerformanceAndroidEventProcessorTest { fun `add cold start measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -95,11 +100,174 @@ class PerformanceAndroidEventProcessorTest { assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) } + @Test + fun `add cold start measurement for standalone app start transaction launched from background`() { + val sut = fixture.getSut() + + var tr = createStandaloneAppStartTransaction() + setStandaloneColdAppStartMetrics() + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + } + + @Test + fun `standalone app start with instrumented application onCreate attaches process and application spans`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load", "application.load"), tr.spans.map { it.op }) + assertTrue(tr.spans.all { it.parentSpanId == rootSpanId }) + } + + @Test + fun `standalone app start without instrumented application onCreate attaches only process span`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = false) + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load"), tr.spans.map { it.op }) + assertEquals(rootSpanId, tr.spans.single().parentSpanId) + } + + @Test + fun `standalone app start uses the transaction root span id as parent`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics() + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + val processLoadSpan = tr.spans.first { it.op == "process.load" } + assertEquals(rootSpanId, processLoadSpan.parentSpanId) + } + + @Test + fun `standalone app start spans do not carry TTID or TTFD contributing flags`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = createStandaloneAppStartTransaction() + + tr = sut.process(tr, Hint()) + + assertTrue(tr.spans.isNotEmpty()) + for (span in tr.spans) { + assertNull(span.data?.get(SpanDataConvention.CONTRIBUTES_TTID)) + assertNull(span.data?.get(SpanDataConvention.CONTRIBUTES_TTFD)) + } + } + + @Test + fun `foreground standalone app start measurement uses foreground fallback time span`() { + val sut = fixture.getSut(enablePerformanceV2 = false) + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = true + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(101) + } + sdkInitTimeSpan.apply { + setStartedAt(10) + setStoppedAt(30) + } + } + + var tr = createStandaloneAppStartTransaction(appStartScreen = "MainActivity") + + tr = sut.process(tr, Hint()) + + assertEquals(20f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) + } + + private fun extendAppStartFinishedWith(status: SpanStatus, endMs: Long) { + val span = mock() + whenever(span.isFinished).thenReturn(true) + whenever(span.status).thenReturn(status) + whenever(span.finishDate).thenReturn(SentryLongDate(endMs * 1_000_000L)) + val ext = AppStartMetrics.getInstance().appStartExtension + ext.setExtendAppStartListener { AppStartExtension.ExtendedAppStart(mock(), span) } + ext.extendAppStart() + } + + @Test + fun `extended app start uses the extended end for the cold start measurement`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartType.COLD + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + val startMs = metrics.appStartTimeSpan.startTimestampMs + extendAppStartFinishedWith(SpanStatus.OK, startMs + 500) + + var tr = createUiLoadTransactionWithAppStartChildSpan() + tr = sut.process(tr, Hint()) + + assertEquals(500f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) + } + + @Test + fun `extended app start never reports shorter than the natural first frame duration`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartType.COLD + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(1000) + } + val startMs = metrics.appStartTimeSpan.startTimestampMs + extendAppStartFinishedWith(SpanStatus.OK, startMs + 100) + + var tr = createUiLoadTransactionWithAppStartChildSpan() + tr = sut.process(tr, Hint()) + + assertEquals(999f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) + } + + @Test + fun `extended app start that hit the deadline suppresses the measurement`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartType.COLD + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + val startMs = metrics.appStartTimeSpan.startTimestampMs + extendAppStartFinishedWith(SpanStatus.DEADLINE_EXCEEDED, startMs + 30_000) + + var tr = createUiLoadTransactionWithAppStartChildSpan() + tr = sut.process(tr, Hint()) + + assertFalse(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) + } + @Test fun `add cold start measurement for performance-v2`() { val sut = fixture.getSut(enablePerformanceV2 = true) - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -111,7 +279,7 @@ class PerformanceAndroidEventProcessorTest { fun `add warm start measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.WARM) + var tr = createUiLoadTransactionWithAppStartChildSpan(coldStart = false) setAppStart(fixture.options, false) tr = sut.process(tr, Hint()) @@ -123,7 +291,7 @@ class PerformanceAndroidEventProcessorTest { fun `set app cold start unit measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -136,23 +304,40 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric twice`() { val sut = fixture.getSut() - var tr1 = getTransaction(AppStartType.COLD) + var tr1 = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options, false) tr1 = sut.process(tr1, Hint()) - var tr2 = getTransaction(AppStartType.UNKNOWN) + var tr2 = createUiLoadTransaction() tr2 = sut.process(tr2, Hint()) assertTrue(tr1.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) assertTrue(tr2.measurements.isEmpty()) } + @Test + fun `do not add standalone app start metric twice`() { + val sut = fixture.getSut() + + setStandaloneColdAppStartMetrics() + + var tr1 = createStandaloneAppStartTransaction() + tr1 = sut.process(tr1, Hint()) + + var tr2 = createStandaloneAppStartTransaction() + tr2 = sut.process(tr2, Hint()) + + assertTrue(tr1.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr2.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr2.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) + } + @Test fun `do not add app start metric if its not ready`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransactionWithAppStartChildSpan() tr = sut.process(tr, Hint()) @@ -163,7 +348,7 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric if performance is disabled`() { val sut = fixture.getSut(tracesSampleRate = null) - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() tr = sut.process(tr, Hint()) @@ -174,7 +359,7 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric if no app_start span`() { val sut = fixture.getSut(tracesSampleRate = null) - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransaction() tr = sut.process(tr, Hint()) @@ -184,7 +369,7 @@ class PerformanceAndroidEventProcessorTest { @Test fun `do not add slow and frozen frames if not auto transaction`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createTransaction("custom.op") tr = sut.process(tr, Hint()) @@ -194,7 +379,7 @@ class PerformanceAndroidEventProcessorTest { @Test fun `do not add slow and frozen frames if tracing is disabled`() { val sut = fixture.getSut(null) - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransaction() tr = sut.process(tr, Hint()) @@ -464,10 +649,10 @@ class PerformanceAndroidEventProcessorTest { val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) tr.spans.add(appStartSpan) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) // then the app start metrics should be attached tr = sut.process(tr, Hint()) - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) assertTrue(tr.spans.any { "application.load" == it.op }) @@ -867,13 +1052,44 @@ class PerformanceAndroidEventProcessorTest { } } - private fun getTransaction(type: AppStartType): SentryTransaction { - val op = - when (type) { - AppStartType.COLD -> "app.start.cold" - AppStartType.WARM -> "app.start.warm" - AppStartType.UNKNOWN -> "ui.load" + private fun setStandaloneColdAppStartMetrics(withApplicationOnCreate: Boolean = false) { + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = false + classLoadedUptimeMs = 50 + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + if (withApplicationOnCreate) { + applicationOnCreateTimeSpan.apply { + setStartedAt(10) + description = "com.example.App.onCreate" + setStoppedAt(42) + } } + } + } + + private fun createUiLoadTransactionWithAppStartChildSpan( + coldStart: Boolean = true + ): SentryTransaction = + createUiLoadTransaction().also { txn -> + txn.spans.add(createAppStartSpan(txn.contexts.trace!!.traceId, coldStart)) + } + + private fun createUiLoadTransaction(): SentryTransaction = createTransaction(UI_LOAD_OP) + + private fun createStandaloneAppStartTransaction( + appStartScreen: String? = null + ): SentryTransaction = + createTransaction(STANDALONE_APP_START_OP).also { txn -> + if (appStartScreen != null) { + txn.contexts.trace!!.setData(APP_START_SCREEN_DATA, appStartScreen) + } + } + + private fun createTransaction(op: String): SentryTransaction { val txn = SentryTransaction(fixture.tracer) txn.contexts.setTrace(SpanContext(op, TracesSamplingDecision(false))) return txn diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 819928dcdc4..94857b91058 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -156,6 +156,12 @@ class SentryAndroidOptionsTest { assertFalse(sentryOptions.isEnablePerformanceV2) } + @Test + fun `standalone app start tracing is disabled by default`() { + val sentryOptions = SentryAndroidOptions() + assertFalse(sentryOptions.isEnableStandaloneAppStartTracing) + } + fun `when options is initialized, enableScopeSync is enabled by default`() { assertTrue(SentryAndroidOptions().isEnableScopeSync) } @@ -233,6 +239,13 @@ class SentryAndroidOptionsTest { sentryOptions.anrProfilingSampleRate = 2.0 } + @Test + fun `app hang tracking is disabled by default with a 5s timeout`() { + val sentryOptions = SentryAndroidOptions() + assertFalse(sentryOptions.isEnableNdkAppHangTracking) + assertEquals(5000L, sentryOptions.ndkAppHangTimeoutIntervalMillis) + } + private class CustomDebugImagesLoader : IDebugImagesLoader { override fun loadDebugImages(): List? = null diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 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/SentryShadowActivityManager.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt index e7079bd46d0..93cb4759e99 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt @@ -1,6 +1,7 @@ package io.sentry.android.core import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo import android.app.ApplicationStartInfo import android.os.Build import org.robolectric.annotation.Implementation @@ -10,18 +11,37 @@ import org.robolectric.annotation.Implements class SentryShadowActivityManager { companion object { private var historicalProcessStartReasons: List = emptyList() + private var importance: Int = RunningAppProcessInfo.IMPORTANCE_FOREGROUND + private var historicalProcessStartReasonsException: RuntimeException? = null fun setHistoricalProcessStartReasons(startReasons: List) { historicalProcessStartReasons = startReasons } + fun setHistoricalProcessStartReasonsException(exception: RuntimeException) { + historicalProcessStartReasonsException = exception + } + + fun setImportance(importance: Int) { + this.importance = importance + } + fun reset() { historicalProcessStartReasons = emptyList() + importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND + historicalProcessStartReasonsException = null + } + + @Implementation + @JvmStatic + fun getMyMemoryState(outState: RunningAppProcessInfo) { + outState.importance = importance } } @Implementation fun getHistoricalProcessStartReasons(maxNum: Int): List { + historicalProcessStartReasonsException?.let { throw it } return historicalProcessStartReasons.take(maxNum) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt index c3ff6653673..e36388fb185 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt @@ -6,15 +6,25 @@ import org.robolectric.annotation.Implements @Implements(android.os.Process::class) class SentryShadowProcess { companion object { - private var startupTimeMillis: Long = 0 + private var startUptimeMillis: Long = 0 + private var startElapsedRealtime: Long = 0 fun setStartUptimeMillis(value: Long) { - startupTimeMillis = value + startUptimeMillis = value } + fun setStartElapsedRealtime(value: Long) { + startElapsedRealtime = value + } + + @Suppress("unused") + @Implementation + @JvmStatic + fun getStartUptimeMillis(): Long = startUptimeMillis + @Suppress("unused") @Implementation @JvmStatic - fun getStartUptimeMillis(): Long = startupTimeMillis + fun getStartElapsedRealtime(): Long = startElapsedRealtime } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/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/SpanFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt index e5d7349d37c..2b6f19a8d31 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt @@ -8,7 +8,6 @@ import io.sentry.SentryNanotimeDate import io.sentry.SpanContext import io.sentry.android.core.internal.util.SentryFrameMetricsCollector import io.sentry.protocol.MeasurementValue -import java.util.Date import java.util.UUID import java.util.concurrent.TimeUnit import kotlin.test.Test @@ -50,11 +49,12 @@ class SpanFrameMetricsCollectorTest { val span = mock() val spanContext = SpanContext("op.fake") whenever(span.spanContext).thenReturn(spanContext) - whenever(span.startDate).thenReturn(SentryNanotimeDate(Date(), startTimeStampNanos)) + whenever(span.startDate) + .thenReturn(SentryNanotimeDate(System.currentTimeMillis(), startTimeStampNanos)) whenever(span.finishDate) .thenReturn( if (endTimeStampNanos != null) { - SentryNanotimeDate(Date(), endTimeStampNanos) + SentryNanotimeDate(System.currentTimeMillis(), endTimeStampNanos) } else { null } @@ -69,11 +69,12 @@ class SpanFrameMetricsCollectorTest { val span = mock() val spanContext = SpanContext("op.fake") whenever(span.spanContext).thenReturn(spanContext) - whenever(span.startDate).thenReturn(SentryNanotimeDate(Date(), startTimeStampNanos)) + whenever(span.startDate) + .thenReturn(SentryNanotimeDate(System.currentTimeMillis(), startTimeStampNanos)) whenever(span.finishDate) .thenReturn( if (endTimeStampNanos != null) { - SentryNanotimeDate(Date(), endTimeStampNanos) + SentryNanotimeDate(System.currentTimeMillis(), endTimeStampNanos) } else { null } @@ -438,8 +439,8 @@ class SpanFrameMetricsCollectorTest { @Test fun `SentryNanoDate diff does nano precision`() { // having this in here, as SpanFrameMetricsCollector relies on this behavior - val a = SentryNanotimeDate(Date(1234), 567) - val b = SentryNanotimeDate(Date(1234), 0) + val a = SentryNanotimeDate(1234, 567) + val b = SentryNanotimeDate(1234, 0) assertEquals(567, a.diff(b)) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt index 9890d553dbc..e3e88d04f7a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt @@ -75,7 +75,8 @@ class TombstoneIntegrationTest : ApplicationExitIntegrationTestBase thread.id == crashedThreadId } - assertEquals("samples.android", crashedThread!!.name) + assertEquals("main", crashedThread!!.name) + assertTrue(crashedThread.isMain!!) assertTrue(crashedThread.isCrashed!!) // Verify that frames from the app's native library are marked as in-app 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/gestures/SentryGestureListenerTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt index fe994f4a828..9d7606bfe44 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt @@ -160,6 +160,18 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) } + @Test + fun `when a transaction is already bound to the Scope, does not start a new UI transaction`() { + val sut = fixture.getSut() + val boundTransaction = SentryTracer(TransactionContext("bound", "op"), fixture.scopes) + whenever(fixture.scope.transaction).thenReturn(boundTransaction) + + sut.onSingleTapUp(fixture.event) + + verify(fixture.scopes, never()).startTransaction(any(), any()) + assertEquals(false, boundTransaction.isFinished) + } + @Test fun `stopTracing remove transaction from scope`() { val sut = fixture.getSut() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt index 1a4f28bbe35..15123ce0a31 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt @@ -5,9 +5,6 @@ import android.content.res.Resources import android.view.MotionEvent import android.view.View import android.view.Window -import kotlin.math.abs -import org.mockito.kotlin.any -import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -35,31 +32,17 @@ internal inline fun mockView( context: Context? = null, finalize: (T) -> Unit = {}, ): T { - val coordinates = IntArray(2) - if (!touchWithinBounds) { - coordinates[0] = (event.x).toInt() + 10 - coordinates[1] = (event.y).toInt() + 10 - } else { - coordinates[0] = (event.x).toInt() - 10 - coordinates[1] = (event.y).toInt() - 10 - } + // The decor-view-relative touch point used in these tests is (0, 0), and child views are mocked + // at offset (0, 0), so the point reaches every view unchanged. A view therefore contains the + // touch iff its width/height are non-negative; a negative size marks the touch as outside. + val size = if (touchWithinBounds) 10 else -1 val mockView: T = mock { whenever(it.id).thenReturn(id) whenever(it.context).thenReturn(context) whenever(it.isClickable).thenReturn(clickable) whenever(it.visibility).thenReturn(if (visible) View.VISIBLE else View.GONE) - - whenever(it.getLocationOnScreen(any())).doAnswer { - val array = it.arguments[0] as IntArray - array[0] = coordinates[0] - array[1] = coordinates[1] - null - } - - val diffPosX = abs(event.x - coordinates[0]).toInt() - val diffPosY = abs(event.y - coordinates[1]).toInt() - whenever(it.width).thenReturn(diffPosX + 10) - whenever(it.height).thenReturn(diffPosY + 10) + whenever(it.width).thenReturn(size) + whenever(it.height).thenReturn(size) finalize(this.mock) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt index 77a38e6ccc1..ed3e6d8ca89 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt @@ -2,24 +2,120 @@ package io.sentry.android.core.internal.gestures import android.content.Context import android.content.res.Resources +import android.graphics.Matrix import android.view.View +import android.view.ViewGroup +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.internal.gestures.UiElement +import io.sentry.util.LazyEvaluator import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doThrow import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@RunWith(AndroidJUnit4::class) class ViewUtilsTest { @Test - fun `getResourceId returns resourceId when available`() { + fun `findTarget hit-tests children in their own local coordinate space`() { + val child = clickableChild() + val decorView = + mock { + whenever(it.width).thenReturn(1000) + whenever(it.height).thenReturn(1000) + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(child) + } + val options = optionsWithViewLocator() + + // (120, 220) maps to (20, 20) in the child's space -> inside its 50x50 bounds. + assertNotNull(ViewUtils.findTarget(options, decorView, 120f, 220f, UiElement.Type.CLICKABLE)) + + // (90, 220) maps to (-10, 20) in the child's space -> outside, despite being inside the decor. + assertNull(ViewUtils.findTarget(options, decorView, 90f, 220f, UiElement.Type.CLICKABLE)) + } + + @Test + fun `findTarget accounts for parent scroll when mapping into a child`() { + val child = clickableChild() + val decorView = + mock { + whenever(it.width).thenReturn(1000) + whenever(it.height).thenReturn(1000) + whenever(it.scrollX).thenReturn(30) + whenever(it.scrollY).thenReturn(40) + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(child) + } + val options = optionsWithViewLocator() + + // With scroll (30, 40), (90, 180) maps to (90 + 30 - 100, 180 + 40 - 200) = (20, 20) -> inside. + assertNotNull(ViewUtils.findTarget(options, decorView, 90f, 180f, UiElement.Type.CLICKABLE)) + + // The same point without accounting for scroll would map to (-10, -20) -> outside the child. + assertNull(ViewUtils.findTarget(options, decorView, 50f, 140f, UiElement.Type.CLICKABLE)) + } + + @Test + fun `findTarget applies the inverse of a non-identity child matrix`() { + // The child is visually translated by (40, 40) within its parent, so a parent-space point is + // mapped back by (-40, -40) to reach the child's own coordinate space. + val matrix = Matrix().apply { setTranslate(40f, 40f) } + val child = clickableChild { whenever(it.matrix).thenReturn(matrix) } + val decorView = + mock { + whenever(it.width).thenReturn(1000) + whenever(it.height).thenReturn(1000) + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(child) + } + val options = optionsWithViewLocator() + + // (180, 280) lands at (80, 80) before the matrix (outside 50x50), but the inverse pulls it to + // (40, 40) -> inside. + assertNotNull(ViewUtils.findTarget(options, decorView, 180f, 280f, UiElement.Type.CLICKABLE)) + + // (130, 230) lands at (30, 30) before the matrix (inside), but the inverse pushes it to + // (-10, -10) -> outside. + assertNull(ViewUtils.findTarget(options, decorView, 130f, 230f, UiElement.Type.CLICKABLE)) + } + + // A clickable child positioned at (100, 200) within its parent, 50x50 in size. + private fun clickableChild(finalize: (View) -> Unit = {}): View { + val context = mock() + val resources = mock() + whenever(context.resources).thenReturn(resources) + whenever(resources.getResourceEntryName(any())).thenReturn("child") + return mock { + whenever(it.id).thenReturn(0x7f010001) + whenever(it.context).thenReturn(context) + whenever(it.isClickable).thenReturn(true) + whenever(it.visibility).thenReturn(View.VISIBLE) + whenever(it.left).thenReturn(100) + whenever(it.top).thenReturn(200) + whenever(it.width).thenReturn(50) + whenever(it.height).thenReturn(50) + finalize(this.mock) + } + } + + private fun optionsWithViewLocator(): SentryAndroidOptions = + SentryAndroidOptions().apply { + gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true })) + } + + @Test + fun `getResourceIdOrNull returns resource name when available`() { val view = mock { - whenever(it.id).doReturn(View.generateViewId()) + whenever(it.id).doReturn(0x7f010001) val context = mock() val resources = mock() @@ -28,56 +124,50 @@ class ViewUtilsTest { whenever(it.context).thenReturn(context) } - assertEquals(ViewUtils.getResourceId(view), "test_view") + assertEquals("test_view", ViewUtils.getResourceIdOrNull(view)) } @Test - fun `getResourceId throws when resource id is not available`() { + fun `getResourceIdOrNull returns null without throwing for generated id`() { + val context = mock() val view = mock { - whenever(it.id).doReturn(View.generateViewId()) - - val context = mock() - val resources = mock() - whenever(resources.getResourceEntryName(any())).doThrow(Resources.NotFoundException()) - whenever(context.resources).thenReturn(resources) + // View.generateViewId() starts with 1 + whenever(it.id).doReturn(1) whenever(it.context).thenReturn(context) } - assertFailsWith { ViewUtils.getResourceId(view) } + assertNull(ViewUtils.getResourceIdOrNull(view)) + verify(context, never()).resources } @Test - fun `when view has no id set, resource name is not looked up `() { + fun `getResourceIdOrNull returns null without throwing when view has no id`() { val context = mock() - val resources = mock() - whenever(context.resources).thenReturn(resources) - val view = mock { whenever(it.id).doReturn(View.NO_ID) whenever(it.context).thenReturn(context) } - assertFailsWith { ViewUtils.getResourceId(view) } + assertNull(ViewUtils.getResourceIdOrNull(view)) verify(context, never()).resources } @Test - fun `when view id is generated, resource name is not looked up `() { - val context = mock() - val resources = mock() - whenever(context.resources).thenReturn(resources) - + fun `getResourceIdOrNull returns null without throwing when resource not found`() { val view = mock { - // View.generateViewId() starts with 1 - whenever(it.id).doReturn(1) + whenever(it.id).doReturn(1234) + + val context = mock() + val resources = mock() + whenever(resources.getResourceEntryName(it.id)).thenThrow(Resources.NotFoundException()) + whenever(context.resources).thenReturn(resources) whenever(it.context).thenReturn(context) } - assertFailsWith { ViewUtils.getResourceId(view) } - verify(context, never()).resources + assertNull(ViewUtils.getResourceIdOrNull(view)) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt index b7db35b63ce..c5798be2111 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt @@ -52,8 +52,9 @@ class ThreadDumpParserTest { assertEquals(SentryLockReason.SLEEPING, blockingThread.heldLocks!!["0x09228c2d"]!!.type) assertEquals(null, blockingThread.heldLocks!!["0x09228c2d"]!!.threadId) - val randomThread = - threads.find { it.name == "io.sentry.android.core.internal.util.SentryFrameMetricsCollector" } + val randomThread = threads.find { + it.name == "io.sentry.android.core.internal.util.SentryFrameMetricsCollector" + } assertEquals(19, randomThread!!.id) assertEquals("Native", randomThread.state) assertEquals(false, randomThread.isCrashed) @@ -100,12 +101,15 @@ class ThreadDumpParserTest { parser.parse(lines) val threads = parser.threads // just verifying a few important threads, as there are many - val thread = threads.find { it.name == "samples.android" } + // the OS named the main thread after the process; it is detected via sysTid==processId (9955) + // and its name is normalized back to "main" + val thread = threads.find { it.isMain == true } assertEquals(9955, thread!!.id) + assertEquals("main", thread.name) assertNull(thread.state) - assertEquals(false, thread.isCrashed) - assertEquals(false, thread.isMain) - assertEquals(false, thread.isCurrent) + assertEquals(true, thread.isCrashed) + assertEquals(true, thread.isMain) + assertEquals(true, thread.isCurrent) // Reverse frames so we can index them with the active frame at index 0 val frames = thread.stacktrace!!.frames!!.reversed() @@ -152,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) @@ -182,6 +187,38 @@ class ThreadDumpParserTest { assertEquals(8.054, artContext.gcWaitingTime) } + @Test + fun `detects main thread via sysTid matching the process id when OS renames it`() { + val lines = Lines.readLines(File("src/test/resources/thread_dump_process_name_main.txt")) + val parser = + ThreadDumpParser(SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false) + parser.parse(lines) + val threads = parser.threads + // the main thread has been renamed to the (truncated) process name, but its sysTid equals the + // process id, which is how we detect it - its name is then normalized back to "main" + val main = threads.find { it.isMain == true } + assertNotNull(main) + assertEquals("main", main!!.name) + assertEquals(true, main.isCrashed) + assertEquals(true, main.isCurrent) + val background = threads.find { it.name == "Thread-2" } + assertNotNull(background) + assertEquals(false, background!!.isMain) + assertEquals(false, background.isCrashed) + } + + @Test + fun `skips threads without a stacktrace`() { + val lines = Lines.readLines(File("src/test/resources/thread_dump_no_stacktrace.txt")) + val parser = + ThreadDumpParser(SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false) + parser.parse(lines) + val threads = parser.threads + // the thread without any frames is skipped, only the one with a stacktrace remains + assertEquals(1, threads.size) + assertEquals("main", threads.first().name) + } + @Test fun `thread dump garbage`() { val lines = Lines.readLines(File("src/test/resources/thread_dump_bad_data.txt")) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt index 34e704188c4..70fc48fd9be 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt @@ -12,6 +12,7 @@ import java.io.StringWriter import java.util.zip.GZIPInputStream import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import org.mockito.kotlin.mock @@ -99,6 +100,10 @@ class TombstoneParserTest { // threads assertEquals(62, event.threads!!.size) + val mainThread = event.threads!!.single { it.isMain == true } + assertEquals(21891, mainThread.id) + assertEquals("main", mainThread.name) + for (thread in event.threads!!) { assertNotNull(thread.id) if (thread.id == crashedThreadId) { @@ -397,6 +402,73 @@ class TombstoneParserTest { assertEquals(expectedJson, actualJson) } + @Test + fun `identifies the main thread via pid matching the thread id and normalizes its name`() { + val tombstone = + Tombstone.Builder() + .pid(1000) + .tid(2000) + .signal(Signal(11, "SIGSEGV", 1, "SEGV_MAPERR", false, 0, 0, false, 0, null)) + // main thread: id == pid, but the OS renamed it to the process name + .addThread( + TombstoneThread( + 1000, + "io.sentry.samples.android", + emptyList(), + emptyList(), + emptyList(), + listOf(BacktraceFrame(0, 0x100, 0, "main", 0, "/system/lib64/libc.so", 0, "")), + emptyList(), + 0, + 0, + ) + ) + .addThread( + TombstoneThread( + 2000, + "crashed-worker", + emptyList(), + emptyList(), + emptyList(), + listOf(BacktraceFrame(0, 0x200, 0, "crash", 0, "/system/lib64/libc.so", 0, "")), + emptyList(), + 0, + 0, + ) + ) + .addThread( + TombstoneThread( + 3000, + "Thread-3", + emptyList(), + emptyList(), + emptyList(), + listOf(BacktraceFrame(0, 0x300, 0, "work", 0, "/system/lib64/libc.so", 0, "")), + emptyList(), + 0, + 0, + ) + ) + .build() + + val event = parser.parse(tombstone) + val threads = event.threads!! + + val main = threads.single { it.isMain == true } + assertEquals(1000, main.id) + assertEquals("main", main.name) + + val crashed = threads.single { it.isCrashed == true } + assertEquals(2000, crashed.id) + assertNotEquals(true, crashed.isMain) + assertEquals("crashed-worker", crashed.name) + + val background = threads.single { it.id == 3000L } + assertNotEquals(true, background.isMain) + assertNotEquals(true, background.isCrashed) + assertEquals("Thread-3", background.name) + } + @Test fun `parses tombstone when nativeLibraryDir is null`() { val tombstoneStream = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt index a6611e17e9a..c3993b94efd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CpuInfoUtilsTest.kt @@ -14,14 +14,13 @@ class CpuInfoUtilsTest { private lateinit var cpuDirs: File private lateinit var ciu: CpuInfoUtils - private fun populateCpuFiles(values: List) = - values.mapIndexed { i, v -> - val cpuMaxFreqFile = - File(cpuDirs, "cpu$i${File.separator}${CpuInfoUtils.CPUINFO_MAX_FREQ_PATH}") - cpuMaxFreqFile.parentFile?.mkdirs() - cpuMaxFreqFile.writeText(v) - cpuMaxFreqFile - } + private fun populateCpuFiles(values: List) = values.mapIndexed { i, v -> + val cpuMaxFreqFile = + File(cpuDirs, "cpu$i${File.separator}${CpuInfoUtils.CPUINFO_MAX_FREQ_PATH}") + cpuMaxFreqFile.parentFile?.mkdirs() + cpuMaxFreqFile.writeText(v) + cpuMaxFreqFile + } @BeforeTest fun `set up`() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt index 008a036cbfc..44d6d9fd03a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt @@ -128,6 +128,37 @@ class FirstDrawDoneListenerTest { assertTrue(fixture.onDrawListeners.isEmpty()) } + @Test + fun `OnGlobalLayoutListener is removed after cleanup`() { + val view = fixture.getSut() + + // Initialize mOnGlobalLayoutListeners via a dummy add/remove + val dummyGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {} + view.viewTreeObserver.addOnGlobalLayoutListener(dummyGlobalListener) + view.viewTreeObserver.removeOnGlobalLayoutListener(dummyGlobalListener) + + // CopyOnWriteArray wraps an internal ArrayList called mData + val copyOnWriteArray: Any = view.viewTreeObserver.getProperty("mOnGlobalLayoutListeners") + val mDataField = copyOnWriteArray.javaClass.getDeclaredField("mData") + mDataField.isAccessible = true + + @Suppress("UNCHECKED_CAST") + fun globalLayoutListeners(): ArrayList<*> = mDataField.get(copyOnWriteArray) as ArrayList<*> + + assertTrue(globalLayoutListeners().isEmpty()) + + FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo) + + // onDraw registers a cleanup OnGlobalLayoutListener + view.viewTreeObserver.dispatchOnDraw() + assertFalse(globalLayoutListeners().isEmpty()) + + // onGlobalLayout fires the cleanup, which removes both the draw and layout listeners + view.viewTreeObserver.dispatchOnGlobalLayout() + assertTrue(globalLayoutListeners().isEmpty()) + assertTrue(fixture.onDrawListeners.isEmpty()) + } + @Test fun `registerForNextDraw calls the given callback on the main thread after onDraw`() { val view = fixture.getSut() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt index 02f65665a9e..334ce229066 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt @@ -19,7 +19,6 @@ import io.sentry.test.getCtor import io.sentry.test.getProperty import io.sentry.test.injectForField import java.lang.ref.WeakReference -import java.lang.reflect.Field import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test @@ -141,6 +140,16 @@ class SentryFrameMetricsCollectorTest { assertNotNull(id) } + @Test + fun `handler thread is started lazily on first startCollection`() { + val collector = fixture.getSut(context) + // not started during construction (would block the main thread on getLooper at SDK init) + assertNull(collector.getProperty("handler")) + + collector.startCollection(mock()) + assertNotNull(collector.getProperty("handler")) + } + @Test fun `collector calls addOnFrameMetricsAvailableListener when an activity starts`() { val collector = fixture.getSut(context) @@ -292,18 +301,41 @@ class SentryFrameMetricsCollectorTest { } @Test - fun `collector accesses choreographer instance on creation on main thread`() { + fun `collector accesses choreographer instance and field asynchronously on main thread`() { val collector = fixture.getSut(context) - val field: Field? = collector.getProperty("choreographerLastFrameTimeField") + + val field: Any? = collector.getProperty("choreographerLastFrameTimeField") var choreographer: Choreographer? = collector.getProperty("choreographer") - // Choreographer instance is accessed on main thread, but the field accessor happens in whatever - // thread created the collector - assertNotNull(field) + assertNull(choreographer) + assertNull(field) + // Execute all posted tasks Shadows.shadowOf(Looper.getMainLooper()).idle() choreographer = collector.getProperty("choreographer") assertNotNull(choreographer) + assertNotNull(collector.getProperty("choreographerLastFrameTimeField")) + } + + // Frame callbacks on API 26+ read their per-frame start timestamp directly from FrameMetrics, + // which can make the Choreographer fallback look like it should be specific to APIs < 26. + // But SpanFrameMetricsCollector separately calls getLastKnownFrameStartTimeNanos() on every + // API level for pending-frame interpolation, so API 26+ still needs the Choreographer + // fallback to be initialized. + @Test + fun `collector keeps choreographer fallback available on version O+`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + + Shadows.shadowOf(Looper.getMainLooper()).idle() + + val choreographer = collector.getProperty("choreographer") + assertNotNull(collector.getProperty("choreographerLastFrameTimeField")) + + choreographer.injectForField("mLastFrameTimeNanos", 100) + + assertEquals(100, collector.getLastKnownFrameStartTimeNanos()) } @Test @@ -611,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) @@ -633,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) @@ -671,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) @@ -698,7 +722,6 @@ class SentryFrameMetricsCollectorTest { Shadows.shadowOf(Looper.getMainLooper()).idle() val listener = collector.getProperty("frameMetricsAvailableListener") - val choreographer = collector.getProperty("choreographer") collector.startCollection(mock()) @@ -710,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) @@ -724,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) } @@ -752,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)) @@ -764,7 +785,8 @@ class SentryFrameMetricsCollectorTest { whenever(frameMetrics.getMetric(FrameMetrics.DRAW_DURATION)).thenReturn(drawNanos) whenever(frameMetrics.getMetric(FrameMetrics.SYNC_DURATION)).thenReturn(syncNanos) whenever(frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION)).thenReturn(totalDurationNanos) - whenever(frameMetrics.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)).thenReturn(50) + whenever(frameMetrics.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)) + .thenReturn(intendedVsyncTimestampNanos) return frameMetrics } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt index 710fc835acd..ef048978795 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt @@ -12,7 +12,6 @@ import io.sentry.SpanDataConvention import io.sentry.SpanOptions import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext -import java.util.Date import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test @@ -31,8 +30,8 @@ class ActivityLifecycleSpanHelperTest { val appStartSpan: ISpan val scopes = mock() val options = SentryOptions() - val date = SentryNanotimeDate(Date(1), 1000000) - val endDate = SentryNanotimeDate(Date(3), 3000000) + val date = SentryNanotimeDate(1, 1000000) + val endDate = SentryNanotimeDate(3, 3000000) init { whenever(scopes.options).thenReturn(options) @@ -59,7 +58,7 @@ class ActivityLifecycleSpanHelperTest { @Test fun `createAndStopOnCreateSpan creates and finishes onCreate span`() { val helper = fixture.getSut() - val date = SentryNanotimeDate(Date(1), 1) + val date = SentryNanotimeDate(1, 1) helper.setOnCreateStartTimestamp(date) helper.createAndStopOnCreateSpan(fixture.appStartSpan) @@ -99,7 +98,7 @@ class ActivityLifecycleSpanHelperTest { @Test fun `createAndStopOnStartSpan creates and finishes onStart span`() { val helper = fixture.getSut() - val date = SentryNanotimeDate(Date(1), 1) + val date = SentryNanotimeDate(1, 1) helper.setOnStartStartTimestamp(date) helper.createAndStopOnStartSpan(fixture.appStartSpan) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index c15ea3c37d0..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 @@ -11,13 +11,17 @@ import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.DateUtils import io.sentry.IContinuousProfiler +import io.sentry.ITransaction import io.sentry.ITransactionProfiler import io.sentry.SentryNanotimeDate +import io.sentry.android.core.AppStartExtension +import io.sentry.android.core.ContextUtils import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess -import java.util.Date +import io.sentry.protocol.SentryId import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -27,6 +31,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.Before import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -44,6 +49,7 @@ class AppStartMetricsTest { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -65,6 +71,7 @@ class AppStartMetricsTest { metrics.appStartProfiler = mock() metrics.appStartContinuousProfiler = mock() metrics.appStartSamplingDecision = mock() + metrics.setAppStartTraceId(SentryId()) metrics.clear() @@ -78,6 +85,7 @@ class AppStartMetricsTest { assertNull(metrics.appStartProfiler) assertNull(metrics.appStartContinuousProfiler) assertNull(metrics.appStartSamplingDecision) + assertNull(metrics.getAppStartTraceId()) } @Test @@ -167,10 +175,10 @@ class AppStartMetricsTest { // when the looper runs waitForMainLooperIdle() - // but no activity creation happened + // but a headless start happened // then the app wasn't launched in foreground and nothing should be sent assertFalse(metrics.isAppLaunchedInForeground) - assertFalse(metrics.shouldSendStartMeasurements()) + assertFalse(metrics.shouldSendStartMeasurements(false)) val now = TimeUnit.MINUTES.toMillis(2) + 1234567 SystemClock.setCurrentTimeMillis(now) @@ -180,7 +188,7 @@ class AppStartMetricsTest { // then it should restart the timespan assertTrue(metrics.isAppLaunchedInForeground) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) assertTrue(metrics.appStartTimeSpan.hasStarted()) assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) @@ -194,7 +202,7 @@ class AppStartMetricsTest { metrics.sdkInitTimeSpan.start() metrics.registerLifecycleCallbacks(mock()) - // when the handler callback is executed and no activity was launched + // when the handler callback is executed and the start is headless waitForMainLooperIdle() // isAppLaunchedInForeground should be false @@ -208,11 +216,177 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } + @Test + fun `headless app start defaults UNKNOWN appStartType to COLD`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `headless app start does not overwrite existing appStartType`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartMetrics.AppStartType.WARM + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `headless app start fires HeadlessAppStartListener`() = + withProcessImportance(false) { + val listenerCalls = AtomicInteger() + + AppStartMetrics.getInstance().setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `foreground process does not fire HeadlessAppStartListener`() { + // Deferred/late SDK init inside an already-running Activity: we missed onActivityCreated, but + // the process is foreground (Robolectric default importance), so this is a real launch, not a + // headless start. The listener must not fire and the headless reclassification must not run. + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(0, listenerCalls.get()) + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + val activity = mock() + whenever(activity.isChangingConfigurations).thenReturn(false) + metrics.onActivityCreated(activity, null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + + metrics.onActivityDestroyed(activity) + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `activity start prevents HeadlessAppStartListener`() { + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.onActivityCreated(mock(), null) + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(0, listenerCalls.get()) + } + + @Test + fun `resolveHeadlessAppStartEndTime uses applicationOnCreate stop when Gradle plugin instrumented`() = + withProcessImportance(false) { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener {} + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime falls back to CLASS_LOADED_UPTIME_MS when no plugin and no ApplicationStartInfo`() = + withProcessImportance(false) { + val metrics = AppStartMetrics.getInstance() + metrics.setClassLoadedUptimeMs(200) + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener {} + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime does not overwrite stopped appStartTimeSpan`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.apply { + setStartedAt(100) + setStoppedAt(150) + } + metrics.setHeadlessAppStartListener {} + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(50, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `headless app start without listener does not stop sdkInitTimeSpan`() { + val metrics = AppStartMetrics.getInstance() + metrics.sdkInitTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertTrue(metrics.sdkInitTimeSpan.hasNotStopped()) + } + + @Test + fun `getAppStartTimeSpanForHeadless falls back to sdkInitTimeSpan when appStartSpan has not stopped`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.sdkInitTimeSpan.apply { + setStartedAt(120) + setStoppedAt(180) + } + + assertSame(metrics.sdkInitTimeSpan, metrics.getAppStartTimeSpanForHeadless()) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle() } + /** + * Mocks the process importance to simulate a user initiated start (e.g. launcher) or a real + * headless start (broadcast/service), i.e. a non-foreground-importance process. + * + * The Robolectric default importance in this test class is IMPORTANCE_FOREGROUND, so any headless + * scenarios must opt into a background importance explicitly. + */ + private fun withProcessImportance(isForeground: Boolean, block: () -> T): T = + mockStatic(ContextUtils::class.java).use { contextUtils -> + contextUtils + .`when` { ContextUtils.isForegroundImportance() } + .thenReturn(isForeground) + block() + } + @Test fun `if app start span is at most 1 minute, appStartTimeSpanWithFallback returns the app start span`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan @@ -331,12 +505,12 @@ class AppStartMetricsTest { } @Test - fun `registerApplicationForegroundCheck set foreground state to false if no activity is running`() { + fun `registerApplicationForegroundCheck set foreground state to false for headless start`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) - // Main thread performs the check and sets the flag to false if no activity was created + // Main thread performs the check and sets the flag to false if the start is headless waitForMainLooperIdle() assertFalse(AppStartMetrics.getInstance().isAppLaunchedInForeground) } @@ -369,11 +543,11 @@ class AppStartMetricsTest { val appStartMetrics = AppStartMetrics.getInstance() appStartMetrics.addActivityLifecycleTimeSpans(mock()) appStartMetrics.contentProviderOnCreateTimeSpans.add(mock()) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) appStartMetrics.onAppStartSpansSent() assertTrue(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) assertTrue(appStartMetrics.contentProviderOnCreateTimeSpans.isEmpty()) - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) } @Test @@ -387,18 +561,18 @@ class AppStartMetricsTest { // then the app start type should be cold and measurements should be sent assertEquals(AppStartMetrics.AppStartType.COLD, appStartMetrics.appStartType) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) // when the activity gets destroyed appStartMetrics.onAppStartSpansSent() - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) appStartMetrics.onActivityDestroyed(activity0) // then it should reset sending the measurements for the next warm activity appStartMetrics.onActivityCreated(mock(), mock()) assertEquals(AppStartMetrics.AppStartType.WARM, appStartMetrics.appStartType) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) } @Test @@ -473,7 +647,7 @@ class AppStartMetricsTest { @Test fun `createProcessInitSpan creates a span`() { val appStartMetrics = AppStartMetrics.getInstance() - val startDate = SentryNanotimeDate(Date(1), 1000000) + val startDate = SentryNanotimeDate(1, 1000000) appStartMetrics.classLoadedUptimeMs = 10 val startMillis = DateUtils.nanosToMillis(startDate.nanoTimestamp().toDouble()).toLong() appStartMetrics.appStartTimeSpan.setStartedAt(1) @@ -585,7 +759,6 @@ class AppStartMetricsTest { waitForMainLooperIdle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) - metrics.isAppLaunchedInForeground = true metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) @@ -791,7 +964,7 @@ class AppStartMetricsTest { whenever(firstActivity.isChangingConfigurations).thenReturn(false) metrics.onActivityCreated(firstActivity, null) assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) metrics.onAppStartSpansSent() waitForMainLooperIdle() @@ -804,7 +977,7 @@ class AppStartMetricsTest { metrics.onActivityCreated(secondActivity, null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) assertTrue(metrics.isAppLaunchedInForeground) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) metrics.onAppStartSpansSent() // Third activity - should still be warm @@ -812,7 +985,7 @@ class AppStartMetricsTest { metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) assertTrue(metrics.isAppLaunchedInForeground) - assertFalse(metrics.shouldSendStartMeasurements()) + assertFalse(metrics.shouldSendStartMeasurements(false)) } @Test @@ -860,4 +1033,117 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } + + @Test + fun `canExtendAppStart is true on a fresh foreground start`() { + assertTrue(AppStartMetrics.getInstance().canExtendAppStart()) + } + + @Test + fun `canExtendAppStart is true for a headless (non-foreground) start`() { + val metrics = AppStartMetrics.getInstance() + metrics.isAppLaunchedInForeground = false + assertTrue(metrics.canExtendAppStart()) + } + + @Test + fun `canExtendAppStart is false once an activity was created`() { + val metrics = AppStartMetrics.getInstance() + metrics.onActivityCreated(mock(), null) + assertFalse(metrics.canExtendAppStart()) + } + + @Test + fun `canExtendAppStart is false once the first frame was drawn`() { + val metrics = AppStartMetrics.getInstance() + metrics.onFirstFrameDrawn() + assertFalse(metrics.canExtendAppStart()) + } + + @Test + fun `canExtendAppStart is false once start measurements were sent`() { + val metrics = AppStartMetrics.getInstance() + metrics.onAppStartSpansSent() + assertFalse(metrics.canExtendAppStart()) + } + + /** Drives the singleton's eager extension into the active state via the listener path. */ + private fun activateExtension(metrics: AppStartMetrics) { + metrics.appStartExtension.setExtendAppStartListener { + AppStartExtension.ExtendedAppStart(mock(), mock()) + } + metrics.appStartExtension.extendAppStart() + assertTrue(metrics.appStartExtension.isActive) + } + + @Test + fun `clear resets the extension state`() { + val metrics = AppStartMetrics.getInstance() + activateExtension(metrics) + metrics.clear() + assertFalse(metrics.appStartExtension.isActive) + metrics.appStartExtension.setExtendAppStartListener(null) + } + + @Test + fun `onAppStartSpansSent resets the extension state`() { + val metrics = AppStartMetrics.getInstance() + activateExtension(metrics) + metrics.onAppStartSpansSent() + assertFalse(metrics.appStartExtension.isActive) + metrics.appStartExtension.setExtendAppStartListener(null) + } + + @Test + fun `late first activity does not reset the app start while the extension is active`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartMetrics.AppStartType.COLD + metrics.appStartTimeSpan.setStartedAt(1) + activateExtension(metrics) + + SystemClock.setCurrentTimeMillis(TimeUnit.MINUTES.toMillis(2)) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertEquals(1, metrics.appStartTimeSpan.startUptimeMs) + metrics.appStartExtension.setExtendAppStartListener(null) + } + + @Test + fun `late first activity resets the app start once the extension has finished`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartMetrics.AppStartType.COLD + metrics.appStartTimeSpan.setStartedAt(1) + val transaction = mock() + whenever(transaction.isFinished).thenReturn(true) + metrics.appStartExtension.setExtendAppStartListener { + AppStartExtension.ExtendedAppStart(transaction, mock()) + } + metrics.appStartExtension.extendAppStart() + assertFalse(metrics.appStartExtension.isActive) + + val now = TimeUnit.MINUTES.toMillis(2) + SystemClock.setCurrentTimeMillis(now) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) + metrics.appStartExtension.setExtendAppStartListener(null) + } + + @Test + fun `broadcast starts are not considered a foreground start`() = + withProcessImportance(false) { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `typical app starts are considered a foreground start`() = + withProcessImportance(true) { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + assertTrue(metrics.isAppLaunchedInForeground) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt index d3738943a2c..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,18 +1,29 @@ package io.sentry.android.core.performance +import android.app.Activity +import android.app.ActivityManager.RunningAppProcessInfo import android.app.Application import android.app.ApplicationStartInfo import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.SentryShadowActivityManager import io.sentry.android.core.SentryShadowProcess +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import org.robolectric.Shadows import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @@ -25,7 +36,9 @@ class AppStartMetricsTestApi35 { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + SentryShadowProcess.setStartElapsedRealtime(42) SentryShadowActivityManager.reset() + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -42,6 +55,22 @@ class AppStartMetricsTestApi35 { assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) } + @Test + fun `known ApplicationStartInfo type without listener does not schedule headless check`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertEquals(-1, metrics.firstIdle) + } + @Test fun `detects warm start using ApplicationStartInfo on API 35`() { val mockStartInfo = mock() @@ -81,4 +110,273 @@ class AppStartMetricsTestApi35 { assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) } + + @Test + fun `headless app start keeps COLD appStartType from ApplicationStartInfo`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `known ApplicationStartInfo type with listener handles headless app start`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_WARM) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setClassLoadedUptimeMs(200) + metrics.setHeadlessAppStartListener {} + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime uses ApplicationStartInfo onCreate uptime timestamp`() { + val appStartUptimeMs = 100L + // START_TIMESTAMP_APPLICATION_ONCREATE is captured with SystemClock.uptimeNanos() (the same + // base as TimeSpan) right before Application.onCreate is invoked, so it is used directly as + // an uptime value marking the onCreate start, without any clock re-anchoring. + val onCreateStartUptimeMs = 350L + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps) + .thenReturn( + mapOf( + ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE to + TimeUnit.MILLISECONDS.toNanos(onCreateStartUptimeMs) + ) + ) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(appStartUptimeMs) + metrics.setHeadlessAppStartListener {} + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(250, metrics.appStartTimeSpan.durationMs) + assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) + } + + @Test + fun `listener fires when set after registerLifecycleCallbacks resolves type on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + // Listener set AFTER registerLifecycleCallbacks — mirrors production ordering + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `getAppStartReason maps ApplicationStartInfo reason to string on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_BROADCAST) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertEquals("broadcast", metrics.appStartReason) + } + + @Test + fun `getAppStartReason returns null when no ApplicationStartInfo is available`() { + SentryShadowActivityManager.setHistoricalProcessStartReasons(emptyList()) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertNull(metrics.appStartReason) + } + + @Test + fun `getAppStartReason returns null for an unmapped reason`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(Int.MAX_VALUE) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertNull(metrics.appStartReason) + } + + @Test + fun `does not crash when getHistoricalProcessStartReasons throws RuntimeException`() { + SentryShadowActivityManager.setHistoricalProcessStartReasonsException( + RuntimeException("isolated process") + ) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + assertNull(metrics.appStartReason) + } + + @Test + fun `background start reason marks app as not launched in foreground`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_PUSH) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `all background start reasons mark app as not launched in foreground`() { + val backgroundReasons = + listOf( + ApplicationStartInfo.START_REASON_ALARM, + ApplicationStartInfo.START_REASON_BACKUP, + ApplicationStartInfo.START_REASON_BOOT_COMPLETE, + ApplicationStartInfo.START_REASON_BROADCAST, + ApplicationStartInfo.START_REASON_CONTENT_PROVIDER, + ApplicationStartInfo.START_REASON_JOB, + ApplicationStartInfo.START_REASON_PUSH, + ApplicationStartInfo.START_REASON_SERVICE, + ) + + val app = ApplicationProvider.getApplicationContext() + for (reason in backgroundReasons) { + AppStartMetrics.getInstance().clear() + SentryShadowActivityManager.reset() + + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(reason) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + + AppStartMetrics.getInstance().registerLifecycleCallbacks(app) + + assertFalse( + AppStartMetrics.getInstance().isAppLaunchedInForeground, + "reason $reason should not be launched in foreground", + ) + } + } + + @Test + fun `user-initiated start reason keeps app launched in foreground`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertTrue(metrics.isAppLaunchedInForeground) + } + + @Test + fun `unknown start reason falls back to foreground importance check`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_OTHER) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertTrue(metrics.isAppLaunchedInForeground) + } + + @Test + fun `background-spawned start is re-classified as warm on the first activity`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_PUSH) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + // App start span anchored at background process creation. + metrics.appStartTimeSpan.setStartedAt(42) + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + // User opens the app 20s later (under the 1-minute warm threshold). + val activityCreatedUptimeMs = 20_000L + SystemClock.setCurrentTimeMillis(activityCreatedUptimeMs) + metrics.onActivityCreated(mock(), null) + + // Re-classified as a warm start re-anchored at activity creation. + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertTrue(metrics.isAppLaunchedInForeground) + assertEquals(activityCreatedUptimeMs, metrics.appStartTimeSpan.startUptimeMs) + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + Shadows.shadowOf(Looper.getMainLooper()).idle() + } } diff --git a/sentry-android-core/src/test/resources/thread_dump_no_stacktrace.txt b/sentry-android-core/src/test/resources/thread_dump_no_stacktrace.txt new file mode 100644 index 00000000000..e0411327546 --- /dev/null +++ b/sentry-android-core/src/test/resources/thread_dump_no_stacktrace.txt @@ -0,0 +1,21 @@ + +----- pid 12345 at 2024-01-01 10:00:00.000000000+0000 ----- +Cmd line: io.sentry.samples.android +Build fingerprint: 'google/sdk_gphone64_arm64/emu64a:13/TE1A.220922.012/9302419:userdebug/dev-keys' +ABI: 'arm64' + +DALVIK THREADS (2): +"main" prio=5 tid=1 Runnable + | group="main" sCount=0 ucsCount=0 flags=0 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=12345 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=R schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity.onCreate(MainActivity.java:42) + +"Thread-2" prio=5 tid=2 Sleeping + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x136c0518 self=0xb400007cabc82ad0 + | sysTid=12346 nice=0 cgrp=top-app sched=0/0 handle=0x7ace0a9cb0 + | state=S schedstat=( 574039 4838087 11 ) utm=0 stm=0 core=1 HZ=100 + | stack=0x7acdfb2000-0x7acdfb4000 stackSize=991KB + | held mutexes= diff --git a/sentry-android-core/src/test/resources/thread_dump_process_name_main.txt b/sentry-android-core/src/test/resources/thread_dump_process_name_main.txt new file mode 100644 index 00000000000..80b864aae73 --- /dev/null +++ b/sentry-android-core/src/test/resources/thread_dump_process_name_main.txt @@ -0,0 +1,24 @@ + +----- pid 12345 at 2024-01-01 10:00:00.000000000+0000 ----- +Cmd line: io.sentry.samples.android +Build fingerprint: 'google/sdk_gphone64_arm64/emu64a:13/TE1A.220922.012/9302419:userdebug/dev-keys' +ABI: 'arm64' + +DALVIK THREADS (2): +"io.sentry.samples.android" prio=5 tid=1 Runnable + | group="main" sCount=0 ucsCount=0 flags=0 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=12345 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=R schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity.onCreate(MainActivity.java:42) + +"Thread-2" prio=5 tid=2 Sleeping + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x136c0518 self=0xb400007cabc82ad0 + | sysTid=12346 nice=0 cgrp=top-app sched=0/0 handle=0x7ace0a9cb0 + | state=S schedstat=( 574039 4838087 11 ) utm=0 stm=0 core=1 HZ=100 + | stack=0x7acdfb2000-0x7acdfb4000 stackSize=991KB + | held mutexes= + at java.lang.Thread.sleep(Native method) + at io.sentry.samples.android.BackgroundWorker.run(BackgroundWorker.java:20) + diff --git a/sentry-android-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 7a4178b0652..3ef1c1934f8 100644 --- a/sentry-android-fragment/build.gradle.kts +++ b/sentry-android-fragment/build.gradle.kts @@ -1,10 +1,10 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } @@ -25,10 +25,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt index 230510fb4de..374713ba969 100644 --- a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt +++ b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt @@ -76,14 +76,7 @@ public class SentryFragmentLifecycleCallbacks( ) { addBreadcrumb(fragment, FragmentLifecycleState.CREATED) - // we only start the tracing for the fragment if the fragment has been added to its activity - // and not only to the backstack - if (fragment.isAdded) { - if (scopes.options.isEnableScreenTracking) { - scopes.configureScope { it.screen = getFragmentName(fragment) } - } - startTracing(fragment) - } + startTracing(fragment) } override fun onFragmentViewCreated( @@ -93,17 +86,30 @@ public class SentryFragmentLifecycleCallbacks( savedInstanceState: Bundle?, ) { addBreadcrumb(fragment, FragmentLifecycleState.VIEW_CREATED) + + // For detach/attach navigation (e.g. manual tab switching, ViewPager v1 with + // FragmentPagerAdapter, custom navigation frameworks), onFragmentCreated is never called for + // off-screen fragments that are re-attached. Starting here enables a narrower + // "view created -> resumed" span for those paths. startTracing is idempotent, so for the + // normal onFragmentCreated -> onFragmentViewCreated path this is a no-op. + startTracing(fragment) } override fun onFragmentStarted(fragmentManager: FragmentManager, fragment: Fragment) { addBreadcrumb(fragment, FragmentLifecycleState.STARTED) - // ViewPager2 locks background fragments to STARTED state + // ViewPager2 locks background fragments to STARTED state, so we stop here to avoid + // spans hanging for off-screen fragments that never reach RESUMED. stopTracing(fragment) } override fun onFragmentResumed(fragmentManager: FragmentManager, fragment: Fragment) { addBreadcrumb(fragment, FragmentLifecycleState.RESUMED) + + // For detach/attach navigation, onFragmentStarted may not fire before onFragmentResumed. + // If a span is still running here, stop it now. stopTracing is idempotent, so this is a + // no-op for the normal path where onFragmentStarted already stopped the span. + stopTracing(fragment) } override fun onFragmentPaused(fragmentManager: FragmentManager, fragment: Fragment) { @@ -116,6 +122,10 @@ public class SentryFragmentLifecycleCallbacks( override fun onFragmentViewDestroyed(fragmentManager: FragmentManager, fragment: Fragment) { addBreadcrumb(fragment, FragmentLifecycleState.VIEW_DESTROYED) + + // Failsafe: cancel any span that didn't finish via the normal started/resumed path + // (e.g. fragment view destroyed before reaching STARTED or RESUMED). + stopTracing(fragment) } override fun onFragmentDestroyed(fragmentManager: FragmentManager, fragment: Fragment) { @@ -153,6 +163,16 @@ public class SentryFragmentLifecycleCallbacks( fragmentsWithOngoingTransactions.containsKey(fragment) private fun startTracing(fragment: Fragment) { + if (!fragment.isAdded) { + return + } + + val fragmentName = getFragmentName(fragment) + + if (scopes.options.isEnableScreenTracking) { + scopes.configureScope { it.screen = fragmentName } + } + if (!isPerformanceEnabled || isRunningSpan(fragment)) { return } @@ -160,7 +180,6 @@ public class SentryFragmentLifecycleCallbacks( var transaction: ISpan? = null scopes.configureScope { transaction = it.transaction } - val fragmentName = getFragmentName(fragment) val span = transaction?.startChild(FRAGMENT_LOAD_OP, fragmentName) span?.let { diff --git a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt index 9446e1caef5..997c1206398 100644 --- a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt +++ b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt @@ -43,9 +43,15 @@ class SentryFragmentLifecycleCallbacksTest { enableAutoFragmentLifecycleTracing: Boolean = false, tracesSampleRate: Double? = 1.0, isAdded: Boolean = true, + enableScreenTracking: Boolean = false, ): SentryFragmentLifecycleCallbacks { whenever(scopes.options) - .thenReturn(SentryOptions().apply { setTracesSampleRate(tracesSampleRate) }) + .thenReturn( + SentryOptions().apply { + setTracesSampleRate(tracesSampleRate) + isEnableScreenTracking = enableScreenTracking + } + ) whenever(span.spanContext) .thenReturn(SpanContext(SentryId.EMPTY_ID, SpanId.EMPTY_ID, "op", null, null)) whenever(transaction.startChild(any(), any())).thenReturn(span) @@ -251,6 +257,115 @@ class SentryFragmentLifecycleCallbacksTest { verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) } + @Test + fun `When fragment view is created via detach-attach, it should start tracing if enabled`() { + // Simulates detach/attach navigation: onFragmentCreated is NOT called, only + // onFragmentViewCreated + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.transaction) + .startChild( + check { assertEquals(SentryFragmentLifecycleCallbacks.FRAGMENT_LOAD_OP, it) }, + check { assertEquals("androidx.fragment.app.Fragment", it) }, + ) + } + + @Test + fun `When fragment view is created via detach-attach, it should update screen name`() { + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true, enableScreenTracking = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.scope).screen = "androidx.fragment.app.Fragment" + } + + @Test + fun `When performance is disabled, it should still update screen name`() { + val sut = + fixture.getSut(enableAutoFragmentLifecycleTracing = false, enableScreenTracking = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.scope).screen = "androidx.fragment.app.Fragment" + verify(fixture.transaction, never()).startChild(any(), any()) + } + + @Test + fun `When fragment view is created after onFragmentCreated, it should not start a second span`() { + // Normal path: onFragmentCreated already started the span; onFragmentViewCreated is a no-op + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.transaction).startChild(any(), any()) + } + + @Test + fun `When fragment is resumed, it should stop tracing if span is still running`() { + // Simulates detach/attach path where onFragmentStarted may be skipped + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + sut.onFragmentResumed(fixture.fragmentManager, fixture.fragment) + + verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) + } + + @Test + fun `When fragment is resumed after started, it should not double-finish the span`() { + // Normal path: onFragmentStarted already stopped the span; onFragmentResumed is a no-op + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) + sut.onFragmentStarted(fixture.fragmentManager, fixture.fragment) + sut.onFragmentResumed(fixture.fragmentManager, fixture.fragment) + + verify(fixture.span).finish(any()) + } + + @Test + fun `When fragment view is destroyed before started, it should stop tracing as failsafe`() { + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + sut.onFragmentViewDestroyed(fixture.fragmentManager, fixture.fragment) + + verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) + } + private fun verifyBreadcrumbAdded(expectedState: String) { verify(fixture.scopes) .addBreadcrumb( diff --git a/sentry-android-integration-tests/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 new file mode 100644 index 00000000000..4b89b7b6105 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/README.md @@ -0,0 +1,57 @@ +# sentry-uitest-android-macrobenchmark + +Jetpack Macrobenchmark for cold-start of `sentry-samples-android`, used to evaluate SDK-init +performance changes on a real device in a **stable, reproducible** way. Not run in CI. + +## What it measures + +`SentryStartupBenchmark` runs a cold start and reports two metrics per iteration: + +- **`timeToInitialDisplay`** (`StartupTimingMetric`) — the whole app cold start, taken from + framework trace events. Because it captures the entire start, an SDK change has to be large enough + (roughly tens of milliseconds) to show above cold-start noise. +- **`SentryAndroid.init`** (`TraceSectionMetric`) — the duration of the `SentryAndroid.init` + `android.os.Trace` section the SDK emits, which isolates SDK-init cost from the rest of the start + and resolves changes that `timeToInitialDisplay` would lose in the noise. + +For even finer detail (sub-millisecond changes, or cost inside init), capture a perfetto trace and +inspect the relevant slices directly (each iteration's trace is saved under +`build/outputs/connected_android_test_additional_output/`). + +`CompilationMode.Full()` pins ART AOT so dexopt state can't drift between runs. `StartupMode.COLD` +does the correct force-stop sequencing (it does **not** `pm clear`, so app data/permissions are +kept). Iterations are capped at 12 because back-to-back cold starts thermally throttle an +unlocked-clock device after ~14 iterations, inflating the tail of longer runs. + +## Running + +Connect a device, then: + +```bash +./gradlew :sentry-android-integration-tests:sentry-uitest-android-macrobenchmark:connectedBenchmarkAndroidTest +``` + +Results print to the console and are written to +`build/outputs/connected_android_test_additional_output/.../*-benchmarkData.json`. + +### Device hygiene (do this for trustworthy numbers) + +- **Wake and unlock the device first** — the launch check fails with "Unable to confirm activity + launch completion" on a dozing/locked screen + (`adb shell input keyevent KEYCODE_WAKEUP && adb shell wm dismiss-keyguard`). +- **Charge above 25%** — Macrobenchmark refuses to run below that. +- **Lock CPU clocks** if the device is rooted: this is the single biggest cure for thermal drift. +- Otherwise: let the device cool between runs, keep it on AC power, enable airplane mode, and turn + animations off (`adb shell settings put global window_animation_scale 0`, plus + `transition_animation_scale` and `animator_duration_scale`). +- Heed Macrobenchmark's warnings about unlocked clocks / low battery — they mean the numbers are + noisy. + +## A/B-ing an SDK change + +Macrobenchmark measures one build per run, so compare separate runs — but **interleave them**: +running all of variant A followed by all of variant B lets thermal drift systematically penalize +whichever variant runs second. Instead, alternate A/B rounds (build variant A, run, build variant +B, run, repeat 2–3 times), keep each round's `*-benchmarkData.json`, and compare the values pooled +per variant. Prefer the `SentryAndroid.init` metric for SDK-init changes — it isolates init cost, so +it moves on changes that `timeToInitialDisplay` would bury in cold-start noise. diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts new file mode 100644 index 00000000000..a00d76d6029 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts @@ -0,0 +1,48 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + +plugins { + id("com.android.test") + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "io.sentry.uitest.android.macrobenchmark" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + // Macrobenchmark requires API 23+. + minSdk = 24 + targetSdk = libs.versions.targetSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + // Pairs with the app's release build via matchingFallbacks. The test APK itself must be + // debuggable (to instrument) and signed (to install); only the target app needs to be + // genuinely release-like. + create("benchmark") { + isDebuggable = true + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlin { compilerOptions.jvmTarget = JVM_11 } + + targetProjectPath = ":sentry-samples:sentry-samples-android" + // Run the test in its own process so it measures the target app cold, not itself. + experimentalProperties["android.experimental.self-instrumenting"] = true +} + +// Benchmarks only make sense against the release build; drop the debug variant entirely. +androidComponents { beforeVariants(selector().withBuildType("debug")) { it.enable = false } } + +dependencies { + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.benchmark.macro.junit4) +} diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/AndroidManifest.xml b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..b2d3ea12352 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt new file mode 100644 index 00000000000..ee49fe8beff --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt @@ -0,0 +1,56 @@ +package io.sentry.uitest.android.macrobenchmark + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.ExperimentalMetricApi +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.TraceSectionMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Cold-start benchmark for the sentry-samples-android app, used to evaluate SDK-init changes on a + * real device in a stable, repeatable way. + * + * Reports two metrics per iteration: + * - timeToInitialDisplay ([StartupTimingMetric]) — the whole app cold start from framework trace + * events. Because it captures the entire start, an SDK change has to be large enough (roughly + * tens of milliseconds) to show above cold-start noise. + * - SentryAndroid.init ([TraceSectionMetric]) — the duration of the `SentryAndroid.init` + * [android.os.Trace] section the SDK emits, isolating SDK-init cost from the rest of the start. + * + * [CompilationMode.Full] pins ART AOT compilation so dexopt state does not drift between runs. + * Iterations are capped at 12: on an unthrottled Pixel 3, back-to-back cold starts hit thermal + * throttling after ~14 iterations, which inflates the tail of longer runs. This is NOT a CI test; + * it requires a connected device. To A/B an SDK change, see README.md (build the app twice, once + * per SDK variant, in interleaved rounds). + */ +@OptIn(ExperimentalMetricApi::class) +@RunWith(AndroidJUnit4::class) +class SentryStartupBenchmark { + + @get:Rule val benchmarkRule = MacrobenchmarkRule() + + @Test + fun startupFullCompilation() = + benchmarkRule.measureRepeated( + packageName = TARGET_PACKAGE, + metrics = listOf(StartupTimingMetric(), TraceSectionMetric(INIT_TRACE_SECTION)), + compilationMode = CompilationMode.Full(), + startupMode = StartupMode.COLD, + iterations = 12, + setupBlock = { pressHome() }, + ) { + startActivityAndWait() + } + + private companion object { + const val TARGET_PACKAGE = "io.sentry.samples.android" + + // Matches the android.os.Trace section name in SentryAndroid.init. + const val INIT_TRACE_SECTION = "SentryAndroid.init" + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android/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 5ea12ddbbc8..9c53f0a022d 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt @@ -21,6 +21,9 @@ class ReplayTest : BaseUiTest() { // we can't run on GH actions emulator, because they don't allow capturing screenshots properly @Suppress("KotlinConstantConditions") assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true)) + // crash on swallowed Compose masking errors (e.g. broken obfuscated internals) so regressions + // fail this on-device test instead of silently under-masking (see SentryReplayDebug) + System.setProperty("io.sentry.replay.compose.fail-fast", "true") } @Test @@ -67,11 +70,10 @@ class ReplayTest : BaseUiTest() { initSentry { it.sessionReplay.sessionSampleRate = 1.0 - it.beforeSendReplay = - SentryOptions.BeforeSendReplayCallback { event, _ -> - sent.set(true) - event - } + it.beforeSendReplay = SentryOptions.BeforeSendReplayCallback { event, _ -> + sent.set(true) + event + } } // wait until first segment is being sent diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt index b76017aeb0b..2ea1905b761 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt @@ -89,11 +89,10 @@ class UserInteractionTests : BaseUiTest() { options.profilesSampleRate = 1.0 options.isEnableUserInteractionTracing = true options.isEnableUserInteractionBreadcrumbs = true - options.beforeBreadcrumb = - SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> - breadcrumbs.add(breadcrumb) - breadcrumb - } + options.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + breadcrumbs.add(breadcrumb) + breadcrumb + } } } } diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt index 1d82a3f8bc0..6d45b2d1f9c 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -23,6 +23,9 @@ class ReplaySnapshotTest : BaseUiTest() { // GH Actions emulators don't support capturing screenshots for replay @Suppress("KotlinConstantConditions") assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true)) + // crash on swallowed Compose masking errors (e.g. broken obfuscated internals) so regressions + // fail this on-device test instead of silently under-masking (see SentryReplayDebug) + System.setProperty("io.sentry.replay.compose.fail-fast", "true") } @Test diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts index 7f5d1017ec3..6c1aa62a57d 100644 --- a/sentry-android-navigation/build.gradle.kts +++ b/sentry-android-navigation/build.gradle.kts @@ -1,10 +1,10 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } @@ -25,10 +25,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index 413fd3a7b77..6867d964124 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -3,8 +3,6 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) } @@ -28,6 +26,10 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } testOptions { diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java index 9d6d64a1236..fae652f7a64 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java @@ -73,6 +73,9 @@ public static void init(@NotNull final SentryAndroidOptions options) { ndkOptions.setTracesSampleRate(tracesSampleRate.floatValue()); } + ndkOptions.setEnableAppHangTracking(options.isEnableNdkAppHangTracking()); + ndkOptions.setAppHangTimeoutMillis(options.getNdkAppHangTimeoutIntervalMillis()); + //noinspection UnstableApiUsage io.sentry.ndk.SentryNdk.init(ndkOptions); diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt index c9f540f0afa..d9b332ac185 100644 --- a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt @@ -3,7 +3,9 @@ package io.sentry.android.ndk import io.sentry.android.core.SentryAndroidOptions import io.sentry.ndk.NdkOptions import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertTrue import org.junit.Test import org.mockito.Mockito import org.mockito.kotlin.any @@ -68,4 +70,30 @@ class SentryNdkTest { assertEquals(0.75f, fixture.capturedOptions!!.tracesSampleRate, 0.0001f) } } + + @Test + fun `SentryNdk does not enable app hang tracking by default`() { + fixture.getSut { + assertNotNull(fixture.capturedOptions) + assertFalse(fixture.capturedOptions!!.isEnableAppHangTracking) + assertEquals(5000L, fixture.capturedOptions!!.appHangTimeoutMillis) + } + } + + @Test + fun `SentryNdk propagates app hang tracking options`() { + fixture.getSut( + options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + cacheDirPath = "/cache" + isEnableNdkAppHangTracking = true + ndkAppHangTimeoutIntervalMillis = 2000 + } + ) { + assertNotNull(fixture.capturedOptions) + assertTrue(fixture.capturedOptions!!.isEnableAppHangTracking) + assertEquals(2000L, fixture.capturedOptions!!.appHangTimeoutMillis) + } + } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 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 60d38c0ae0a..6d03ba771b0 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -1,12 +1,12 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) // TODO: enable it later // alias(libs.plugins.detekt) @@ -27,20 +27,21 @@ android { buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() - useLiveLiterals = false - } + composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } buildTypes { getByName("debug") { consumerProguardFiles("proguard-rules.pro") } getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { @@ -82,6 +83,7 @@ dependencies { testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.test.runner) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.androidx.compose.ui) diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro index 42e3cb30a42..6ce45c1ef5d 100644 --- a/sentry-android-replay/proguard-rules.pro +++ b/sentry-android-replay/proguard-rules.pro @@ -29,3 +29,9 @@ # Rules to detect a PreviewView view to later mask it -dontwarn androidx.camera.view.PreviewView -keepnames class androidx.camera.view.PreviewView +# Rules to walk the Compose Node tree. +-keep class androidx.compose.ui.node.LayoutNode { + *** getChildren*(...); + *** getOuterCoordinator*(...); + *** getCollapsedSemantics*(...); +} \ No newline at end of file diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 32e42dafac1..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() @@ -199,6 +209,10 @@ public class ReplayCache(private val options: SentryOptions, private val replayI if (frameCount == 0) { options.logger.log(DEBUG, "Generated a video with no frames, not capturing a replay segment") + encoderLock.acquire().use { + encoder?.release() + encoder = null + } deleteFile(videoFile) return null } @@ -267,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 @@ -301,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" @@ -313,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()) { @@ -411,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 116ab45af06..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 @@ -107,10 +108,17 @@ public class ReplayIntegration( private var gestureRecorder: GestureRecorder? = null private val random by lazy { Random() } internal val rootViewsSpy by lazy { RootViewsSpy.install() } - private val replayExecutor by lazy { + internal val lazyReplayExecutor = lazy { val delegate = Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) ReplayExecutorService(delegate, options) } + internal val replayExecutor by lazyReplayExecutor + internal val lazyPersistingExecutor = lazy { + val delegate = + Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) + ReplayExecutorService(delegate, options) + } + internal val persistingExecutor by lazyPersistingExecutor internal val isEnabled = AtomicBoolean(false) internal val isManualPause = AtomicBoolean(false) @@ -123,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) { @@ -192,6 +200,7 @@ public class ReplayIntegration( scopes, dateProvider, replayExecutor, + persistingExecutor, replayCacheProvider, ) } else { @@ -201,6 +210,7 @@ public class ReplayIntegration( dateProvider, random, replayExecutor, + persistingExecutor, replayCacheProvider, ) } @@ -252,6 +262,7 @@ public class ReplayIntegration( onSegmentSent = { newTimestamp -> captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1 captureStrategy?.segmentTimestamp = newTimestamp + captureStrategy?.isFlushed = true }, ) captureStrategy = captureStrategy?.convert() @@ -287,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)) { @@ -335,7 +353,7 @@ public class ReplayIntegration( } addFrame(bitmap, frameTimeStamp, screen) } - checkCanRecord() + postOnMainThread { checkCanRecord() } } override fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) { @@ -358,7 +376,7 @@ public class ReplayIntegration( } addFrame(screenshot, frameTimestamp, screen) } - checkCanRecord() + postOnMainThread { checkCanRecord() } } override fun close() { @@ -373,9 +391,24 @@ public class ReplayIntegration( recorder?.close() recorder = null rootViewsSpy.close() - 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() + } + } } override fun onConnectionStatusChanged(status: ConnectionStatus) { @@ -414,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. @@ -554,4 +598,14 @@ public class ReplayIntegration( return ret } } + + private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayPersister-" + cnt++) + ret.setDaemon(true) + return ret + } + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/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 dab98ec4e24..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 @@ -25,7 +26,6 @@ import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.gestures.ReplayGestureConverter -import io.sentry.android.replay.util.ReplayExecutorService import io.sentry.android.replay.util.ReplayRunnable import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebEvent @@ -34,9 +34,7 @@ import java.io.File import java.util.Date import java.util.Deque import java.util.concurrent.ConcurrentLinkedDeque -import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.ThreadFactory import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference @@ -50,19 +48,15 @@ internal abstract class BaseCaptureStrategy( private val scopes: IScopes?, private val dateProvider: ICurrentDateProvider, protected val replayExecutor: ScheduledExecutorService, + protected val persistingExecutor: ScheduledExecutorService, private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null, ) : CaptureStrategy { internal companion object { private const val TAG = "CaptureStrategy" // https://github.com/getsentry/sentry-javascript/blob/30eb68fff5077211c30c61ba74625e66ab514870/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts#L41 - private const val MAX_TRACE_IDS = 100 + private const val MAX_CONTEXT_VALUES = 100 } - private val persistingExecutor: ScheduledExecutorService by lazy { - val delegate = - Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) - ReplayExecutorService(delegate, options) - } private val gestureConverter = ReplayGestureConverter(dateProvider) protected val isTerminating = AtomicBoolean(false) @@ -96,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) @@ -140,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, @@ -163,6 +164,7 @@ internal abstract class BaseCaptureStrategy( breadcrumbs, events, traceIds, + segmentNames, ) } @@ -181,24 +183,21 @@ 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()) } } } } - private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { - private var cnt = 0 - - override fun newThread(r: Runnable): Thread { - val ret = Thread(r, "SentryReplayPersister-" + cnt++) - ret.setDaemon(true) - return ret + 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 0eea2043bd8..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( @@ -33,6 +48,7 @@ internal class BufferCaptureStrategy( private val dateProvider: ICurrentDateProvider, private val random: Random, executor: ScheduledExecutorService, + persistingExecutor: ScheduledExecutorService, replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null, ) : BaseCaptureStrategy( @@ -40,6 +56,7 @@ internal class BufferCaptureStrategy( scopes, dateProvider, executor, + persistingExecutor, replayCacheProvider = replayCacheProvider, ) { // TODO: capture envelopes for buffered segments instead, but don't send them until buffer is @@ -98,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 @@ -150,8 +178,17 @@ internal class BufferCaptureStrategy( ) return this } - // we hand over replayExecutor to the new strategy to preserve order of execution - val captureStrategy = SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor) + if (isReplayRateLimited()) { + // captureReplay skipped the flush, so there is nothing to continue in session mode. Staying + // in buffer mode keeps the rolling buffer warm, so the next error after the rate limit + // expires can send a complete replay starting at segment 0. + options.logger.log(DEBUG, "Not converting to session mode, because replay is rate-limited") + return this + } + // we hand over replayExecutor and persistingExecutor to the new strategy to preserve order of + // execution + val captureStrategy = + SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor, persistingExecutor) captureStrategy.recorderConfig = recorderConfig captureStrategy.start( segmentId = currentSegment, @@ -167,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 4d3ee588f01..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,13 +16,33 @@ import io.sentry.util.FileUtils import java.util.Date import java.util.concurrent.ScheduledExecutorService +/** + * Records a full session: segments are encoded and sent continuously, one per + * `sessionSegmentDuration`, until the 1h `sessionDuration` deadline. Used when the session is + * sampled by `sessionSampleRate`. + * + * [captureReplay] is a no-op here — there is no buffer to flush, the segment covering the error is + * sent like any other. Because envelopes are in flight the whole time, `ReplayIntegration` pauses + * this strategy while offline or rate-limited so the envelope cache doesn't overflow. + * + * See [BufferCaptureStrategy] for the on-error counterpart. + */ internal class SessionCaptureStrategy( private val options: SentryOptions, private val scopes: IScopes?, private val dateProvider: ICurrentDateProvider, executor: ScheduledExecutorService, + persistingExecutor: ScheduledExecutorService, replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null, -) : BaseCaptureStrategy(options, scopes, dateProvider, executor, replayCacheProvider) { +) : + BaseCaptureStrategy( + options, + scopes, + dateProvider, + executor, + persistingExecutor, + replayCacheProvider, + ) { internal companion object { private const val TAG = "SessionCaptureStrategy" } 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/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt index 2882b2113b8..704260cf311 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -1,9 +1,31 @@ +/* + * Portions of this file are adapted from AndroidX Compose UI: + * - the `boundsInWindow` extension is a faster copy of `LayoutCoordinates.boundsInWindow` + * - the `fastMinOf`, `fastMaxOf`, `fastCoerceIn`, `fastCoerceAtLeast` and `fastCoerceAtMost` + * helpers are copied from `androidx.compose.ui.util.MathHelpers` + * + * Adapted from: + * https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt + * https://github.com/androidx/androidx/blob/androidx-main/compose/ui/ui-util/src/commonMain/kotlin/androidx/compose/ui/util/MathHelpers.kt + * + * Copyright (C) 2019, 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ @file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals and classes package io.sentry.android.replay.util -import android.graphics.Rect import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.graphics.painter.Painter @@ -11,6 +33,8 @@ import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.text.TextLayoutResult +import kotlin.math.ceil +import kotlin.math.floor import kotlin.math.roundToInt internal class ComposeTextLayout(internal val layout: TextLayoutResult) : TextLayout { @@ -176,7 +200,7 @@ internal fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates val boundsBottom = bounds.bottom.fastCoerceIn(0f, rootHeight) if (boundsLeft == boundsRight || boundsTop == boundsBottom) { - return Rect() + return Rect(0.0f, 0.0f, 0.0f, 0.0f) } val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop)) @@ -200,5 +224,18 @@ internal fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates val top = fastMinOf(topLeftY, topRightY, bottomLeftY, bottomRightY) val bottom = fastMaxOf(topLeftY, topRightY, bottomLeftY, bottomRightY) - return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) + return Rect(left, top, right, bottom) +} + +internal fun Rect.toRect(): android.graphics.Rect { + // Round outward (floor min edges, ceil max edges) so that a sub-pixel but non-empty Rect doesn't + // collapse to a zero-width/height android.graphics.Rect. Otherwise a node could be marked visible + // and maskable based on the float bounds, while the integer rect the MaskRenderer draws has zero + // area, leaving sensitive content unmasked. Rounding outward also biases toward over-masking. + return android.graphics.Rect( + floor(left).toInt(), + floor(top).toInt(), + ceil(right).toInt(), + ceil(bottom).toInt(), + ) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt index 31a3279d074..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 { @@ -57,6 +67,27 @@ internal class ReplayExecutorService( } } } + + fun gracefulShutdown() { + synchronized(this) { + if (!isShutdown) { + delegate.shutdown() + } + } + } } internal class ReplayRunnable(val taskName: String, delegate: Runnable) : Runnable by delegate + +/** A Future that represents an already-completed inline execution — never used as null. */ +internal object CompletedFuture : Future { + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false + + override fun isCancelled(): Boolean = false + + override fun isDone(): Boolean = true + + override fun get() {} + + override fun get(timeout: Long, unit: TimeUnit) {} +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SentryReplayDebug.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SentryReplayDebug.kt new file mode 100644 index 00000000000..966428f84c2 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SentryReplayDebug.kt @@ -0,0 +1,26 @@ +package io.sentry.android.replay.util + +/** + * Internal, undocumented escape hatch used to make Session Replay fail fast instead of silently + * degrading masking when an exception is swallowed (e.g. unsupported/obfuscated Compose internals). + * + * It is intended to be enabled only in our own sample/UI-test apps that run on real devices in CI + * (which are release/obfuscated builds, so [io.sentry.android.replay.BuildConfig.DEBUG] can't be + * used), so that regressions surface as crashes rather than under-masked replays. Customers should + * never set this. + * + * Enable via: + * ``` + * System.setProperty("io.sentry.replay.compose.fail-fast", "true") + * ``` + */ +internal object SentryReplayDebug { + private const val FAIL_FAST_PROPERTY = "io.sentry.replay.compose.fail-fast" + + /** + * Read live (not cached) so it's only evaluated on the error path and unit tests can toggle it + * between cases. + */ + val failFast: Boolean + get() = "true".equals(System.getProperty(FAIL_FAST_PROPERTY), ignoreCase = true) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt index 36741686701..0063cf636e4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -67,7 +67,12 @@ internal class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer } override fun release() { - muxer.stop() + // stop() throws unless the muxer was started AND at least one sample was written, so we guard + // it + // to ensure muxer.release() is always reached and the underlying resources are freed + if (started && videoFrames > 0) { + muxer.stop() + } muxer.release() } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index a400be865e7..dd9af1c24d6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -1,6 +1,6 @@ /** * Adapted from - * https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleFrameMuxer.kt + * https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleVideoEncoder.kt * * Copyright (c) 2021 fzyzcjy * @@ -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/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index a0312b69cd0..2e40144e2de 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -22,11 +22,13 @@ import io.sentry.SentryLevel import io.sentry.SentryMaskingOptions import io.sentry.android.replay.SentryReplayModifiers import io.sentry.android.replay.util.ComposeTextLayout +import io.sentry.android.replay.util.SentryReplayDebug import io.sentry.android.replay.util.boundsInWindow import io.sentry.android.replay.util.findPainter import io.sentry.android.replay.util.findTextColor import io.sentry.android.replay.util.isMaskable import io.sentry.android.replay.util.toOpaque +import io.sentry.android.replay.util.toRect import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode @@ -147,11 +149,17 @@ internal object ComposeViewHierarchyNode { ) } + // fail fast in our own sample/UI-test apps (see SentryReplayDebug), so regressions surface + // as crashes instead of silently degrading masking + if (SentryReplayDebug.failFast) { + throw t + } + // If we're unable to retrieve the semantics configuration // we should play safe and mask the whole node. return GenericViewHierarchyNode( - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -161,17 +169,17 @@ internal object ComposeViewHierarchyNode { isImportantForContentCapture = false, // will be set by children isVisible = !SentryLayoutNodeHelper.isTransparent(node) && - visibleRect.height() > 0 && - visibleRect.width() > 0, - visibleRect = visibleRect, + visibleRect.height > 0 && + visibleRect.width > 0, + visibleRect = visibleRect.toRect(), ) } val isVisible = !SentryLayoutNodeHelper.isTransparent(node) && (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && - visibleRect.height() > 0 && - visibleRect.width() > 0 + visibleRect.height > 0 && + visibleRect.width > 0 val isEditable = semantics?.contains(SemanticsActions.SetText) == true || semantics?.contains(SemanticsProperties.EditableText) == true @@ -206,8 +214,8 @@ internal object ComposeViewHierarchyNode { null }, dominantColor = textColor?.toArgb()?.toOpaque(), - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -216,7 +224,7 @@ internal object ComposeViewHierarchyNode { shouldMask = shouldMask, isImportantForContentCapture = true, isVisible = isVisible, - visibleRect = visibleRect, + visibleRect = visibleRect.toRect(), ) } else -> { @@ -226,8 +234,8 @@ internal object ComposeViewHierarchyNode { parent?.setImportantForCaptureToAncestors(true) ImageViewHierarchyNode( - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -236,7 +244,7 @@ internal object ComposeViewHierarchyNode { isVisible = isVisible, isImportantForContentCapture = true, shouldMask = shouldMask && painter.isMaskable(), - visibleRect = visibleRect, + visibleRect = visibleRect.toRect(), ) } else { val shouldMask = isVisible && semantics.shouldMask(isImage = false, options) @@ -245,8 +253,8 @@ internal object ComposeViewHierarchyNode { // TODO: traverse the ViewHierarchyNode here again. For now we can recommend // TODO: using custom modifiers to obscure the entire node if it's sensitive GenericViewHierarchyNode( - x = visibleRect.left.toFloat(), - y = visibleRect.top.toFloat(), + x = visibleRect.left, + y = visibleRect.top, width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -255,7 +263,7 @@ internal object ComposeViewHierarchyNode { shouldMask = shouldMask, isImportantForContentCapture = false, // will be set by children isVisible = isVisible, - visibleRect = visibleRect, + visibleRect = visibleRect.toRect(), ) } } @@ -291,6 +299,11 @@ internal object ComposeViewHierarchyNode { """ .trimIndent(), ) + // fail fast in our own sample/UI-test apps (see SentryReplayDebug), so regressions surface + // as crashes instead of silently skipping the whole Compose subtree (i.e. not masking it) + if (SentryReplayDebug.failFast) { + throw e + } return false } 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 3df0c9f005f..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 @@ -754,6 +754,12 @@ class ReplayIntegrationTest { null } }, + mock { + whenever(mock.submit(any())).doAnswer { + (it.arguments[0] as Runnable).run() + null + } + }, ) { _ -> fixture.replayCache } @@ -1037,7 +1043,9 @@ class ReplayIntegrationTest { replay.start() fixture.options.sessionReplay.frameObserver = - SentryReplayOptions.ReplayFrameObserver { _, _, _ -> throw RuntimeException("test") } + SentryReplayOptions.ReplayFrameObserver { _, _, _ -> + throw RuntimeException("test") + } val sourceBitmap = mock { @@ -1104,6 +1112,20 @@ class ReplayIntegrationTest { assertEquals(traceId, traceIdRegistered) } + @Test + fun `close shuts down replay executors`() { + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay = fixture.getSut(context) + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.stop() + replay.close() + + assertTrue(replay.replayExecutor.isShutdown) + assertTrue(replay.persistingExecutor.isShutdown) + } + private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy = SessionCaptureStrategy( options, @@ -1116,5 +1138,12 @@ class ReplayIntegrationTest { null } }, + persistingExecutor = + mock { + whenever(mock.submit(any())).doAnswer { + (it.arguments[0] as Runnable).run() + null + } + }, ) } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index c26e6be9c41..b5e15b5534f 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -23,13 +23,16 @@ import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.transport.RateLimiter import java.time.Duration +import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.BeforeTest import kotlin.test.assertEquals import kotlin.test.assertNotEquals +import kotlin.test.assertTrue import org.awaitility.core.ConditionTimeoutException import org.awaitility.kotlin.await import org.junit.Rule @@ -41,6 +44,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -61,11 +65,17 @@ class ReplaySmokeTest { internal class Fixture { val options = SentryOptions() val scope = Scope(options) + val rateLimiter = + mock { + on { isActiveForCategory(any()) }.thenReturn(false) + } val scopes = mock { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(it) .configureScope(any()) + + on { rateLimiter }.doReturn(rateLimiter) } private class ImmediateHandler : @@ -91,7 +101,10 @@ class ReplaySmokeTest { mainLooperHandler = mock { whenever(mock.handler).thenReturn(ImmediateHandler()) - whenever(mock.post(any())).then { (it.arguments[0] as Runnable).run() } + whenever(mock.post(any())).then { + (it.arguments[0] as Runnable).run() + true + } whenever(mock.postDelayed(any(), anyLong())).then { // have to use another thread here otherwise it will block the test thread recordingThread.schedule( @@ -243,6 +256,45 @@ class ReplaySmokeTest { assertNotEquals(falseReplay.rootViewsSpy, replay.rootViewsSpy) assertEquals(0, falseReplay.rootViewsSpy.listeners.size) } + + @Test + fun `close does not deadlock when executor task is waiting on lifecycleLock`() { + fixture.options.sessionReplay.sessionSampleRate = 1.0 + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay = fixture.getSut(context) + replay.register(fixture.scopes, fixture.options) + replay.start() + + val taskBlocked = CountDownLatch(1) + val lockReleased = CountDownLatch(1) + + // hold lifecycleLock on this thread + val token = replay.lifecycleLock.acquire() + + // submit a task on the executor that tries to acquire the same lock — it will block + replay.replayExecutor.submit { + taskBlocked.countDown() + replay.lifecycleLock.acquire().use {} + } + + // wait for the executor task to actually be running and blocked + assertTrue(taskBlocked.await(2, TimeUnit.SECONDS)) + + // release the lock, then close — if shutdown were inside the lock this would deadlock + token.close() + + // close() must complete within a reasonable time + val closedInTime = AtomicBoolean(false) + val closeThread = Thread { + replay.close() + closedInTime.set(true) + } + closeThread.start() + closeThread.join(5000) + + assertTrue(closedInTime.get(), "close() deadlocked") + } } private class ExampleActivity : Activity() { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ScreenshotRecorderTest.kt 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 380e9b3ce75..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(), @@ -111,6 +127,12 @@ class BufferCaptureStrategyTest { null } }, + mock { + whenever(it.submit(any())).doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + } + }, ) { _ -> replayCache } @@ -233,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() @@ -330,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 b5a00bc624b..fc2354eb1c0 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -122,6 +122,14 @@ class SessionCaptureStrategyTest { .whenever(it) .submit(any()) }, + mock { + doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + } + .whenever(it) + .submit(any()) + }, ) { _ -> replayCache } @@ -554,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 e043b035668..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 @@ -44,6 +44,7 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHiera import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import java.io.File +import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -219,6 +220,9 @@ class ComposeMaskingOptionsTest { } @Test + @Ignore( + "Flaky: Robolectric intermittently reports zero bounds for nodes, causing isVisible=false and making the assertion non-deterministic" + ) fun `when sentry-unmask modifier is set unmasks the node`() { ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.sentryReplayUnmask() } val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() @@ -228,18 +232,23 @@ class ComposeMaskingOptionsTest { val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] - textNodes.forEach { - if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { - assertFalse( - it.shouldMask, - "Node with text ${(it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text} should not be masked", - ) - } else { - assertTrue( - it.shouldMask, - "Node with text ${(it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text} should be masked", - ) - } + + val unmaskNode = textNodes.first { + (it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request" + } + assertTrue(unmaskNode.isVisible, "The unmasked node must be visible for the test to be valid") + assertFalse(unmaskNode.shouldMask, "Node with sentryReplayUnmask() should not be masked") + + // Robolectric may intermittently report zero bounds for some nodes when running + // the full test class, making them invisible (shouldMask = isVisible && ...). + // Assert that all other visible nodes remain masked. + val otherVisibleNodes = textNodes.filter { it !== unmaskNode && it.isVisible } + assertTrue(otherVisibleNodes.isNotEmpty(), "Expected at least one other visible text node") + otherVisibleNodes.forEach { + assertTrue( + it.shouldMask, + "Node with text ${(it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text} should be masked", + ) } } diff --git a/sentry-android-sqlite/README.md b/sentry-android-sqlite/README.md new file mode 100644 index 00000000000..307beb51f0e --- /dev/null +++ b/sentry-android-sqlite/README.md @@ -0,0 +1,23 @@ +# sentry-android-sqlite + +SQLite instrumentation for AndroidX APIs. + +Two instrumentation paths are supported: + +- **`androidx.sqlite.SQLiteDriver`**: Used by Room 2.7+ and 3.0+. Applied automatically by the Sentry Android Gradle Plugin. +- **`androidx.sqlite.db.SupportSQLiteOpenHelper`**: Used by SQLDelight and legacy (pre-2.7) Room. Applied automatically by the Sentry Android Gradle Plugin. + +To avoid duplicate spans, only one path should be used per database file. Most Room and SQLDelight APIs enforce that division. The exception is Room's `SupportSQLiteDriver`: either the `SupportSQLiteOpenHelper` it consumes should be wrapped or the support driver itself, but never both. + +See the [SQLite integration docs](https://docs.sentry.io/platforms/android/integrations/room-and-sqlite/) for more details. + +## Package layout + +The module is organized as two separate packages: + +- **`io.sentry.android.sqlite`**: Android-specific code. Depends on `android.database.*` and/or on `androidx.sqlite.db.*`. +- **`io.sentry.sqlite`**: No Android-specific code. Depends only on multiplatform `androidx.sqlite.*`. + +The split anticipates future Kotlin Multiplatform support. The `androidx.sqlite.*` interfaces are defined in KMP's `commonMain` source set and are used by Room in non-JVM environments. Classes in `io.sentry.sqlite` are written against those portable interfaces and are intended to lift cleanly into a KMP `commonMain` source set if/when the `sentry` core gains multiplatform targets. + +Note that the module artifact itself (`sentry-android-sqlite`) is currently an Android-only AAR regardless of package layout. diff --git a/sentry-android-sqlite/api/sentry-android-sqlite.api b/sentry-android-sqlite/api/sentry-android-sqlite.api index c8780f1338d..7b9f633b46a 100644 --- a/sentry-android-sqlite/api/sentry-android-sqlite.api +++ b/sentry-android-sqlite/api/sentry-android-sqlite.api @@ -21,3 +21,15 @@ public final class io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper$Compan public final fun create (Landroidx/sqlite/db/SupportSQLiteOpenHelper;)Landroidx/sqlite/db/SupportSQLiteOpenHelper; } +public final class io/sentry/sqlite/SentrySQLiteDriver : androidx/sqlite/SQLiteDriver { + public static final field Companion Lio/sentry/sqlite/SentrySQLiteDriver$Companion; + public synthetic fun (Landroidx/sqlite/SQLiteDriver;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver; + public fun hasConnectionPool ()Z + public fun open (Ljava/lang/String;)Landroidx/sqlite/SQLiteConnection; +} + +public final class io/sentry/sqlite/SentrySQLiteDriver$Companion { + public final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver; +} + diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index 07fa7ad343f..9637b91546a 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -1,10 +1,10 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } @@ -25,10 +25,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { @@ -73,7 +77,6 @@ dependencies { // tests testImplementation(libs.androidx.sqlite) testImplementation(libs.kotlin.test.junit) - testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) } diff --git a/sentry-android-sqlite/proguard-rules.pro b/sentry-android-sqlite/proguard-rules.pro index 02ab589d3bd..13fa4bf9dea 100644 --- a/sentry-android-sqlite/proguard-rules.pro +++ b/sentry-android-sqlite/proguard-rules.pro @@ -4,4 +4,8 @@ # https://developer.android.com/studio/build/shrink-code#decode-stack-trace -keepattributes LineNumberTable,SourceFile +# SentrySQLiteDriver.create() uses a runtime class-name check to skip wrapping the Room 2.7+ +# SupportSQLiteDriver bridge adapter and avoid duplicate spans. +-keepnames class androidx.sqlite.driver.SupportSQLiteDriver + ##---------------End: proguard configuration for SQLite ---------- diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt similarity index 96% rename from sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt rename to sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt index 1bdeb7d369c..059eb1bb1b5 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt @@ -13,7 +13,8 @@ import io.sentry.SpanStatus private const val TRACE_ORIGIN = "auto.db.sqlite" -internal class SQLiteSpanManager( +/** Span instrumentation for [SentrySupportSQLiteOpenHelper]. */ +internal class OpenHelperSpans( private val scopes: IScopes = ScopesAdapter.getInstance(), private val databaseName: String? = null, ) { diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt index 1f3796a8975..01da8c476db 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt @@ -2,20 +2,20 @@ package io.sentry.android.sqlite import android.database.CrossProcessCursor import android.database.CursorWindow +import android.database.CursorWrapper /* * SQLiteCursor executes the query lazily, when one of getCount() and onMove() is called. * Also, by docs, fillWindow() can be used to fill the cursor with data. * So we wrap these methods to create a span. - * SQLiteCursor is never used directly in the code, but only the Cursor interface. - * This means we can use CrossProcessCursor - that extends Cursor - as wrapper, since - * CrossProcessCursor is an interface and we can use Kotlin delegation. + * Ordinary Cursor methods are delegated through CursorWrapper to avoid adding Sentry frames to + * app database exceptions that the wrapper did not instrument. */ internal class SentryCrossProcessCursor( private val delegate: CrossProcessCursor, - private val spanManager: SQLiteSpanManager, + private val spans: OpenHelperSpans, private val sql: String, -) : CrossProcessCursor by delegate { +) : CursorWrapper(delegate), CrossProcessCursor { // We have to start the span only the first time, regardless of how many times its methods get // called. private var isSpanStarted = false @@ -25,7 +25,7 @@ internal class SentryCrossProcessCursor( return delegate.count } isSpanStarted = true - return spanManager.performSql(sql) { delegate.count } + return spans.performSql(sql) { delegate.count } } override fun onMove(oldPosition: Int, newPosition: Int): Boolean { @@ -33,14 +33,16 @@ internal class SentryCrossProcessCursor( return delegate.onMove(oldPosition, newPosition) } isSpanStarted = true - return spanManager.performSql(sql) { delegate.onMove(oldPosition, newPosition) } + return spans.performSql(sql) { delegate.onMove(oldPosition, newPosition) } } + override fun getWindow(): CursorWindow? = delegate.window + override fun fillWindow(position: Int, window: CursorWindow?) { if (isSpanStarted) { return delegate.fillWindow(position, window) } isSpanStarted = true - return spanManager.performSql(sql) { delegate.fillWindow(position, window) } + return spans.performSql(sql) { delegate.fillWindow(position, window) } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt index bfe3265f89b..458203a232f 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt @@ -14,11 +14,11 @@ import androidx.sqlite.db.SupportSQLiteStatement * and it's created automatically by the [SentrySupportSQLiteOpenHelper]. * * @param delegate The [SupportSQLiteDatabase] instance to delegate calls to. - * @param sqLiteSpanManager The [SQLiteSpanManager] responsible for the creation of the spans. + * @param spans The [OpenHelperSpans] manager responsible for the creation of the spans. */ internal class SentrySupportSQLiteDatabase( private val delegate: SupportSQLiteDatabase, - private val sqLiteSpanManager: SQLiteSpanManager, + private val spans: OpenHelperSpans, ) : SupportSQLiteDatabase by delegate { /** * Compiles the given SQL statement. It will return Sentry's wrapper around @@ -28,35 +28,34 @@ internal class SentrySupportSQLiteDatabase( * @return Compiled statement. */ override fun compileStatement(sql: String): SupportSQLiteStatement = - SentrySupportSQLiteStatement(delegate.compileStatement(sql), sqLiteSpanManager, sql) + SentrySupportSQLiteStatement(delegate.compileStatement(sql), spans, sql) @Suppress("AcronymName") // To keep consistency with framework method name. override fun execPerConnectionSQL( sql: String, @SuppressLint("ArrayReturn") bindArgs: Array?, ) { - sqLiteSpanManager.performSql(sql) { delegate.execPerConnectionSQL(sql, bindArgs) } + spans.performSql(sql) { delegate.execPerConnectionSQL(sql, bindArgs) } } - override fun query(query: String): Cursor = - sqLiteSpanManager.performSql(query) { delegate.query(query) } + override fun query(query: String): Cursor = spans.performSql(query) { delegate.query(query) } override fun query(query: String, bindArgs: Array): Cursor = - sqLiteSpanManager.performSql(query) { delegate.query(query, bindArgs) } + spans.performSql(query) { delegate.query(query, bindArgs) } override fun query(query: SupportSQLiteQuery): Cursor = - sqLiteSpanManager.performSql(query.sql) { delegate.query(query) } + spans.performSql(query.sql) { delegate.query(query) } override fun query(query: SupportSQLiteQuery, cancellationSignal: CancellationSignal?): Cursor = - sqLiteSpanManager.performSql(query.sql) { delegate.query(query, cancellationSignal) } + spans.performSql(query.sql) { delegate.query(query, cancellationSignal) } @Throws(SQLException::class) override fun execSQL(sql: String) { - sqLiteSpanManager.performSql(sql) { delegate.execSQL(sql) } + spans.performSql(sql) { delegate.execSQL(sql) } } @Throws(SQLException::class) override fun execSQL(sql: String, bindArgs: Array) { - sqLiteSpanManager.performSql(sql) { delegate.execSQL(sql, bindArgs) } + spans.performSql(sql) { delegate.execSQL(sql, bindArgs) } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt index 76b405d9f11..12b63cfa128 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt @@ -33,14 +33,14 @@ import androidx.sqlite.db.SupportSQLiteOpenHelper public class SentrySupportSQLiteOpenHelper private constructor(private val delegate: SupportSQLiteOpenHelper) : SupportSQLiteOpenHelper by delegate { - private val sqLiteSpanManager = SQLiteSpanManager(databaseName = delegate.databaseName) + private val spans = OpenHelperSpans(databaseName = delegate.databaseName) private val sentryWritableDatabase: SupportSQLiteDatabase by lazy { - SentrySupportSQLiteDatabase(delegate.writableDatabase, sqLiteSpanManager) + SentrySupportSQLiteDatabase(delegate.writableDatabase, spans) } private val sentryReadableDatabase: SupportSQLiteDatabase by lazy { - SentrySupportSQLiteDatabase(delegate.readableDatabase, sqLiteSpanManager) + SentrySupportSQLiteDatabase(delegate.readableDatabase, spans) } override val writableDatabase: SupportSQLiteDatabase diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt index 1a364dc27ba..3df6d287b28 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt @@ -9,25 +9,22 @@ import androidx.sqlite.db.SupportSQLiteStatement * [SentrySupportSQLiteDatabase.compileStatement]. * * @param delegate The [SupportSQLiteStatement] instance to delegate calls to. - * @param sqLiteSpanManager The [SQLiteSpanManager] responsible for the creation of the spans. + * @param spans The [OpenHelperSpans] manager responsible for the creation of the spans. * @param sql The query string. */ internal class SentrySupportSQLiteStatement( private val delegate: SupportSQLiteStatement, - private val sqLiteSpanManager: SQLiteSpanManager, + private val spans: OpenHelperSpans, private val sql: String, ) : SupportSQLiteStatement by delegate { - override fun execute() = sqLiteSpanManager.performSql(sql) { delegate.execute() } + override fun execute() = spans.performSql(sql) { delegate.execute() } - override fun executeUpdateDelete(): Int = - sqLiteSpanManager.performSql(sql) { delegate.executeUpdateDelete() } + override fun executeUpdateDelete(): Int = spans.performSql(sql) { delegate.executeUpdateDelete() } - override fun executeInsert(): Long = - sqLiteSpanManager.performSql(sql) { delegate.executeInsert() } + override fun executeInsert(): Long = spans.performSql(sql) { delegate.executeInsert() } - override fun simpleQueryForLong(): Long = - sqLiteSpanManager.performSql(sql) { delegate.simpleQueryForLong() } + override fun simpleQueryForLong(): Long = spans.performSql(sql) { delegate.simpleQueryForLong() } override fun simpleQueryForString(): String? = - sqLiteSpanManager.performSql(sql) { delegate.simpleQueryForString() } + spans.performSql(sql) { delegate.simpleQueryForString() } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt new file mode 100644 index 00000000000..598dc524ed1 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt @@ -0,0 +1,38 @@ +package io.sentry.sqlite + +/** [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] value for in-memory databases. */ +internal const val DB_SYSTEM_IN_MEMORY = "in-memory" + +/** [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] value for SQLite databases. */ +internal const val DB_SYSTEM_SQLITE = "sqlite" + +/** + * Sentinel file name that [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open] interprets as an + * in-memory database (see docs + * [here](https://developer.android.com/reference/androidx/sqlite/driver/AndroidSQLiteDriver)). + */ +private const val IN_MEMORY_DB_FILENAME = ":memory:" + +/** Path separators matching [File.separatorChar][java.io.File.separatorChar]. */ +private val FILE_NAME_PATH_SEPARATORS = charArrayOf('/', '\\') + +internal data class DbMetadata(val name: String?, val system: String) + +/** + * Returns metadata based on the [fileName] argument passed to + * [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open]. + */ +internal fun dbMetadataFromFileName(fileName: String): DbMetadata { + if (fileName == IN_MEMORY_DB_FILENAME) { + return DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY) + } + + val trimmed = fileName.trimEnd { it in FILE_NAME_PATH_SEPARATORS } + if (trimmed.isEmpty()) { + return DbMetadata(name = null, system = DB_SYSTEM_SQLITE) + } + + val index = trimmed.lastIndexOfAny(FILE_NAME_PATH_SEPARATORS) + val basename = if (index >= 0) trimmed.substring(index + 1) else trimmed + return DbMetadata(name = basename.ifEmpty { null }, system = DB_SYSTEM_SQLITE) +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt new file mode 100644 index 00000000000..b3c0eb7c713 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt @@ -0,0 +1,117 @@ +package io.sentry.sqlite + +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.Instrumenter +import io.sentry.ScopesAdapter +import io.sentry.SentryDate +import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate +import io.sentry.SentryStackTraceFactory +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus + +private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" + +/** + * Sentinel for extracting a [SentryNanotimeDate]'s underlying [System.nanoTime] value via + * [SentryDate.diff]. + */ +private val EMPTY_NANO_TIME = SentryNanotimeDate(0, 0L) + +/** Span instrumentation for [SentrySQLiteDriver]. */ +internal class DriverSpans(private val scopes: IScopes, private val dbMetadata: DbMetadata) { + + private val stackTraceFactory = SentryStackTraceFactory(scopes.options) + + /** + * Returns a timestamp in nanoseconds for use with [record]. Timestamp is ns-precise if the active + * parent span uses a [SentryNanotimeDate] (the ordinary case); otherwise it's ms-precise. + * + * Note: Internalizing the start time in [record] would shift spans to end-of-work on the trace + * timeline, which is less desirable; callers capture the start before doing database work and + * pass it back to [record]. + */ + fun startTimestamp(): Long = + // Try to retain nanosecond precision + avoid SentryDate allocation... + scopes.span?.computeNanoStartTimestampForChild() + // ...otherwise fall back to millisecond precision + allocate. + ?: scopes.options.dateProvider.now().nanoTimestamp() + + /** Records a `db.sql.query` span. */ + fun record( + sql: String, + startTimestampNanos: Long, + durationNanos: Long, + status: SpanStatus, + throwable: Throwable? = null, + ) { + val parent = scopes.span ?: return + val startTimestamp = SentryLongDate(startTimestampNanos) + val endTimestamp = SentryLongDate(startTimestampNanos + durationNanos) + + parent.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY).apply { + spanContext.origin = SQLITE_TRACE_ORIGIN + throwable?.let { this.throwable = it } + + val isMainThread = scopes.options.threadChecker.isMainThread + setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread) + + if (isMainThread) { + setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack) + } + + dbMetadata.name?.let { setData(SpanDataConvention.DB_NAME_KEY, it) } + setData(SpanDataConvention.DB_SYSTEM_KEY, dbMetadata.system) + finish(status, endTimestamp) + } + } + + companion object { + + /** + * Returns [DriverSpans] based on the [fileName] argument passed to + * [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open]. + */ + fun fromFileName(fileName: String, scopes: IScopes = ScopesAdapter.getInstance()): DriverSpans = + DriverSpans(scopes, dbMetadataFromFileName(fileName)) + } +} + +/** + * Computes a start timestamp with nanosecond precision for the child of the receiver span. Returns + * null if nanosecond precision isn't possible. + * + * Lets us improve the display of spans in the Sentry UI. If timestamps are only ms-precise, the + * Sentry UI will left-align and arbitrarily reorder spans that share the same wall clock ms: + * ``` + * (Relative start times out of order) + * ↓ + * Parent span ├█████████████┤ + * END TRANSACTION ├███┤ 0.33 ms + * BEGIN IMMEDIATE TRANSACTION ├████┤ 0.02 ms + * INSERT INTO `my_db` … ├██┤ 0.30 ms + * ↑ + * (All spans share the same ms baseline + * even though their execution was staggered) + * ``` + * + * Nanosecond precision ensures proper ordering and lets the spans stagger: + * ``` + * Parent span ├█████████████┤ + * BEGIN IMMEDIATE TRANSACTION ├████┤ 0.02 ms + * INSERT INTO `my_db` … ├██┤ 0.30 ms + * END TRANSACTION ├███┤ 0.33 ms + * ``` + */ +internal fun ISpan.computeNanoStartTimestampForChild(): Long? { + if (startDate !is SentryNanotimeDate) { + return null + } + + val parentWallClockNanos = startDate.nanoTimestamp() + val parentMonotonicNanos = startDate.diff(EMPTY_NANO_TIME) + val elapsedSinceParentStart = System.nanoTime() - parentMonotonicNanos + // Return the child's absolute start time. + return parentWallClockNanos + elapsedSinceParentStart +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt new file mode 100644 index 00000000000..e01544b0523 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt @@ -0,0 +1,15 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement + +internal class SentrySQLiteConnection( + private val delegate: SQLiteConnection, + private val spans: DriverSpans, +) : SQLiteConnection by delegate { + + override fun prepare(sql: String): SQLiteStatement { + val statement = delegate.prepare(sql) + return statement as? SentrySQLiteStatement ?: SentrySQLiteStatement(statement, spans, sql) + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt new file mode 100644 index 00000000000..28b661cd3e7 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -0,0 +1,107 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import io.sentry.ScopesAdapter +import io.sentry.SentryLevel +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion + +/** + * Wraps a [SQLiteDriver] and automatically adds spans for each SQL statement it executes. + * + * Example usage: + * ``` + * val driver = SentrySQLiteDriver.create(AndroidSQLiteDriver()) + * ``` + * + * If you use Room: + * ``` + * val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName") + * .setDriver(SentrySQLiteDriver.create(AndroidSQLiteDriver())) + * .build() + * ``` + * + * If you're using the Sentry Android Gradle Plugin (SAGP) 6.13.0+, wrapping will be performed + * automatically for Room. + * + * @param delegate The [SQLiteDriver] instance to delegate calls to. + */ +public class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) : + SQLiteDriver { + + init { + addIntegrationToSdkVersion("SQLiteDriver") + } + + @Suppress("INAPPLICABLE_JVM_NAME") + @get:JvmName("hasConnectionPool") + override val hasConnectionPool: Boolean + get() = + try { + delegate.hasConnectionPool + } catch (_: LinkageError) { + // Delegates on androidx.sqlite < 2.6.0 won't have a hasConnectionPool property. + false + } + + @Suppress("TooGenericExceptionCaught") + override fun open(fileName: String): SQLiteConnection { + val connection = delegate.open(fileName) + + return try { + val spans = DriverSpans.fromFileName(fileName) + // create() ensures delegate is unwrapped, so we don't need to protect against double-wrapping + // the connection. + SentrySQLiteConnection(connection, spans) + } catch (t: Throwable) { + ScopesAdapter.getInstance() + .options + .logger + .log( + SentryLevel.ERROR, + "Failed to instrument SQLite connection; returning uninstrumented connection.", + t, + ) + connection + } + } + + public companion object { + + /** + * Name of the bridge adapter often used with Room 2.7+. It implements the `SQLiteDriver` + * interface and its constructor consumes a `SupportSQLiteOpenHelper`. (Users of the Sentry + * Android Gradle Plugin will have the `SupportSQLiteOpenHelper` wrapped for them + * automatically.) We deliberately avoid wrapping the adapter to prevent duplicate spans. + * + * String (rather than an `is` check) lets us avoid a compile-time dependency on + * androidx.sqlite:sqlite-framework. + */ + private const val SUPPORT_SQLITE_DRIVER_FQN = "androidx.sqlite.driver.SupportSQLiteDriver" + + /** + * Wraps the provided delegate in a [SentrySQLiteDriver]. + * + * To avoid duplicate spans, returns the delegate as-is if: + * 1. it's already wrapped, or + * 2. it's an `androidx.sqlite.driver.SupportSQLiteDriver`. + * + * In the case of (2), wrap the open helper passed to the `SupportSQLiteDriver` constructor via + * `SentrySupportSQLiteOpenHelper` instead. + * + * Note that wrapping will be performed if the delegate isn't a `SupportSQLiteDriver` itself but + * wraps or subclasses one. In that case, ensure the open helper passed to the support driver + * constructor is *not* wrapped. + */ + // Warning! The SAGP depends on this method's ABI. + @JvmStatic + public fun create(delegate: SQLiteDriver): SQLiteDriver = + // FQN check simplifies our SAGP implementation, allowing it to naively instrument all + // RoomDatabase.Builder.setDriver() call sites. + if (delegate is SentrySQLiteDriver || delegate.javaClass.name == SUPPORT_SQLITE_DRIVER_FQN) { + delegate + } else { + SentrySQLiteDriver(delegate) + } + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt new file mode 100644 index 00000000000..e220a74cd1e --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt @@ -0,0 +1,79 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteStatement +import io.sentry.SpanStatus + +/** + * Wraps a [SQLiteStatement] and records a single Sentry span covering all [step] calls for the + * statement's lifetime (until [step] iteration is complete or the statement is [reset] or + * [closed][close]). + * + * Span duration is restricted to accumulated database time, i.e., each [step] call is individually + * timed and the durations are summed. Time the application spends between steps (e.g., processing + * rows, sleeping, or doing I/O) is intentionally excluded. + * + * Not thread-safe: assumes sequential access within each SQL statement (normal SQLite usage). + */ +internal class SentrySQLiteStatement( + private val delegate: SQLiteStatement, + private val spans: DriverSpans, + private val sql: String, + private val nanoTimeProvider: () -> Long = { System.nanoTime() }, +) : SQLiteStatement by delegate { + + private var firstStepTimestampNanos: Long? = null + private var accumulatedDbNanos: Long = 0L + private var stepsComplete = false + private var closed = false + + @Suppress("TooGenericExceptionCaught") + override fun step(): Boolean { + if (stepsComplete || closed) { + return delegate.step() + } + + val beforeNanos = nanoTimeProvider() + return try { + if (firstStepTimestampNanos == null) { + firstStepTimestampNanos = spans.startTimestamp() + } + + stepsComplete = !delegate.step() + accumulatedDbNanos += nanoTimeProvider() - beforeNanos + if (stepsComplete) { + recordSpan(SpanStatus.OK) + } + !stepsComplete + } catch (e: Throwable) { + accumulatedDbNanos += nanoTimeProvider() - beforeNanos + recordSpan(SpanStatus.INTERNAL_ERROR, e) + throw e + } + } + + override fun reset() { + if (closed) { + return delegate.reset() + } + + try { + recordSpan(SpanStatus.OK) + } finally { + delegate.reset() + stepsComplete = false + } + } + + override fun close() { + closed = true + delegate.use { recordSpan(SpanStatus.OK) } + } + + private fun recordSpan(status: SpanStatus, throwable: Throwable? = null) { + val startNanos = firstStepTimestampNanos ?: return + val duration = accumulatedDbNanos + firstStepTimestampNanos = null + accumulatedDbNanos = 0L + spans.record(sql, startNanos, duration, status, throwable) + } +} diff --git a/sentry-android-sqlite/src/test/AndroidManifest.xml b/sentry-android-sqlite/src/test/AndroidManifest.xml new file mode 100644 index 00000000000..967265a1f16 --- /dev/null +++ b/sentry-android-sqlite/src/test/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt new file mode 100644 index 00000000000..2de7f1d38f5 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt @@ -0,0 +1,18 @@ +package androidx.sqlite.driver + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver + +/** + * Minimal stub of `androidx.sqlite.driver.SupportSQLiteDriver` (which lives in + * `androidx.sqlite:sqlite-framework`, not on this module's compile/test classpath) for verifying + * behavior of `SentrySQLiteDriver.create(SupportSQLiteDriver)`. + */ +internal class SupportSQLiteDriver : SQLiteDriver { + + override val hasConnectionPool: Boolean = false + + override fun open(fileName: String): SQLiteConnection { + throw UnsupportedOperationException("Test stub; not for runtime use") + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt similarity index 97% rename from sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt rename to sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt index 6fd6fa51bb3..0552094838e 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt @@ -21,13 +21,13 @@ import org.junit.Before import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -class SQLiteSpanManagerTest { +class OpenHelperSpansTest { private class Fixture { private val scopes = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions - fun getSut(isSpanActive: Boolean = true, databaseName: String? = null): SQLiteSpanManager { + fun getSut(isSpanActive: Boolean = true, databaseName: String? = null): OpenHelperSpans { options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } whenever(scopes.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) @@ -35,7 +35,7 @@ class SQLiteSpanManagerTest { if (isSpanActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SQLiteSpanManager(scopes, databaseName) + return OpenHelperSpans(scopes, databaseName) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt index 44836dd0c97..ba77b2398c7 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt @@ -1,6 +1,7 @@ package io.sentry.android.sqlite import android.database.CrossProcessCursor +import android.database.CursorWrapper import io.sentry.IScopes import io.sentry.ISpan import io.sentry.SentryOptions @@ -20,7 +21,7 @@ import org.mockito.kotlin.whenever class SentryCrossProcessCursorTest { private class Fixture { private val scopes = mock() - private val spanManager = SQLiteSpanManager(scopes) + private val spans = OpenHelperSpans(scopes) val mockCursor = mock() lateinit var options: SentryOptions lateinit var sentryTracer: SentryTracer @@ -33,7 +34,7 @@ class SentryCrossProcessCursorTest { if (isSpanActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SentryCrossProcessCursor(mockCursor, spanManager, sql) + return SentryCrossProcessCursor(mockCursor, spans, sql) } } @@ -52,13 +53,14 @@ class SentryCrossProcessCursorTest { cursor.fillWindow(0, mock()) verify(fixture.mockCursor).fillWindow(eq(0), any()) + } - // Let's verify other methods are delegated, even if not explicitly - cursor.close() - verify(fixture.mockCursor).close() + @Test + fun `ordinary cursor methods are delegated by Android CursorWrapper`() { + val getStringMethod = + SentryCrossProcessCursor::class.java.getMethod("getString", Int::class.javaPrimitiveType!!) - cursor.getString(1) - verify(fixture.mockCursor).getString(eq(1)) + assertEquals(CursorWrapper::class.java, getStringMethod.declaringClass) } @Test diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt index 81bd964cc87..6a47eb6fa92 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt @@ -23,7 +23,7 @@ import org.mockito.kotlin.whenever class SentrySupportSQLiteDatabaseTest { private class Fixture { private val scopes = mock() - private val spanManager = SQLiteSpanManager(scopes) + private val spans = OpenHelperSpans(scopes) val mockDatabase = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions @@ -41,7 +41,7 @@ class SentrySupportSQLiteDatabaseTest { whenever(scopes.span).thenReturn(sentryTracer) } - return SentrySupportSQLiteDatabase(mockDatabase, spanManager) + return SentrySupportSQLiteDatabase(mockDatabase, spans) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt index b2b4998ace8..c4d810adbcd 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt @@ -18,7 +18,7 @@ import org.mockito.kotlin.whenever class SentrySupportSQLiteStatementTest { private class Fixture { private val scopes = mock() - private val spanManager = SQLiteSpanManager(scopes) + private val spans = OpenHelperSpans(scopes) val mockStatement = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions @@ -31,7 +31,7 @@ class SentrySupportSQLiteStatementTest { if (isSpanActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SentrySupportSQLiteStatement(mockStatement, spanManager, sql) + return SentrySupportSQLiteStatement(mockStatement, spans, sql) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt new file mode 100644 index 00000000000..13ae1389b77 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt @@ -0,0 +1,99 @@ +package io.sentry.sqlite + +import io.sentry.DateUtils +import io.sentry.ISpan +import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ComputeNanoStartTimestampForChildTest { + + @Test + fun `returns parent wall clock plus elapsed monotonic time since parent started`() { + val wallClockMillis = 1_000_000L + val elapsedNanos = 500_000L + val parentMonotonicNanos = System.nanoTime() - elapsedNanos + val span = spanWithNanotimeStart(wallClockMillis, parentMonotonicNanos) + + val timestamp = span.computeNanoStartTimestampForChild()!! + + val elapsedSinceParentStart = timestamp - DateUtils.millisToNanos(wallClockMillis) + assertTrue(elapsedSinceParentStart >= elapsedNanos) + assertTrue(elapsedSinceParentStart < elapsedNanos + TEST_SLACK_NANOS) + } + + @Test + fun `same millisecond wall clocks with different monotonic offsets produce distinct ordered timestamps`() { + val wallClockMillis = 1_000_000L + val wallClockNanos = DateUtils.millisToNanos(wallClockMillis) + val earlierParentMonotonicNanos = System.nanoTime() - 200_000L + val laterParentMonotonicNanos = System.nanoTime() - 800_000L + val earlierSpan = spanWithNanotimeStart(wallClockMillis, earlierParentMonotonicNanos) + val laterSpan = spanWithNanotimeStart(wallClockMillis, laterParentMonotonicNanos) + + assertEquals( + earlierSpan.startDate.nanoTimestamp(), + laterSpan.startDate.nanoTimestamp(), + "Raw parent timestamps share the same ms-quantized value", + ) + + val earlier = earlierSpan.computeNanoStartTimestampForChild()!! + val later = laterSpan.computeNanoStartTimestampForChild()!! + + assertTrue(earlier > wallClockNanos) + assertTrue(later > wallClockNanos) + assertTrue(earlier < later) + assertTrue(later - earlier >= 500_000L) + } + + @Test + fun `returns parent wall clock when no monotonic time has elapsed since parent started`() { + val wallClockMillis = 1_000_000L + val parentMonotonicNanos = System.nanoTime() + val span = spanWithNanotimeStart(wallClockMillis, parentMonotonicNanos) + + val elapsedSinceParentStart = + span.computeNanoStartTimestampForChild()!! - DateUtils.millisToNanos(wallClockMillis) + assertTrue(elapsedSinceParentStart >= 0L) + assertTrue(elapsedSinceParentStart < TEST_SLACK_NANOS) + } + + @Test + fun `works when parent wall clock differs from millisecond baseline`() { + val wallClockMillis = 1_000_001L + val elapsedNanos = 1_500_000L + val parentMonotonicNanos = System.nanoTime() - elapsedNanos + val span = spanWithNanotimeStart(wallClockMillis, parentMonotonicNanos) + + val elapsedSinceParentStart = + span.computeNanoStartTimestampForChild()!! - DateUtils.millisToNanos(wallClockMillis) + assertTrue(elapsedSinceParentStart >= elapsedNanos) + assertTrue(elapsedSinceParentStart < elapsedNanos + TEST_SLACK_NANOS) + } + + @Test + fun `returns null when start date is not SentryNanotimeDate`() { + val span = mock() + whenever(span.startDate).thenReturn(SentryLongDate(DateUtils.millisToNanos(1_000_000L))) + + assertNull(span.computeNanoStartTimestampForChild()) + } + + private fun spanWithNanotimeStart(wallClockMillis: Long, parentMonotonicNanos: Long): ISpan { + val startDate = SentryNanotimeDate(wallClockMillis, parentMonotonicNanos) + val span = mock() + whenever(span.startDate).thenReturn(startDate) + return span + } + + companion object { + + // Upper bound for monotonic drift while the test body runs. + private const val TEST_SLACK_NANOS = 50_000_000L + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt new file mode 100644 index 00000000000..09d80793ed2 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt @@ -0,0 +1,79 @@ +package io.sentry.sqlite + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DbMetadataTest { + + @Test + fun `dbMetadataFromFileName returns in-memory system with no db name for in-memory sentinel`() { + assertEquals( + DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY), + dbMetadataFromFileName(":memory:"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for unix path`() { + assertEquals( + DbMetadata(name = "tracks.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data/data/com.example/databases/tracks.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name when fileName has no separator`() { + assertEquals( + DbMetadata(name = "tracks", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("tracks"), + ) + assertEquals( + DbMetadata(name = "tracks.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("tracks.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for relative path with forward slashes`() { + assertEquals( + DbMetadata(name = "myapp.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("databases/myapp.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for windows-style path`() { + assertEquals( + DbMetadata(name = "myapp.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("C:\\Users\\app\\databases\\myapp.db"), + ) + } + + @Test + fun `dbMetadataFromFileName uses last separator when both slash types are present`() { + assertEquals( + DbMetadata(name = "db.sqlite", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data\\mixed/path\\db.sqlite"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name when fileName ends with separator`() { + assertEquals( + DbMetadata(name = "databases", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data/data/com.example/databases/"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and unknown db name when fileName contains only separators`() { + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("/")) + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("///")) + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("\\\\")) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and unknown db name for empty fileName`() { + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("")) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt new file mode 100644 index 00000000000..319fc20d7ce --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt @@ -0,0 +1,219 @@ +package io.sentry.sqlite + +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.SentryDateProvider +import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.util.thread.IThreadChecker +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class DriverSpansTest { + + private class Fixture { + + val scopes = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + + fun getSut(isTransactionActive: Boolean = true, fileName: String = ":memory:"): DriverSpans { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) + if (isTransactionActive) { + whenever(scopes.span).thenReturn(sentryTracer) + } + return DriverSpans.fromFileName(fileName, scopes) + } + } + + private val fixture = Fixture() + + @Test + fun `startTimestamp is ns-precise and skips date provider when parent uses SentryNanotimeDate`() { + // Only the parent date is queued. If startTimestamp() were to call dateProvider.now(), + // the queue would underflow and the test would fail loudly — this is what verifies the + // optimization is in effect. + val parentDate = SentryNanotimeDate(1_000_000L, 100_000_000L) + val sut = setUpWithNanotimeDates(parentDate) + + val start = sut.startTimestamp() + + val durationNanos = 42_000_000L + sut.record("SELECT 1", start, durationNanos, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + + // startTimestamp returns an already-ns-precise value, anchored to the parent's wall clock and + // offset by elapsed System.nanoTime(). The exact ns-math is unit-tested in + // ChildStartTimestampOrNullTest; here we verify the integration shape. + assertIs(span.startDate) + assertEquals(start, span.startDate.nanoTimestamp()) + assertEquals(start + durationNanos, span.finishDate!!.nanoTimestamp()) + } + + @Test + fun `startTimestamp falls back to date provider when parent does not use SentryNanotimeDate`() { + val providerDate = SentryNanotimeDate(2_000_000L, 200_000_000L) + val parentSpan = mock() + whenever(parentSpan.startDate).thenReturn(SentryLongDate(1_000_000_000_000_000L)) + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + dateProvider = SentryDateProvider { providerDate } + } + whenever(fixture.scopes.options).thenReturn(options) + whenever(fixture.scopes.span).thenReturn(parentSpan) + + val sut = DriverSpans.fromFileName(":memory:", fixture.scopes) + + assertEquals(providerDate.nanoTimestamp(), sut.startTimestamp()) + } + + @Test + fun `startTimestamp falls back to date provider when no transaction is active`() { + val providerDate = SentryNanotimeDate(2_000_000L, 200_000_000L) + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + dateProvider = SentryDateProvider { providerDate } + } + whenever(fixture.scopes.options).thenReturn(options) + whenever(fixture.scopes.span).thenReturn(null) + + val sut = DriverSpans.fromFileName(":memory:", fixture.scopes) + + assertEquals(providerDate.nanoTimestamp(), sut.startTimestamp()) + } + + @Test + fun `record method records a span if a transaction is active`() { + val sut = fixture.getSut(isTransactionActive = true) + sut.record("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + assertEquals(1, fixture.sentryTracer.children.size) + } + + @Test + fun `record method does not record a span if no transaction is active`() { + val sut = fixture.getSut(isTransactionActive = false) + val start = sut.startTimestamp() + sut.record("SELECT 1", start, 1_000_000, SpanStatus.OK) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `record method creates a span with correct properties`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + sut.record("SELECT * FROM users", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.firstOrNull() + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals("SELECT * FROM users", span.description) + assertEquals("auto.db.sqlite", span.spanContext.origin) + assertEquals(SpanStatus.OK, span.status) + assertTrue(span.isFinished) + } + + @Test + fun `record method sets finishDate equal to startDate + durationNanos`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + val durationNanos = 42_000_000L + + sut.record("SELECT 1", start, durationNanos, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals(span.startDate.nanoTimestamp() + durationNanos, span.finishDate!!.nanoTimestamp()) + } + + @Test + fun `record method attaches throwable when provided`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + val exception = RuntimeException("disk I/O error") + + sut.record("INSERT INTO t VALUES(1)", start, 500_000, SpanStatus.INTERNAL_ERROR, exception) + + val span = fixture.sentryTracer.children.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + } + + @Test + fun `record method sets db system and db name when fileName is not the in-memory sentinel`() { + val sut = fixture.getSut(fileName = "/data/data/com.example/databases/tracks.db") + val start = sut.startTimestamp() + sut.record("SELECT 1", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `record method sets db system only when fileName is the in-memory sentinel`() { + val sut = fixture.getSut(fileName = ":memory:") + val start = sut.startTimestamp() + sut.record("SELECT 1", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("in-memory", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertNull(span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `record method sets blocked_main_thread to true and attaches call stack on main thread`() { + val sut = fixture.getSut() + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(true) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("main") + + sut.record("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertTrue(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + assertNotNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) + } + + @Test + fun `record method sets blocked_main_thread to false and does not attach a call stack on background thread`() { + val sut = fixture.getSut() + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(false) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("worker") + + sut.record("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertFalse(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + assertNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) + } + + private fun setUpWithNanotimeDates(vararg dates: SentryNanotimeDate): DriverSpans { + val dateQueue = ArrayDeque(dates.toList()) + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + dateProvider = SentryDateProvider { dateQueue.removeFirst() } + } + whenever(fixture.scopes.options).thenReturn(options) + fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) + return DriverSpans.fromFileName(":memory:", fixture.scopes) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt new file mode 100644 index 00000000000..212e3b032e4 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt @@ -0,0 +1,63 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement +import io.sentry.IScopes +import io.sentry.SentryOptions +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertSame +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteConnectionTest { + + private class Fixture { + + val scopes = mock() + val mockConnection = mock() + val mockStatement = mock() + lateinit var options: SentryOptions + + fun getSut(): SentrySQLiteConnection { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + whenever(mockConnection.prepare("SELECT 1")).thenReturn(mockStatement) + val spans = DriverSpans.fromFileName("test.db", scopes) + return SentrySQLiteConnection(mockConnection, spans) + } + } + + private val fixture = Fixture() + + @Test + fun `prepare returns a SentrySQLiteStatement`() { + val sut = fixture.getSut() + val statement = sut.prepare("SELECT 1") + assertIs(statement) + } + + @Test + fun `prepare with already-wrapped statement returns same instance without re-wrapping`() { + val sut = fixture.getSut() + val spans = DriverSpans.fromFileName("test.db", fixture.scopes) + val alreadyInstrumented = SentrySQLiteStatement(fixture.mockStatement, spans, "SELECT 1") + whenever(fixture.mockConnection.prepare("SELECT 1")).thenReturn(alreadyInstrumented) + + val statement = sut.prepare("SELECT 1") + + assertSame(alreadyInstrumented, statement) + } + + @Test + fun `all calls are propagated to the delegate`() { + val sut = fixture.getSut() + + sut.prepare("SELECT 1") + verify(fixture.mockConnection).prepare("SELECT 1") + + sut.close() + verify(fixture.mockConnection).close() + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt new file mode 100644 index 00000000000..5816f3d859c --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt @@ -0,0 +1,156 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteStatement +import androidx.sqlite.driver.SupportSQLiteDriver +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.TransactionContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.Before +import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteDriverTest { + + private class Fixture { + + val mockDriver = mock() + val mockConnection = mock() + + fun getSut(fileName: String): SentrySQLiteDriver { + whenever(mockDriver.open(fileName)).thenReturn(mockConnection) + return SentrySQLiteDriver.create(mockDriver) as SentrySQLiteDriver + } + } + + private val fixture = Fixture() + + @Before + fun setup() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + } + + @Test + fun `create registers SQLiteDriver integration`() { + assertFalse(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLiteDriver")) + SentrySQLiteDriver.create(fixture.mockDriver) + assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLiteDriver")) + } + + @Test + fun `create with non-wrapped driver returns SentrySQLiteDriver`() { + val result = SentrySQLiteDriver.create(fixture.mockDriver) + assertIs(result) + } + + @Test + fun `create with already-wrapped driver returns same instance without re-wrapping`() { + val wrapped = SentrySQLiteDriver.create(fixture.mockDriver) + val doubleWrapped = SentrySQLiteDriver.create(wrapped) + assertSame(wrapped, doubleWrapped) + } + + @Test + fun `create with SupportSQLiteDriver bridge returns same instance without wrapping`() { + val bridge = SupportSQLiteDriver() + + val result = SentrySQLiteDriver.create(bridge) + + assertSame(bridge, result) + assertFalse(result is SentrySQLiteDriver) + } + + @Test + fun `hasConnectionPool forwards delegate value when supported`() { + whenever(fixture.mockDriver.hasConnectionPool).thenReturn(true) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertTrue(sut.hasConnectionPool) + } + + @Test + fun `hasConnectionPool returns false when delegate throws LinkageError`() { + whenever(fixture.mockDriver.hasConnectionPool).thenThrow(AbstractMethodError()) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertFalse(sut.hasConnectionPool) + } + + @Test + fun `hasConnectionPool does not catch non-LinkageErrors`() { + whenever(fixture.mockDriver.hasConnectionPool).thenThrow(IllegalStateException()) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertFailsWith { sut.hasConnectionPool } + } + + @Test + fun `open returns SentrySQLiteConnection wrapping delegate if wrapping succeeds`() { + val driver = fixture.getSut("myapp.db") + val connection = driver.open("myapp.db") + assertIs(connection) + } + + @Test + fun `open returns the unwrapped delegate if wrapping fails`() { + val brokenScopes = mock() + val validOptions = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(brokenScopes.options) + .thenThrow(RuntimeException("Sentry options unavailable")) + .thenReturn(validOptions) + + Mockito.mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(brokenScopes) + + val driver = fixture.getSut("myapp.db") + val result = driver.open("myapp.db") + + assertSame(fixture.mockConnection, result) + verify(fixture.mockDriver).open("myapp.db") + } + } + + // Smoke test ensuring all layers are properly wired up. + @Test + fun `full stack produces a span with correct metadata`() { + val scopes = mock() + val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + val tracer = SentryTracer(TransactionContext("name", "op"), scopes) + whenever(scopes.span).thenReturn(tracer) + + val mockStatement = mock() + whenever(fixture.mockConnection.prepare("SELECT * FROM users")).thenReturn(mockStatement) + whenever(mockStatement.step()).thenReturn(true, false) + + Mockito.mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + + val driver = fixture.getSut("/data/data/com.example/databases/myapp.db") + val connection = driver.open("/data/data/com.example/databases/myapp.db") + val statement = connection.prepare("SELECT * FROM users") + + assertIs(connection) + assertIs(statement) + + statement.step() + statement.step() + + val span = tracer.children.firstOrNull() + assertNotNull(span) + assertEquals("myapp.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt new file mode 100644 index 00000000000..bc6b074545a --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt @@ -0,0 +1,290 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteStatement +import io.sentry.SpanStatus +import java.util.concurrent.atomic.AtomicLong +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteStatementTest { + + private class Fixture { + val mockStatement = mock() + val mockSpans = mock() + val startTimestampNanos = 1_000_000_000_000L + val fakeClock = AtomicLong(0L) + + fun getSut(sql: String): SentrySQLiteStatement { + whenever(mockSpans.startTimestamp()).thenReturn(startTimestampNanos) + return SentrySQLiteStatement(mockStatement, mockSpans, sql, fakeClock::getAndIncrement) + } + } + + private val fixture = Fixture() + + @Test + fun `step calls recordSpan once after iteration completes`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, true, false) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + sut.step() + verify(fixture.mockSpans) + .record( + eq("SELECT * FROM users"), + eq(fixture.startTimestampNanos), + any(), + eq(SpanStatus.OK), + anyOrNull(), + ) + } + + @Test + fun `step that throws an exception calls recordSpan with INTERNAL_ERROR and exception`() { + val sut = fixture.getSut("BAD SQL") + val exception = RuntimeException("db error") + whenever(fixture.mockStatement.step()).thenThrow(exception) + + assertFailsWith { sut.step() } + + verify(fixture.mockSpans) + .record( + eq("BAD SQL"), + eq(fixture.startTimestampNanos), + any(), + eq(SpanStatus.INTERNAL_ERROR), + eq(exception), + ) + } + + @Test + fun `step after exception calls recordSpan once new iteration cycle completes`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()) + .thenThrow(RuntimeException("first failure")) + .thenReturn(false) + + assertFailsWith { sut.step() } + verifyCalledRecordSpan(times = 1) + + sut.step() + verifyCalledRecordSpan(times = 2) + } + + @Test + fun `step after step iteration completes does not call recordSpan again`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()).thenReturn(true, false, false) + + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.step() + + verifyCalledRecordSpan(times = 1) + verify(fixture.mockStatement, times(3)).step() + } + + @Test + fun `reset calls recordSpan if step iteration is in progress`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + + sut.reset() + + verifyCalledRecordSpan() + } + + @Test + fun `reset does not call recordSpan if step iteration has not started`() { + val sut = fixture.getSut("SELECT 1") + sut.reset() + verifyNeverCalledRecordSpan() + } + + @Test + fun `reset does not call recordSpan if step iteration has completed`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, false) + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.reset() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `step after reset calls recordSpan when new iteration cycle completes`() { + val sut = fixture.getSut("SELECT 1") + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.reset() + sut.step() + + verifyCalledRecordSpan(times = 2) + } + + @Test + fun `close calls recordSpan if step iteration is in progress`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + + sut.close() + + verifyCalledRecordSpan() + } + + @Test + fun `close does not call recordSpan if step iteration has not started`() { + val sut = fixture.getSut("SELECT 1") + sut.close() + verifyNeverCalledRecordSpan() + } + + @Test + fun `close does not call recordSpan if step iteration has completed`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, false) + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.close() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `step after close does not call recordSpan`() { + val sut = fixture.getSut("SELECT 1") + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.close() + sut.step() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `reset after close does not call recordSpan`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.close() + verifyCalledRecordSpan(times = 1) + + sut.reset() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `recorded duration captures step time but excludes time between steps`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()) + .thenAnswer { + fixture.fakeClock.addAndGet(10) + true + } + .thenAnswer { + fixture.fakeClock.addAndGet(20) + true + } + .thenAnswer { + fixture.fakeClock.addAndGet(30) + false + } + + sut.step() + // Simulate work done between steps. + fixture.fakeClock.addAndGet(1_000_000) + sut.step() + fixture.fakeClock.addAndGet(2_000_000) + sut.step() + + val durationCaptor = argumentCaptor() + verify(fixture.mockSpans).record(any(), any(), durationCaptor.capture(), any(), anyOrNull()) + // Each step contributes its internal time (10 + 20 + 30) plus one unit from + // fakeClock::getAndIncrement between before/after reads, so total is 63. + assertEquals(63L, durationCaptor.firstValue) + } + + @Test + fun `all calls are propagated to the delegate`() { + val sut = fixture.getSut("SELECT 1") + + sut.bindBlob(0, byteArrayOf()) + verify(fixture.mockStatement).bindBlob(0, byteArrayOf()) + + sut.bindDouble(0, 1.0) + verify(fixture.mockStatement).bindDouble(0, 1.0) + + sut.bindLong(0, 1L) + verify(fixture.mockStatement).bindLong(0, 1L) + + sut.bindText(0, "text") + verify(fixture.mockStatement).bindText(0, "text") + + sut.bindNull(0) + verify(fixture.mockStatement).bindNull(0) + + sut.getDouble(0) + verify(fixture.mockStatement).getDouble(0) + + sut.getLong(0) + verify(fixture.mockStatement).getLong(0) + + sut.getText(0) + verify(fixture.mockStatement).getText(0) + + sut.isNull(0) + verify(fixture.mockStatement).isNull(0) + + sut.getColumnCount() + verify(fixture.mockStatement).getColumnCount() + + sut.getColumnName(0) + verify(fixture.mockStatement).getColumnName(0) + + sut.step() + verify(fixture.mockStatement).step() + + sut.reset() + verify(fixture.mockStatement).reset() + + sut.clearBindings() + verify(fixture.mockStatement).clearBindings() + + sut.close() + verify(fixture.mockStatement).close() + } + + private fun verifyNeverCalledRecordSpan() { + verifyCalledRecordSpan(times = 0) + } + + private fun verifyCalledRecordSpan(times: Int = 1) { + verify(fixture.mockSpans, times(times)).record(any(), any(), any(), any(), anyOrNull()) + } +} diff --git a/sentry-android-timber/build.gradle.kts b/sentry-android-timber/build.gradle.kts index 16083b43f1b..3c8ac1ea1e4 100644 --- a/sentry-android-timber/build.gradle.kts +++ b/sentry-android-timber/build.gradle.kts @@ -1,10 +1,10 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } @@ -32,10 +32,14 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + kotlin { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt b/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt index 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 4c9aba6e31b..7502fb6c4b8 100644 --- a/sentry-apache-http-client-5/build.gradle.kts +++ b/sentry-apache-http-client-5/build.gradle.kts @@ -1,19 +1,20 @@ import net.ltgt.gradle.errorprone.errorprone -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } -tasks.withType().configureEach { - compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 +kotlin { + compilerOptions.jvmTarget = JvmTarget.JVM_1_8 + compilerOptions.languageVersion = KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = KotlinVersion.KOTLIN_1_9 } dependencies { @@ -34,27 +35,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt b/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt index 639dd4e0513..9f5c9b910ad 100644 --- a/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt +++ b/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt @@ -213,7 +213,7 @@ class ApacheHttpClientTransportTest { val now = Date(9001) val sut = fixture.getSut() fixture.options.dateProvider = mock() - whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now, 0)) + whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now.time, 0)) val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) sut.send(envelope) @@ -226,7 +226,7 @@ class ApacheHttpClientTransportTest { val now = Date(9001) val sut = fixture.getSut() fixture.options.dateProvider = mock() - whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now, 0)) + whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now.time, 0)) val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) sut.send(envelope, Hint()) diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index 8819e0993d4..70f43d946ef 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -5,11 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -38,31 +37,6 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") -} - -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } } tasks.withType().configureEach { diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index 85ea2c3b52b..4f1276f0bf4 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -5,16 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) -} - -configure { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -45,31 +39,6 @@ dependencies { testImplementation(libs.mockito.inline) testImplementation(libs.okhttp.mockwebserver) testImplementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0") - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") -} - -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } } tasks.withType().configureEach { diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index 909d52aa127..2da8d8b20c1 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -5,11 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -39,31 +38,6 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") -} - -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } } tasks.withType().configureEach { diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index 5af2f0bef45..17454baa662 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -4,11 +4,11 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` kotlin("jvm") - jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -37,27 +37,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java index b7b5662a8e5..718fae422f7 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -26,7 +26,6 @@ @ApiStatus.Internal public final class JfrAsyncProfilerToSentryProfileConverter extends JfrConverter { - private static final double NANOS_PER_SECOND = 1_000_000_000.0; private static final long UNKNOWN_THREAD_ID = -1; private final @NotNull SentryProfile sentryProfile = new SentryProfile(); @@ -83,7 +82,6 @@ private class ProfileEventVisitor implements EventCollector.Visitor { private final @NotNull SentryStackTraceFactory stackTraceFactory; private final @NotNull JfrReader jfr; private final @NotNull Arguments args; - private final double ticksPerNanosecond; public ProfileEventVisitor( @NotNull SentryProfile sentryProfile, @@ -94,7 +92,6 @@ public ProfileEventVisitor( this.stackTraceFactory = stackTraceFactory; this.jfr = jfr; this.args = args; - ticksPerNanosecond = jfr.ticksPerSec / NANOS_PER_SECOND; } @Override @@ -150,11 +147,7 @@ private void processSampleWithStack(Event event, long threadId, StackTrace stack } private double calculateTimestamp(Event event) { - long nanosFromStart = (long) ((event.time - jfr.chunkStartTicks) / ticksPerNanosecond); - - long timeNs = jfr.chunkStartNanos + nanosFromStart; - - return DateUtils.nanosToSeconds(timeNs); + return DateUtils.nanosToSeconds(jfr.eventTimeToNanos(event.time)); } private int addStackTrace(StackTrace stackTrace) { 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-bom/build.gradle.kts b/sentry-bom/build.gradle.kts index 45ef7363d47..f219c964e52 100644 --- a/sentry-bom/build.gradle.kts +++ b/sentry-bom/build.gradle.kts @@ -9,6 +9,7 @@ dependencies { .filter { !it.name.startsWith("sentry-samples") && it.name != project.name && + !it.name.endsWith("-bom") && !it.name.contains("test", ignoreCase = true) && !it.name.contains("sentry-android-distribution") } diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 3385d0328e2..8b835ba16fe 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -7,7 +7,6 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.compose) id("com.android.library") - alias(libs.plugins.kover) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) alias(libs.plugins.dokka) @@ -45,13 +44,13 @@ kotlin { } sourceSets { - val commonMain by getting { + getByName("commonMain") { compilerOptions { apiVersion.set(KotlinVersion.KOTLIN_1_9) languageVersion.set(KotlinVersion.KOTLIN_1_9) } } - val androidMain by getting { + getByName("androidMain") { dependencies { api(projects.sentry) api(projects.sentryAndroidNavigation) @@ -61,7 +60,7 @@ kotlin { implementation(libs.androidx.lifecycle.common.java8) } } - val androidUnitTest by getting { + getByName("androidUnitTest") { dependencies { implementation(libs.androidx.compose.ui.test.junit4) implementation(libs.androidx.navigation.compose) @@ -88,13 +87,15 @@ android { buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } - sourceSets["main"].apply { manifest.srcFile("src/androidMain/AndroidManifest.xml") } - buildTypes { getByName("debug") { consumerProguardFiles("proguard-rules.pro") } getByName("release") { consumerProguardFiles("proguard-rules.pro") } } + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + testOptions { animationsDisabled = true unitTests.apply { diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt index 3fec407987b..3c8fb48c35a 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt @@ -53,5 +53,13 @@ public object SentryModifier { override fun SemanticsPropertyReceiver.applySemantics() { this[SentryTag] = tag } + + // SemanticsModifierNode.isImportantForBounds() was added as an abstract method in + // compose-ui 1.11. Classes compiled against earlier versions lack this method in + // their bytecode, which causes AbstractMethodError when the accessibility tree is + // traversed on 1.11+ runtimes. + // Returning true to match the default behavior + // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt;l=69-83;drc=bd7809b4bc9205721c2f1bc681694dd348885849 + @Suppress("unused") fun isImportantForBounds(): Boolean = true } } diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt index 54deb774c53..47dda6eda9c 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt @@ -15,7 +15,7 @@ import io.sentry.compose.boundsInWindow import io.sentry.internal.gestures.GestureTargetLocator import io.sentry.internal.gestures.UiElement import io.sentry.util.AutoClosableReentrantLock -import java.util.LinkedList +import java.util.ArrayDeque import java.util.Queue @OptIn(InternalComposeUiApi::class) @@ -45,7 +45,7 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT val rootLayoutNode = root.root // Pair - val queue: Queue> = LinkedList() + val queue: Queue> = ArrayDeque() queue.add(Pair(rootLayoutNode, null)) // the final tag to return, only relevant for clicks @@ -92,7 +92,10 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT } } } - queue.addAll(node.zSortedChildren.asMutableList().map { Pair(it, tag) }) + val children = node.zSortedChildren.asMutableList() + for (index in children.indices) { + queue.add(Pair(children[index], tag)) + } } } diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts index a8256ca8a27..32db28fae8f 100644 --- a/sentry-graphql-22/build.gradle.kts +++ b/sentry-graphql-22/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -42,27 +42,6 @@ dependencies { testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql-core/build.gradle.kts b/sentry-graphql-core/build.gradle.kts index cb8c9f49493..34f71ab9cfb 100644 --- a/sentry-graphql-core/build.gradle.kts +++ b/sentry-graphql-core/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -41,27 +41,6 @@ dependencies { testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index 46bef6e4b9d..d92dc52c6d7 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -42,27 +42,6 @@ dependencies { testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jcache/build.gradle.kts b/sentry-jcache/build.gradle.kts index a9393a7d905..b388f35881f 100644 --- a/sentry-jcache/build.gradle.kts +++ b/sentry-jcache/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -37,27 +37,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jdbc/build.gradle.kts b/sentry-jdbc/build.gradle.kts index 0415fd8ccff..e2a7f573138 100644 --- a/sentry-jdbc/build.gradle.kts +++ b/sentry-jdbc/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -35,27 +35,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java b/sentry-jdbc/src/main/java/io/sentry/jdbc/DatabaseUtils.java index 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 13bee6418d6..2eec61eb171 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -34,25 +34,7 @@ dependencies { testImplementation(libs.slf4j.api) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } test { // used to test io.sentry.jul.SentryHandler systemProperty( diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts index ee3ba0d4a60..0d543bad270 100644 --- a/sentry-kafka/build.gradle.kts +++ b/sentry-kafka/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -34,27 +34,6 @@ dependencies { testImplementation(libs.kafka.clients) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index 55aca007130..101761b2a82 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -5,11 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -33,31 +32,6 @@ dependencies { testImplementation(libs.kotlinx.coroutines) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.kotlin) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") -} - -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } } tasks.withType().configureEach { diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index 2965e81ebd3..fefcdbfebaf 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -4,12 +4,11 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` alias(libs.plugins.kotlin.jvm) - jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -40,31 +39,6 @@ dependencies { testImplementation(libs.ktor.client.core) testImplementation(libs.ktor.client.java) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") -} - -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } } buildConfig { diff --git a/sentry-launchdarkly-android/build.gradle.kts b/sentry-launchdarkly-android/build.gradle.kts index bf59c256ed1..f201c57b97d 100644 --- a/sentry-launchdarkly-android/build.gradle.kts +++ b/sentry-launchdarkly-android/build.gradle.kts @@ -1,8 +1,6 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) } @@ -29,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 ee273fa5a9c..95aba9faaf5 100644 --- a/sentry-launchdarkly-server/build.gradle.kts +++ b/sentry-launchdarkly-server/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -38,27 +38,6 @@ dependencies { testImplementation(libs.launchdarkly.server) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 68ebd90b1e8..6e5250ece50 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -36,27 +36,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.log4j2") diff --git a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java index 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 385209e8c49..5fd6c975231 100644 --- a/sentry-logback/build.gradle.kts +++ b/sentry-logback/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -33,27 +33,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.logback") diff --git a/sentry-logback/src/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 f7178cf1dfe..47b8bfe5b15 100644 --- a/sentry-okhttp/build.gradle.kts +++ b/sentry-okhttp/build.gradle.kts @@ -4,12 +4,11 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` alias(libs.plugins.kotlin.jvm) - jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) - alias(libs.plugins.animalsniffer) + id("io.sentry.animalsniffer.android") } tasks.withType().configureEach { @@ -39,31 +38,6 @@ dependencies { testImplementation(libs.mockito.inline) testImplementation(libs.okhttp) testImplementation(libs.okhttp.mockwebserver) - - val gummyBearsModule = libs.gummy.bears.api21.get().module - signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") -} - -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } } buildConfig { diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index ea8fdb44159..7031be3b0b3 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -77,6 +77,7 @@ public open class SentryOkHttpInterceptor( } @Suppress("LongMethod") + @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { var request = chain.request() diff --git a/sentry-openfeature/build.gradle.kts b/sentry-openfeature/build.gradle.kts index 632d16b55cf..b079ead1fc5 100644 --- a/sentry-openfeature/build.gradle.kts +++ b/sentry-openfeature/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -38,27 +38,6 @@ dependencies { testImplementation(libs.openfeature) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-openfeign/build.gradle.kts b/sentry-openfeign/build.gradle.kts index 40119987f72..3baa85dee26 100644 --- a/sentry-openfeign/build.gradle.kts +++ b/sentry-openfeign/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -35,27 +35,6 @@ dependencies { testImplementation(libs.okhttp.mockwebserver) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt index 571a2339326..c25a81f9501 100644 --- a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt +++ b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt @@ -286,11 +286,10 @@ class SentryFeignClientTest { @Test fun `customizer modifies span`() { - val sut = - fixture.getSut { span, _, _ -> - span.description = "overwritten description" - span - } + val sut = fixture.getSut { span, _, _ -> + span.description = "overwritten description" + span + } sut.getOk() assertEquals(1, fixture.sentryTracer.children.size) val httpClientSpan = fixture.sentryTracer.children.first() @@ -299,13 +298,12 @@ class SentryFeignClientTest { @Test fun `customizer receives request and response`() { - val sut = - fixture.getSut { span, request, response -> - assertEquals(request.url(), request.url()) - assertEquals(request.httpMethod().name, request.httpMethod().name) - assertNotNull(response) { assertEquals(201, it.status()) } - span - } + val sut = fixture.getSut { span, request, response -> + assertEquals(request.url(), request.url()) + assertEquals(request.httpMethod().name, request.httpMethod().name) + assertNotNull(response) { assertEquals(201, it.status()) } + span + } sut.getOk() } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts index 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 b4a84300efd..087568d03ee 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts @@ -3,9 +3,9 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -41,27 +41,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts index c02ca0ca468..5a94dcd4422 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } dependencies { diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts index 43e87d53beb..72508d73737 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } dependencies { diff --git a/sentry-opentelemetry/sentry-opentelemetry-bom/README.md b/sentry-opentelemetry/sentry-opentelemetry-bom/README.md new file mode 100644 index 00000000000..c7522772d21 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-bom/README.md @@ -0,0 +1,84 @@ +# sentry-opentelemetry-bom + +This BOM aligns Sentry OpenTelemetry modules with the OpenTelemetry artifacts tested by Sentry. + +Use this BOM only when you want Sentry to manage OpenTelemetry dependency versions for Sentry's OpenTelemetry integrations. Do not import it for regular Sentry usage unless you also want this OpenTelemetry version alignment. + +The BOM intentionally manages stable and `-alpha` OpenTelemetry artifacts, including incubator artifacts used by the OpenTelemetry instrumentation stack. It makes Sentry's tested OpenTelemetry versions authoritative, so verify dependency resolution before importing it if your application already uses newer OpenTelemetry versions. + +This BOM is primarily for classpath-based OpenTelemetry integrations such as `sentry-opentelemetry-agentless`, `sentry-opentelemetry-agentless-spring`, `sentry-opentelemetry-otlp`, and `sentry-opentelemetry-otlp-spring`. It does not change the OpenTelemetry dependencies shaded into the `sentry-opentelemetry-agent` Java agent JAR. + +## Dependency management ordering + +Ordering matters when another BOM, such as Spring Boot's dependency management, also manages OpenTelemetry versions. + +### Gradle + +With Gradle's native dependency management, import the BOM as a platform and omit versions from Sentry OpenTelemetry and OpenTelemetry dependencies: + +```kotlin +dependencies { + implementation(platform("io.sentry:sentry-opentelemetry-bom:")) + + implementation("io.sentry:sentry-opentelemetry-agentless") + implementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure") +} +``` + +If another imported platform also manages OpenTelemetry versions, Gradle's normal version conflict resolution applies. Use `enforcedPlatform(...)` only when you need Sentry's tested OpenTelemetry versions to override other platforms. + +When using Gradle with the Spring dependency management plugin, the last imported BOM wins. Import this BOM after Spring Boot's dependency management so its OpenTelemetry versions take precedence: + +```kotlin +dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:") + mavenBom("io.sentry:sentry-opentelemetry-bom:") + } +} +``` + +If the Spring Boot Gradle plugin imports Spring Boot dependency management implicitly, add the Sentry BOM in your `dependencyManagement` block; explicit imports are applied after the implicit Spring Boot import. + +### Maven + +Maven uses different precedence rules: when multiple BOMs are imported in the same `` block, the first declaration wins. + +When using `spring-boot-starter-parent`, declare `sentry-opentelemetry-bom` in the child POM's `` block. Dependency management in the child POM takes precedence over the parent: + +```xml + + + + io.sentry + sentry-opentelemetry-bom + ${sentry.version} + pom + import + + + +``` + +When importing `spring-boot-dependencies` manually in the same POM, import `sentry-opentelemetry-bom` first so Sentry's OpenTelemetry versions win: + +```xml + + + + io.sentry + sentry-opentelemetry-bom + ${sentry.version} + pom + import + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + +``` diff --git a/sentry-opentelemetry/sentry-opentelemetry-bom/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bom/build.gradle.kts new file mode 100644 index 00000000000..d945002b716 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-bom/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + `java-platform` + `maven-publish` +} + +javaPlatform.allowDependencies() + +dependencies { + api(platform(libs.otel.bom)) + api(platform(libs.otel.alpha.bom)) + api(platform(libs.otel.instrumentation.bom)) + api(platform(libs.otel.instrumentation.alpha.bom)) + + constraints { + api(projects.sentryOpentelemetry.sentryOpentelemetryAgent) + api(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) + api(projects.sentryOpentelemetry.sentryOpentelemetryAgentless) + api(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) + api(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) + api(projects.sentryOpentelemetry.sentryOpentelemetryCore) + api(projects.sentryOpentelemetry.sentryOpentelemetryOtlp) + api(projects.sentryOpentelemetry.sentryOpentelemetryOtlpSpring) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api index 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 64db4096bb9..3585aa40d4a 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -3,9 +3,9 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -28,6 +28,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) @@ -36,27 +37,6 @@ dependencies { testImplementation(libs.otel.semconv.incubating) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java index 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 2ab3d4988d5..a252628c1a2 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -3,9 +3,9 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -38,6 +38,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) @@ -46,27 +47,6 @@ dependencies { testImplementation(libs.otel.semconv.incubating) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/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 f039b3c95ef..ec240c681ae 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -3,9 +3,9 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` + id("io.sentry.animalsniffer") id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -21,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) @@ -42,27 +42,6 @@ dependencies { // testImplementation(libs.otel.semconv.incubating) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-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 f81254f110f..f4f0d9d07d2 100644 --- a/sentry-quartz/build.gradle.kts +++ b/sentry-quartz/build.gradle.kts @@ -5,10 +5,10 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.animalsniffer") } tasks.withType().configureEach { @@ -36,27 +36,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-reactor/build.gradle.kts b/sentry-reactor/build.gradle.kts index 9e8b6e74be9..615ce38ecc5 100644 --- a/sentry-reactor/build.gradle.kts +++ b/sentry-reactor/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -44,27 +43,6 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter") } -configure { test { java.srcDir("src/test/java") } } - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.reactor") @@ -84,8 +62,6 @@ tasks.withType().configureEach { } } -repositories { mavenCentral() } - tasks.jar { manifest { attributes( diff --git a/sentry-samples/sentry-samples-android/README.md b/sentry-samples/sentry-samples-android/README.md new file mode 100644 index 00000000000..99d0edcd1c3 --- /dev/null +++ b/sentry-samples/sentry-samples-android/README.md @@ -0,0 +1,72 @@ +# Sentry Sample Android App + +Sample application demonstrating how to use the Sentry Android SDK, including core functionality (error reporting, tracing, session replay, +profiling) and integrations (Compose, OkHttp, SQLite, etc.). + +## How to run it? + +Install the app on your device or emulator: + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug +``` + +or simply open the project in Android Studio and run the `sentry-samples-android` configuration. + +You can also apply the [Sentry Android Gradle Plugin](https://github.com/getsentry/sentry-android-gradle-plugin) (SAGP) when building (not applied by default): + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp +``` + +In Android Studio, add `useSagp=` (empty value) to `gradle.properties`, or pass `-PuseSagp` as a Gradle project property. + +## Build modes + +### With or without SAGP + +The sample app can be built with or without the SAGP. + +| Gradle Property | Required | Purpose | +|-----------------|----------|-------------------------------------------------------------------------------------------------| +| `useSagp` | No | When present, apply SAGP when building the sample app. Omit the property to build without SAGP. | + +You can configure SAGP properties via the lambda passed to `extensions.configure("sentry")` in the sample app's +`build.gradle.kts` file. + +### Testing an unpublished SAGP build + +`-PuseSagp` builds check `mavenLocal()` first when resolving SAGP. To test a local SAGP branch: + +1. In your `sentry-android-gradle-plugin` checkout, temporarily set a unique local version in `plugin-build/gradle.properties` (e.g. + `6.10.0-LOCAL`) and publish to Maven Local: + +``` +./gradlew -p plugin-build publishToMavenLocal +``` + +Re-run `publishToMavenLocal` after each SAGP change. + +2. Temporarily bump the `sagp` pin in `gradle/libs.versions.toml` to match that version. + +Then build from sentry-java: + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp +``` + +## Viewing SDK output + +### Locally + +Debug builds enable SDK debug logging, so captured envelopes are printed to logcat (tag `Sentry`): + +``` +adb logcat -s Sentry +``` + +### On Sentry UI + +By default, SDK output produced by the sample app appears under the [sentry-sdk test project](https://sentry-sdks.sentry.io/issues/?project=5428559). +To redirect them to your own project, replace the test DSN (i.e., the `io.sentry.dsn` `meta-data` value in `src/main/AndroidManifest.xml` +with your own. diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index ed8cea25661..31009f6dbb9 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -1,5 +1,8 @@ import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.variant.BuildConfigField import com.android.build.api.variant.impl.VariantImpl +import io.sentry.android.gradle.extensions.InstrumentationFeature +import io.sentry.android.gradle.extensions.SentryPluginExtension import org.apache.tools.ant.taskdefs.condition.Os import org.gradle.internal.extensions.stdlib.capitalized @@ -7,6 +10,35 @@ plugins { id("com.android.application") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.sentry) apply false + alias(libs.plugins.sqldelight) +} + +if (providers.gradleProperty("useSagp").isPresent) { + apply(plugin = "io.sentry.android.gradle") +} + +plugins.withId("io.sentry.android.gradle") { + // Extension configs match non-SAGP builds. Update locally to test your feature. + extensions.configure("sentry") { + autoInstallation.enabled.set(false) + includeProguardMapping.set(false) + includeDependenciesReport.set(false) + telemetry.set(false) + tracingInstrumentation { + features.set( + setOf( + // FILE_IO is disabled for non-SAGP builds. + InstrumentationFeature.COMPOSE, + InstrumentationFeature.DATABASE, + InstrumentationFeature.OKHTTP, + ) + ) + logcat.enabled.set(false) + appStart.enabled.set(false) + } + } } android { @@ -15,7 +47,8 @@ android { defaultConfig { applicationId = "io.sentry.samples.android" - minSdk = libs.versions.minSdk.get().toInt() + // androidx.sqlite 2.6+ require minSdk 23; the Sentry SDK still supports 21. + minSdk = 23 targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 2 versionName = project.version.toString() @@ -78,7 +111,12 @@ android { buildTypes { getByName("debug") { + // Suffix the id so debug and release builds can be installed side by side. + applicationIdSuffix = ".debug" addManifestPlaceholders(mapOf("sentryDebug" to true, "sentryEnvironment" to "debug")) + // The SDK modules only publish a release variant, so fall back to it for the + // debug build of the sample. + matchingFallbacks += "release" } getByName("release") { isMinifyEnabled = true @@ -90,13 +128,26 @@ android { } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } - - androidComponents.beforeVariants { - it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + // Java 11 b/c androidx.room3 requires it. + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 } + androidComponents.onVariants { variant -> + variant.buildConfigFields?.put( + "USE_SAGP", + providers.provider { + BuildConfigField( + type = "boolean", + value = providers.gradleProperty("useSagp").isPresent.toString(), + comment = "Whether the Sentry Android Gradle Plugin was applied", + ) + }, + ) + val taskName = "toggle${variant.name.capitalized()}NativeLogging" val toggleNativeLoggingTask = project.tasks.register(taskName) { @@ -116,6 +167,17 @@ android { @Suppress("UnstableApiUsage") packagingOptions { jniLibs { useLegacyPackaging = true } } } +sqldelight { + databases { + create("SampleSQLDelightDatabase") { + packageName.set("io.sentry.samples.android.sqlite") + // Keep .sq files next to the hand-written Kotlin (src/main/java/.../sqlite) instead of the + // default src/main/sqldelight source root. + srcDirs("src/main/java") + } + } +} + dependencies { implementation( kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION) @@ -123,6 +185,7 @@ dependencies { implementation(projects.sentryAndroid) implementation(projects.sentryAndroidFragment) + implementation(projects.sentryAndroidSqlite) implementation(projects.sentryAndroidTimber) implementation(projects.sentryCompose) implementation(projects.sentryKotlinExtensions) @@ -148,17 +211,24 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.recyclerview) implementation(libs.androidx.browser) + implementation(libs.androidx.room3.runtime) + implementation(libs.bundles.androidx.room2) + implementation(libs.bundles.androidx.sqlite.drivers) + implementation(libs.camerax.camera2) + implementation(libs.camerax.core) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) implementation(libs.coil.compose) implementation(libs.kotlinx.coroutines.android) implementation(libs.lottie.compose) implementation(libs.retrofit) implementation(libs.retrofit.gson) implementation(libs.sentry.native.ndk) + implementation(libs.sqldelight.android.driver) implementation(libs.timber) - implementation(libs.camerax.core) - implementation(libs.camerax.camera2) - implementation(libs.camerax.lifecycle) - implementation(libs.camerax.view) + + ksp(libs.androidx.room.compiler) + ksp(libs.androidx.room3.compiler) debugImplementation(projects.sentryAndroidDistribution) debugImplementation(libs.leakcanary) diff --git a/sentry-samples/sentry-samples-android/proguard-rules.pro b/sentry-samples/sentry-samples-android/proguard-rules.pro index 1165340c893..5f4016f8f72 100644 --- a/sentry-samples/sentry-samples-android/proguard-rules.pro +++ b/sentry-samples/sentry-samples-android/proguard-rules.pro @@ -32,3 +32,15 @@ -dontwarn org.openjsse.javax.net.ssl.SSLParameters -dontwarn org.openjsse.javax.net.ssl.SSLSocket -dontwarn org.openjsse.net.ssl.OpenJSSE + +# Retrofit relies on generic signatures for its service methods. Under R8 full mode these are +# stripped for classes that aren't kept, which breaks call-adapter creation (SecondActivity's +# GithubAPI request). Keep the signature attributes and Retrofit's generic types. +-keepattributes Signature, InnerClasses, EnclosingMethod +-keep,allowobfuscation,allowshrinking interface retrofit2.Call +-keep,allowobfuscation,allowshrinking class retrofit2.Response +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface <1> +# Keep the response model so Gson can deserialize it. +-keep class io.sentry.samples.android.Repo { *; } diff --git a/sentry-samples/sentry-samples-android/src/debug/res/values/strings.xml b/sentry-samples/sentry-samples-android/src/debug/res/values/strings.xml new file mode 100644 index 00000000000..ef5dee529b6 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + Sentry Sample Debug + DEBUG + diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index e5b5ed2250b..ac53c538de5 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -31,12 +31,27 @@ android:theme="@style/AppTheme" tools:ignore="GoogleAppIndexingWarning, UnusedAttribute"> + + + + + + + + + + + @@ -70,7 +90,8 @@ + android:exported="false" + android:theme="@style/AppTheme.Main" /> + + + + + + + + android:value="false" /> @@ -229,12 +263,16 @@ android:name="io.sentry.performance-v2.enable" android:value="true" /> + + + 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 0a7d4dbb111..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) { @@ -17,7 +25,7 @@ JNIEXPORT void JNICALL Java_io_sentry_samples_android_NativeSample_message(JNIEn sentry_value_t event = sentry_value_new_message_event( /* level */ SENTRY_LEVEL_INFO, /* logger */ "custom", - /* message */ "It works!" + /* message */ "Native Capture button: native message" ); sentry_capture_event(event); } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt new file mode 100644 index 00000000000..3a38814c5d8 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt @@ -0,0 +1,50 @@ +package io.sentry.samples.android + +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit + +class DetachAttachTabsActivity : AppCompatActivity(R.layout.activity_detach_attach_tabs) { + + private val tags = arrayOf("tab_a", "tab_b") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + findViewById(R.id.btn_tab_a).setOnClickListener { showTab(0) } + findViewById(R.id.btn_tab_b).setOnClickListener { showTab(1) } + + if (savedInstanceState == null) { + val tabB = TabFragmentB() + supportFragmentManager.commit { + add(R.id.tab_container, TabFragmentA(), tags[0]) + add(R.id.tab_container, tabB, tags[1]) + detach(tabB) + } + } + } + + private fun showTab(index: Int) { + supportFragmentManager.commit { + for (i in tags.indices) { + val frag = supportFragmentManager.findFragmentByTag(tags[i]) ?: continue + if (i == index) attach(frag) else detach(frag) + } + } + } +} + +class TabFragmentA : Fragment(R.layout.fragment_tab) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById(R.id.tab_label).text = "Tab A" + } +} + +class TabFragmentB : Fragment(R.layout.fragment_tab) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById(R.id.tab_label).text = "Tab B" + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index 86f1aace82e..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 @@ -23,12 +23,17 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -72,6 +77,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -184,11 +190,21 @@ fun MainScreen() { Surface(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) { + // NavigationRail already draws its background edge-to-edge (behind the status bar) while + // insetting its own items, so we only need to inset the content area on the remaining sides. CategoryNavigationRail( selectedCategory = selectedCategory, onCategorySelected = { selectedCategory = it }, ) - Surface(modifier = Modifier.fillMaxSize()) { + Surface( + modifier = + Modifier.fillMaxSize() + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Top + WindowInsetsSides.Bottom + WindowInsetsSides.End + ) + ) + ) { when (selectedCategory) { Category.ERRORS -> ErrorsScreen() Category.TRACING -> TracingScreen() @@ -231,6 +247,15 @@ fun CategoryNavigationRail( .padding(12.dp) .rotate(rotation.value), ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.build_type), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = + if (BuildConfig.DEBUG) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, + ) Spacer(Modifier.height(16.dp)) Category.entries.forEach { category -> NavigationRailItem( @@ -271,7 +296,12 @@ fun ErrorsScreen() { ) { item { SentryTraced("crash_from_java") { - OutlinedButton(onClick = { throw RuntimeException("Uncaught Exception from Java.") }) { + OutlinedButton( + onClick = { + tagSampleAction("crash_from_java") + throw RuntimeException("Crash from Java button: uncaught RuntimeException") + } + ) { Text("Crash from Java", maxLines = 2, overflow = TextOverflow.Ellipsis) } } @@ -279,7 +309,12 @@ fun ErrorsScreen() { item { SentryTraced("capture_exception") { OutlinedButton( - onClick = { Sentry.captureException(Exception(Exception(Exception("Some exception.")))) }, + onClick = { + tagSampleAction("capture_exception") + Sentry.captureException( + Exception(Exception(Exception("Capture Exception button: nested exception"))) + ) + }, modifier = Modifier, ) { Text("Capture Exception", maxLines = 2, overflow = TextOverflow.Ellipsis) @@ -290,11 +325,12 @@ fun ErrorsScreen() { SentryTraced("breadcrumb") { OutlinedButton( onClick = { - Sentry.addBreadcrumb("Breadcrumb") + tagSampleAction("breadcrumb") + Sentry.addBreadcrumb("Breadcrumb button clicked") Sentry.setExtra("extra", "extra") Sentry.setFingerprint(listOf("fingerprint")) Sentry.setTransaction("transaction") - Sentry.captureException(Exception("Some exception with scope.")) + Sentry.captureException(Exception("Breadcrumb button: exception with scope data")) }, modifier = Modifier, ) { @@ -304,21 +340,39 @@ fun ErrorsScreen() { } item { SentryTraced("stack_overflow") { - OutlinedButton(onClick = { stackOverflow() }, modifier = Modifier) { + OutlinedButton( + onClick = { + tagSampleAction("stack_overflow") + stackOverflow() + }, + modifier = Modifier, + ) { Text("Stack Overflow", maxLines = 2, overflow = TextOverflow.Ellipsis) } } } item { SentryTraced("native_crash") { - OutlinedButton(onClick = { NativeSample.crash() }, modifier = Modifier) { + OutlinedButton( + onClick = { + tagSampleAction("native_crash") + NativeSample.crash() + }, + modifier = Modifier, + ) { Text("Native Crash", maxLines = 2, overflow = TextOverflow.Ellipsis) } } } item { SentryTraced("native_capture") { - OutlinedButton(onClick = { NativeSample.message() }, modifier = Modifier) { + OutlinedButton( + onClick = { + tagSampleAction("native_capture") + NativeSample.message() + }, + modifier = Modifier, + ) { Text("Native Capture", maxLines = 2, overflow = TextOverflow.Ellipsis) } } @@ -327,6 +381,7 @@ fun ErrorsScreen() { SentryTraced("anr") { OutlinedButton( onClick = { + tagSampleAction("anr") Thread { synchronized(mutex) { while (true) { @@ -341,7 +396,14 @@ fun ErrorsScreen() { .start() Handler(Looper.getMainLooper()) - .postDelayed({ synchronized(mutex) { throw IllegalStateException() } }, 1000) + .postDelayed( + { + synchronized(mutex) { + throw IllegalStateException("ANR button: main thread blocked") + } + }, + 1000, + ) }, modifier = Modifier, ) { @@ -353,10 +415,18 @@ fun ErrorsScreen() { SentryTraced("native_anr") { OutlinedButton( onClick = { + tagSampleAction("native_anr") Thread { NativeSample.freezeMysteriously(mutex) }.start() Handler(Looper.getMainLooper()) - .postDelayed({ synchronized(mutex) { throw IllegalStateException() } }, 1000) + .postDelayed( + { + synchronized(mutex) { + throw IllegalStateException("ANR (native) button: main thread blocked") + } + }, + 1000, + ) }, modifier = Modifier, ) { @@ -368,6 +438,7 @@ fun ErrorsScreen() { SentryTraced("out_of_memory") { OutlinedButton( onClick = { + tagSampleAction("out_of_memory") val latch = CountDownLatch(1) for (i in 0 until 20) { Thread { @@ -393,7 +464,13 @@ fun ErrorsScreen() { } item { SentryTraced("send_message") { - OutlinedButton(onClick = { Sentry.captureMessage("Some message.") }, modifier = Modifier) { + OutlinedButton( + onClick = { + tagSampleAction("send_message") + Sentry.captureMessage("Send Message button: test message") + }, + modifier = Modifier, + ) { Text("Send Message", maxLines = 2, overflow = TextOverflow.Ellipsis) } } @@ -402,11 +479,12 @@ fun ErrorsScreen() { SentryTraced("test_timber") { OutlinedButton( onClick = { + tagSampleAction("test_timber") crashCount.intValue++ - Timber.i("Some info here") + Timber.i("Test Timber button: info log") Timber.e( - RuntimeException("Uncaught Exception from Java."), - "Something wrong happened ${crashCount.intValue} times", + RuntimeException("Test Timber button: error RuntimeException"), + "Test Timber button: error logged ${crashCount.intValue} times", ) }, modifier = Modifier, @@ -473,6 +551,20 @@ fun TracingScreen() { } } } + item { + SentryTraced("open_sqlite") { + OutlinedButton( + onClick = { + activity.startActivity( + Intent(activity, io.sentry.samples.android.sqlite.SQLiteActivity::class.java) + ) + }, + modifier = Modifier, + ) { + Text("Open SQLite Activity", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } } } @@ -713,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") } } } @@ -780,6 +874,18 @@ fun IntegrationsScreen() { } } } + item { + SentryTraced("open_detach_attach_tabs") { + OutlinedButton( + onClick = { + activity.startActivity(Intent(activity, DetachAttachTabsActivity::class.java)) + }, + modifier = Modifier, + ) { + Text("Open Detach/Attach Tabs", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } item { SentryTraced("open_permissions_activity") { OutlinedButton( @@ -909,3 +1015,8 @@ fun Context.getActivity(): ComponentActivity { fun stackOverflow() { stackOverflow() } + +private fun tagSampleAction(action: String) { + // Tag every event with the button that triggered it so it can be filtered in Sentry. + Sentry.setTag("sample_action", action) +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java index 572c4cdba72..d9d142cfc65 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java @@ -2,17 +2,28 @@ import android.app.Application; import android.os.StrictMode; +import io.sentry.ISpan; import io.sentry.Sentry; +import io.sentry.samples.android.sqlite.SampleDatabases; /** Apps. main Application. */ public class MyApplication extends Application { @Override public void onCreate() { + // Make Session Replay fail fast instead of silently degrading masking when an exception is + // swallowed (e.g. unsupported/obfuscated Compose internals). This way regressions surface as + // crashes in our release/obfuscated builds that run on real devices in CI. Only meant for our + // own sample/UI-test apps, customers should never set this. + System.setProperty("io.sentry.replay.compose.fail-fast", "true"); Sentry.startProfiler(); strictMode(); super.onCreate(); + extendAppStartExample(); + + SampleDatabases.INSTANCE.warmUp(this); + // Example how to initialize the SDK manually which allows access to SentryOptions callbacks. // Make sure you disable the auto init via manifest meta-data: io.sentry.auto-init=false // SentryAndroid.init( @@ -28,6 +39,35 @@ public void onCreate() { // }); } + // Example of extending the app start: launch-time work done here (after the SDK auto-inits) is + // included in the app start measurement. Requires standalone app start tracing + // (io.sentry.standalone-app-start-tracing.enable in the manifest). The artificial delays stand in + // for real launch work, e.g. loading remote config or feature flags before the first screen. + private void extendAppStartExample() { + Sentry.extendAppStart(); + + final ISpan extendedSpan = Sentry.getExtendedAppStartSpan(); + if (extendedSpan != null) { + final ISpan configSpan = extendedSpan.startChild("remote_config", "Load remote config"); + artificialDelay(200); + configSpan.finish(); + + final ISpan flagsSpan = extendedSpan.startChild("feature_flags", "Fetch feature flags"); + artificialDelay(100); + flagsSpan.finish(); + } + + Sentry.finishExtendedAppStart(); + } + + private static void artificialDelay(final long millis) { + try { + Thread.sleep(millis); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + private void strictMode() { // https://developer.android.com/reference/android/os/StrictMode // StrictMode is a developer tool which detects things you might be doing by accident and diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt index 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/java/io/sentry/samples/android/TestBroadcastReceiver.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java new file mode 100644 index 00000000000..10b4fd4d94e --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java @@ -0,0 +1,26 @@ +package io.sentry.samples.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * A manifest-declared broadcast receiver for testing standalone app starts. + * + *

Test with: + * + *

{@code
+ * adb shell am force-stop io.sentry.samples.android && \
+ * adb shell am broadcast -a io.sentry.samples.android.TEST_BROADCAST \
+ *   -n io.sentry.samples.android/.TestBroadcastReceiver
+ * }
+ */ +public class TestBroadcastReceiver extends BroadcastReceiver { + private static final String TAG = "SentryAppStart"; + + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "TestBroadcastReceiver.onReceive() called - no activity will launch"); + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt new file mode 100644 index 00000000000..fd80a5aae1e --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt @@ -0,0 +1,111 @@ +package io.sentry.samples.android.sqlite + +/** + * Display text for each "SQL run" summary shown in the [SQLiteActivity] screen UI. Documentation + * only / never executed. The real statements live in [SqlStatements]. + */ +internal data class DisplayInfo(val sql: String, val sqlHeavy: String = sql) + +internal val DRIVER_DIRECT = + DisplayInfo( + sql = + """ + CREATE TABLE IF NOT EXISTS song(…) + INSERT INTO song(title, artist) VALUES (?, ?) + SELECT count(*) FROM song + """ + .trimIndent(), + sqlHeavy = + """ + CREATE TABLE IF NOT EXISTS song(…) + INSERT INTO song(title, artist) VALUES (?, ?) + INSERT INTO song(title, artist) VALUES (?, ?), (?, ?), … (?, ?) + SELECT id, title, artist FROM song + SELECT count(*) FROM song + -- then, per row: appWork() = 500x SHA-256, in the app (not in any span) + """ + .trimIndent(), + ) + +internal val DRIVER_ROOM2 = + DisplayInfo( + sql = + """ + INSERT OR ABORT INTO `song` (…) VALUES (nullif(?, 0), ?, ?) + SELECT count(*) FROM song + """ + .trimIndent(), + sqlHeavy = + """ + INSERT OR ABORT INTO `song` (…) VALUES (nullif(?, 0), ?, ?) + SELECT * FROM song + SELECT count(*) FROM song + -- then, per row: appWork() = 500x SHA-256, outside the step()-timed spans + """ + .trimIndent(), + ) + +// Room 3 issues the same statements as Room 2 (see SqlStatements.driverWithRoom3). +internal val DRIVER_ROOM3 = DRIVER_ROOM2 + +internal val OPENHELPER_DIRECT = + DisplayInfo( + sql = + """ + CREATE TABLE IF NOT EXISTS song(…) + INSERT INTO song(title, artist) VALUES (?, ?) + SELECT count(*) FROM song + """ + .trimIndent(), + sqlHeavy = + """ + CREATE TABLE IF NOT EXISTS song(…) + INSERT INTO song(title, artist) VALUES (?, ?) + INSERT INTO song(title, artist) VALUES (?, ?), (?, ?), … (?, ?) + SELECT id, title, artist FROM song + SELECT count(*) FROM song + -- then, per row: appWork() = 500x SHA-256, in the app + """ + .trimIndent(), + ) + +internal val OPENHELPER_ROOM = + DisplayInfo( + sql = + """ + INSERT OR ABORT INTO `song` (…) VALUES (nullif(?, 0), ?, ?) + SELECT count(*) FROM song + """ + .trimIndent(), + sqlHeavy = + """ + INSERT OR ABORT INTO `song` (…) VALUES (nullif(?, 0), ?, ?) + SELECT * FROM song + SELECT count(*) FROM song + -- then, per row: appWork() = 500x SHA-256, in the app + """ + .trimIndent(), + ) + +// Bridge demos run the same SQL as the driver paths; spans come from the open-helper layer. +internal val BRIDGE_DIRECT = DRIVER_DIRECT + +internal val BRIDGE_ROOM2 = DRIVER_ROOM2 + +internal val OPENHELPER_SQLDELIGHT = + DisplayInfo( + sql = + """ + INSERT INTO song(title, artist) VALUES (?, ?) + SELECT count(*) FROM song + """ + .trimIndent(), + sqlHeavy = + """ + INSERT INTO song(title, artist) VALUES (?, ?) + SELECT * FROM song + SELECT count(*) FROM song + -- then, per row: appWork() = 500x SHA-256, in the app + """ + .trimIndent(), + ) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room2Dao.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room2Dao.kt new file mode 100644 index 00000000000..31814c750bc --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room2Dao.kt @@ -0,0 +1,42 @@ +package io.sentry.samples.android.sqlite + +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.RoomDatabase + +@Entity(tableName = "song") +data class SongEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val title: String, + val artist: String, +) + +@Dao +interface SongDao { + + @Insert suspend fun insert(song: SongEntity) + + /** Batch insert: Room runs all rows in a single transaction, reusing one compiled statement. */ + @Insert suspend fun insertAll(songs: List) + + @Query("SELECT * FROM song") suspend fun getAll(): List + + @Query("SELECT count(*) FROM song") suspend fun count(): Int + + /** + * No-op write (matches no rows) used at warm-up to open Room's writer connection up front. A read + * like [count] only opens a reader, so without this the first INSERT would (noisily) open and + * bootstrap the writer connection inside a demo transaction. + */ + @Query("DELETE FROM song WHERE id < 0") suspend fun primeWriter() +} + +@Database(entities = [SongEntity::class], version = 1, exportSchema = false) +abstract class SampleRoom2Database : RoomDatabase() { + + abstract fun songDao(): SongDao +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt new file mode 100644 index 00000000000..145e12d3897 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt @@ -0,0 +1,42 @@ +package io.sentry.samples.android.sqlite + +import androidx.room3.Dao +import androidx.room3.Database +import androidx.room3.Entity +import androidx.room3.Insert +import androidx.room3.PrimaryKey +import androidx.room3.Query +import androidx.room3.RoomDatabase + +@Entity(tableName = "song") +data class SongEntity3( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val title: String, + val artist: String, +) + +@Dao +interface SongDao3 { + + @Insert suspend fun insert(song: SongEntity3) + + /** Batch insert: Room runs all rows in a single transaction, reusing one compiled statement. */ + @Insert suspend fun insertAll(songs: List) + + @Query("SELECT * FROM song") suspend fun getAll(): List + + @Query("SELECT count(*) FROM song") suspend fun count(): Int + + /** + * No-op write (matches no rows) used at warm-up to open Room's writer connection up front. A read + * like [count] only opens a reader, so without this the first INSERT would (noisily) open and + * bootstrap the writer connection inside a demo transaction. + */ + @Query("DELETE FROM song WHERE id < 0") suspend fun primeWriter() +} + +@Database(entities = [SongEntity3::class], version = 1, exportSchema = false) +abstract class SampleRoom3Database : RoomDatabase() { + + abstract fun songDao(): SongDao3 +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt new file mode 100644 index 00000000000..54334b6e407 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -0,0 +1,777 @@ +package io.sentry.samples.android.sqlite + +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.keyframes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.lifecycleScope +import io.sentry.Sentry +import io.sentry.SpanId +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.SentryId +import io.sentry.samples.android.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val SentryPink = Color(0xFFC85B9C) +private val SentryPurple = Color(0xFF7B52FB) +private val SentryOrange = Color(0xFFE8743F) +private val SentryRed = Color(0xFFF55459) + +/** Intro text, surfaced via the "?" tooltip next to the "Run it" header. */ +private const val INSTRUCTIONS = + "Tap a button to execute a SQL statement in its own transaction; long press to run it in a ui.load transaction." + +/** Start state of the "SQL run" box. */ +private const val SQL_DETAIL_HINT = "Tap a button above to see the SQL it runs…" + +private val TOGGLE_SECTION_GAP = 24.dp + +private val CONTROL_SECTION_GAP = TOGGLE_SECTION_GAP * 2 + +private val SECTION_HEADER_HEIGHT = 28.dp + +private const val SAGP_DIRECT_DRIVER_MESSAGE = + "SAGP doesn't auto-instrument SQLiteDriver for direct use" + +/** Which sentry-android-sqlite integration the demo currently targets. */ +private enum class IntegrationMode( + val color: Color, + val segmentLabel: String, + val apiName: String, +) { + + DRIVER(SentryPurple, "SQLiteDriver", "SQLiteDriver"), + OPEN_HELPER(SentryPink, "OpenHelper", "SupportSQLiteOpenHelper"), + // Not directly-supported, but lets us verify behavior when both the DRIVER and OPEN_HELPER + // integrations are used together via the SupportSQLiteDriver bridge. + BRIDGE(SentryOrange, "Bridge", "SupportSQLiteDriver bridge"); + + fun subtitle(): String = + when (this) { + DRIVER -> + if (BuildConfig.USE_SAGP) { + "BundledSQLiteDriver (SAGP auto-wrap)" + } else { + "SentrySQLiteDriver.create(BundledSQLiteDriver)" + } + + OPEN_HELPER -> + if (BuildConfig.USE_SAGP) { + "FrameworkSQLiteOpenHelperFactory (SAGP auto-wrap)" + } else { + "SentrySupportSQLiteOpenHelper.create(...)" + } + + BRIDGE -> + if (BuildConfig.USE_SAGP) { + "SupportSQLiteDriver(open helper) (SAGP auto-wrap)" + } else { + "SentrySQLiteDriver.create(SupportSQLiteDriver(Sentry helper))" + } + } +} + +/** + * How one demo button behaves for a given integration: which [SqlStatements] work it runs ([demo]), + * the name/op of the manual transaction a tap wraps it in, and the SQL summary shown in the detail + * panel ([displayInfo]). + */ +private class DemoVariant( + val demo: SqlDemo, + val transactionName: String, + val op: String, + val displayInfo: DisplayInfo, +) + +/** + * A single demo button in the list. [driver] / [openHelper] / [bridge] hold the variant for each + * integration; a null variant means the row doesn't apply and renders dimmed (e.g., Room 3 is + * driver-only; SQLDelight is open-helper-only; etc.). + */ +private class DemoRow( + val label: String, + val driver: DemoVariant?, + val openHelper: DemoVariant?, + val bridge: DemoVariant?, +) { + + fun variantFor(mode: IntegrationMode): DemoVariant? = + when (mode) { + IntegrationMode.DRIVER -> driver + IntegrationMode.OPEN_HELPER -> openHelper + IntegrationMode.BRIDGE -> bridge + } +} + +// The demo buttons, top to bottom, paired with each integration's variant. Pure data — the actual +// SQL lives in SqlStatements, dispatched by id. +private val DEMO_ROWS = + listOf( + DemoRow( + label = "Direct (no library)", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_DIRECT, + transactionName = "SentrySQLiteDriver — Direct", + op = "db.sql.driver-direct", + displayInfo = DRIVER_DIRECT, + ), + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_DIRECT, + transactionName = "SentrySupportSQLiteOpenHelper — Direct", + op = "db.sql.openhelper-direct", + displayInfo = OPENHELPER_DIRECT, + ), + bridge = + DemoVariant( + demo = SqlDemo.BRIDGE_DIRECT, + transactionName = "Bridge stack — Direct", + op = "db.sql.bridge-direct", + displayInfo = BRIDGE_DIRECT, + ), + ), + DemoRow( + label = "Room 2", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_ROOM2, + transactionName = "SentrySQLiteDriver — Room 2", + op = "db.sql.driver-room2", + displayInfo = DRIVER_ROOM2, + ), + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_ROOM, + transactionName = "SentrySupportSQLiteOpenHelper — Room", + op = "db.sql.openhelper-room", + displayInfo = OPENHELPER_ROOM, + ), + bridge = + DemoVariant( + demo = SqlDemo.BRIDGE_ROOM2, + transactionName = "Bridge stack — Room 2", + op = "db.sql.bridge-room2", + displayInfo = BRIDGE_ROOM2, + ), + ), + DemoRow( + label = "Room 3", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_ROOM3, + transactionName = "SentrySQLiteDriver — Room 3", + op = "db.sql.driver-room3", + displayInfo = DRIVER_ROOM3, + ), + openHelper = null, // Room 3 only runs on the SQLiteDriver path. + bridge = null, + ), + DemoRow( + label = "SQLDelight", + driver = null, // SQLDelight's AndroidSqliteDriver is built on SupportSQLiteOpenHelper. + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_SQLDELIGHT, + transactionName = "SentrySupportSQLiteOpenHelper — SQLDelight", + op = "db.sql.openhelper-sqldelight", + displayInfo = OPENHELPER_SQLDELIGHT, + ), + bridge = null, + ), + ) + +/** + * Activity that lets us exercise our two `sentry-android-sqlite` integrations + * ([SentrySQLiteDriver][io.sentry.sqlite.SentrySQLiteDriver] and + * [SentrySupportSQLiteOpenHelper][io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper]), both + * directly and via Room or SQLDelight. + * + * Example SQL statements are deliberately identical across integrations so we can identify + * similarities and differences in their transaction / span support. + */ +class SQLiteActivity : ComponentActivity() { + + private var latestResult by mutableStateOf("") + private var warmUpErrors by mutableStateOf("") + private var sqlDetail by mutableStateOf(SQL_DETAIL_HINT) + private var heavyWork by mutableStateOf(false) + + /** + * When enabled, every per-button transaction in one screen visit continues [screenTraceHeader], + * so they all share a trace ("session"-like). When disabled (the default), each tap is the root + * of its own trace, which renders as a standalone waterfall scaled to that one transaction — + * easier to read how time is allocated among its spans. + */ + private var shareScreenTrace by mutableStateOf(false) + + /** Which integration is currently being demoed. Switching it disables rows that don't apply. */ + private var integration by mutableStateOf(IntegrationMode.DRIVER) + + /** Incremented on each tap that runs SQL. Used to retrigger the detail box's outline shimmer. */ + private var runTick by mutableStateOf(0) + + /** True while a demo or reset is running SQL on a background thread. */ + private var dbOperationInFlight by mutableStateOf(false) + + /** True for the duration of a reset; disables the reset button immediately (no debounce). */ + private var resetInProgress by mutableStateOf(false) + + /** + * The shared trace used when [shareScreenTrace] is enabled: one trace per visit to this screen. + * onResume() generates a fresh one each time the screen is (re)entered. + */ + private var screenTraceHeader = newScreenTrace() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme { + Surface { + Column( + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val screenHeightDp = LocalConfiguration.current.screenHeightDp + // A small gap below the screen title that grows with screen height and collapses to 0 + // on short screens, so the title isn't crowded against "Configure it" on tall devices. + val titleGap = + (((((screenHeightDp / 4) - 48) / 3).coerceAtLeast(0).dp + TOGGLE_SECTION_GAP) / 2 - + SECTION_HEADER_HEIGHT) + .coerceAtLeast(0.dp) + + // Pulse the "Under the hood" outline in the integration color whenever a tap runs SQL. + val shimmer = remember { Animatable(0f) } + LaunchedEffect(runTick) { + if (runTick == 0) return@LaunchedEffect + shimmer.animateTo( + targetValue = 0f, + animationSpec = + keyframes { + durationMillis = 900 + 0f at 0 + 1f at 200 + 0.4f at 450 + 1f at 650 + 0f at 900 + }, + ) + } + + val detailOutline = + lerp(MaterialTheme.colorScheme.outline, integration.color, shimmer.value) + + Text(text = "SQLite Instrumentation", style = MaterialTheme.typography.headlineSmall) + SagpBuildPill() + + Spacer(Modifier.height(titleGap)) + + SectionHeader("Configure it") + + val controlSwitchColors = + SwitchDefaults.colors( + checkedTrackColor = Color.Black, + checkedBorderColor = Color.Black, + ) + IntegrationModeSelector( + selected = integration, + onSelected = { + integration = it + sqlDetail = SQL_DETAIL_HINT + latestResult = "" + }, + ) + ToggleRow( + label = if (heavyWork) "Heavy app-level work" else "No app-level work", + checked = heavyWork, + switchColors = controlSwitchColors, + ) { + heavyWork = it + } + ToggleRow( + label = + if (shareScreenTrace) "Single trace for all button clicks" + else "Separate trace per button click", + checked = shareScreenTrace, + switchColors = controlSwitchColors, + ) { + shareScreenTrace = it + } + + SectionHeader("Run it", topPadding = CONTROL_SECTION_GAP) { HelpTooltip() } + + // One consolidated list of demo buttons. Each row dispatches to the selected + // integration's variant; a row that doesn't apply explains why via a toast (see + // [DemoRowButton]). + DEMO_ROWS.forEach { row -> + val variant = row.variantFor(integration) + DemoRowButton( + label = row.label, + color = integration.color, + variant = variant, + sagpDisabledReason = sagpDisabledReason(integration, row), + disabledReason = "${row.label} doesn't support the ${integration.apiName} stack", + ) + } + + ResetButton( + dbOperationInFlight = dbOperationInFlight, + resetInProgress = resetInProgress, + ) + + // Same [CONTROL_SECTION_GAP] above as the other sections, separating the controls from + // the detail output. + SectionHeader("Under the hood", topPadding = CONTROL_SECTION_GAP) + LaunchedEffect(Unit) { + while (!SampleDatabases.isWarmUpComplete()) { + warmUpErrors = SampleDatabases.warmUpErrors + delay(250) + } + warmUpErrors = SampleDatabases.warmUpErrors + } + if (warmUpErrors.isNotEmpty()) { + Text( + text = warmUpErrors, + style = MaterialTheme.typography.bodyMedium, + color = SentryRed, + ) + } + // The latest run result (row counts, errors). Hidden until the first run. + if (latestResult.isNotEmpty()) { + Text( + text = latestResult, + style = MaterialTheme.typography.bodyMedium, + color = if (latestResult.looksLikeError()) SentryRed else Color.Unspecified, + ) + } + DetailField("SQL run", sqlDetail, borderColor = detailOutline) + } + } + } + } + } + + override fun onResume() { + super.onResume() + // Start a new trace each time the user (re)enters the screen, so each visit is its own session. + screenTraceHeader = newScreenTrace() + } + + /** Run the variant's SQL statement inside a manual, scope-bound transaction. */ + private fun onTap(variant: DemoVariant) { + if (dbOperationInFlight) return + + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql + runTick++ // shimmer the detail box outline in the integration color + + lifecycleScope.launch { + dbOperationInFlight = true + try { + val result = + withContext(Dispatchers.IO) { + runInTransaction(variant.transactionName, variant.op) { + SqlStatements.execute(applicationContext, variant.demo, heavyWork) + } + } + latestResult = result + } finally { + dbOperationInFlight = false + } + } + } + + /** + * Run the variant's SQL statement in [UiLoadActivity] with no manual transaction, so its auto + * `ui.load` transaction owns the spans. + */ + private fun onLongPress(variant: DemoVariant) { + if (dbOperationInFlight) return + + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql + latestResult = "Opened the auto-load screen — its ui.load transaction owns the db spans." + startActivity(UiLoadActivity.intent(this, variant.demo, heavyWork)) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun IntegrationModeSelector( + selected: IntegrationMode, + onSelected: (IntegrationMode) -> Unit, + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + IntegrationMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + shape = + SegmentedButtonDefaults.itemShape(index = index, count = IntegrationMode.entries.size), + onClick = { onSelected(mode) }, + selected = selected == mode, + icon = {}, + colors = + SegmentedButtonDefaults.colors( + activeContainerColor = mode.color, + activeContentColor = Color.White, + ), + label = { Text(mode.segmentLabel, style = MaterialTheme.typography.labelSmall) }, + ) + } + } + + Text( + text = selected.subtitle(), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + modifier = Modifier.padding(top = 6.dp), + ) + } + + @Composable + private fun SagpBuildPill() { + val useSagp = BuildConfig.USE_SAGP + + Surface( + shape = RoundedCornerShape(percent = 50), + color = if (useSagp) SentryPurple.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.2f), + modifier = Modifier.padding(top = 6.dp), + ) { + Text( + text = if (useSagp) "Built with SAGP" else "Built without SAGP", + style = MaterialTheme.typography.labelSmall, + color = if (useSagp) SentryPurple else Color.DarkGray, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } + } + + /** + * A compact, left-justified labeled switch. [labelColor] defaults to [Color.Unspecified] so the + * label inherits the default text color. + */ + @Composable + private fun ToggleRow( + label: String, + checked: Boolean, + modifier: Modifier = Modifier, + labelColor: Color = Color.Unspecified, + switchColors: SwitchColors = SwitchDefaults.colors(), + onCheckedChange: (Boolean) -> Unit, + ) { + // Constrain the row height: a Switch otherwise reserves ~48dp, leaving a large gap between the + // toggles. 32dp keeps them about one line of text apart. + Row(modifier = modifier.height(32.dp), verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = switchColors, + modifier = Modifier.scale(0.75f), + ) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = labelColor, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + + @Composable + private fun SectionHeader( + title: String, + topPadding: Dp = 8.dp, + trailing: (@Composable () -> Unit)? = null, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(top = topPadding)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + trailing?.invoke() + } + HorizontalDivider(thickness = 1.dp, modifier = Modifier.padding(top = 4.dp)) + } + } + + /** + * A circled "?" next to the "Run it" header. Tapping it briefly shows the [INSTRUCTIONS] in a + * tooltip that auto-dismisses after a few seconds. + */ + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun HelpTooltip() { + val tooltipState = rememberTooltipState(isPersistent = true) + val scope = rememberCoroutineScope() + LaunchedEffect(tooltipState.isVisible) { + if (tooltipState.isVisible) { + delay(4000) + tooltipState.dismiss() + } + } + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(INSTRUCTIONS) } }, + state = tooltipState, + ) { + Icon( + imageVector = Icons.Outlined.HelpOutline, + contentDescription = "What do the buttons do?", + tint = Color.Gray, + modifier = + Modifier.padding(start = 8.dp).size(20.dp).clickable { + scope.launch { tooltipState.show() } + }, + ) + } + } + + /** + * A filled button that runs [variant] on tap (manual transaction) or long-press (ui.load). It's a + * [Surface] rather than a [Button] because Material3's Button has no long-press hook; the + * [combinedClickable] modifier gives us both. + * + * A null [variant] means the row doesn't apply to the selected integration: the button renders + * dimmed and, when clicked, explains why via a toast ([disabledReason]) instead of running. + */ + @OptIn(ExperimentalFoundationApi::class) + @Composable + private fun DemoRowButton( + label: String, + color: Color, + variant: DemoVariant?, + sagpDisabledReason: String?, + disabledReason: String, + ) { + val context = LocalContext.current + val enabled = variant != null && sagpDisabledReason == null + val explain = { + Toast.makeText(context, sagpDisabledReason ?: disabledReason, Toast.LENGTH_SHORT).show() + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = ButtonDefaults.shape, + color = if (enabled) color else color.copy(alpha = 0.26f), + contentColor = Color.White, + ) { + Box( + modifier = + Modifier.combinedClickable( + onClick = { if (enabled) onTap(variant) else explain() }, + onLongClick = { if (enabled) onLongPress(variant) else explain() }, + ) + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, style = MaterialTheme.typography.labelLarge) + } + } + } + + @Composable + private fun ResetButton(dbOperationInFlight: Boolean, resetInProgress: Boolean) { + // Debounce demo-driven disablement so fast taps don't flicker the button; reset disables + // immediately via [resetInProgress]. [dbOperationInFlight] still guards [onClick] either way. + var enabled by remember { mutableStateOf(true) } + LaunchedEffect(dbOperationInFlight, resetInProgress) { + when { + resetInProgress -> enabled = false + dbOperationInFlight -> { + delay(RESET_DISABLE_DEBOUNCE_MS) + enabled = false + } + else -> enabled = true + } + } + + Button( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + enabled = enabled, + colors = ButtonDefaults.buttonColors(containerColor = Color.Gray, contentColor = Color.White), + onClick = { + if (dbOperationInFlight) return@Button + lifecycleScope.launch { + this@SQLiteActivity.resetInProgress = true + this@SQLiteActivity.dbOperationInFlight = true + try { + val message = withContext(Dispatchers.IO) { resetDatabases() } + latestResult = message + warmUpErrors = SampleDatabases.warmUpErrors + sqlDetail = "DROP: deletes every demo database file, resetting all row counts to 0." + } catch (t: Throwable) { + Log.e(TAG, "Reset failed", t) + latestResult = "Reset failed: ${t.message ?: t.javaClass.simpleName}" + } finally { + this@SQLiteActivity.dbOperationInFlight = false + this@SQLiteActivity.resetInProgress = false + } + } + }, + ) { + Text("Drop all tables (reset)") + } + } + + @Composable + private fun DetailField(label: String, value: String, borderColor: Color) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + textStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp), + // The border color is driven by the shimmer animation so the box pulses on each SQL run. + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = borderColor, + unfocusedBorderColor = borderColor, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + + /** + * Runs [block] inside a scope-bound transaction and returns the result. When [shareScreenTrace] + * is enabled, the transaction continues this screen's trace so all demos in one visit share a + * trace; otherwise it starts its own trace (1 transaction = 1 trace). + */ + private suspend fun runInTransaction( + transactionName: String, + op: String, + block: suspend () -> String, + ): String { + // Continuing the screen trace keeps the shared trace id but mints a fresh span id for this + // transaction; the standalone path (and the continueTrace fallback when tracing is disabled) + // gives the transaction its own trace. + val context = + if (shareScreenTrace) { + Sentry.continueTrace(screenTraceHeader, null)?.apply { + name = transactionName + operation = op + } ?: TransactionContext(transactionName, op) + } else { + TransactionContext(transactionName, op) + } + + val options = TransactionOptions().apply { isBindToScope = true } + val transaction = Sentry.startTransaction(context, options) + + return try { + val result = block() + transaction.status = SpanStatus.OK + result + } catch (t: Throwable) { + transaction.status = SpanStatus.INTERNAL_ERROR + Log.e(TAG, "$transactionName failed", t) + "$transactionName failed: ${t.message ?: t.javaClass.simpleName}" + } finally { + transaction.finish() + } + } + + private fun sagpDisabledReason(mode: IntegrationMode, row: DemoRow): String? { + if (!BuildConfig.USE_SAGP) return null + val demo = row.variantFor(mode)?.demo ?: return null + return when (demo) { + SqlDemo.DRIVER_DIRECT -> SAGP_DIRECT_DRIVER_MESSAGE + else -> null + } + } + + /** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */ + private suspend fun resetDatabases(): String { + val cleared = SampleDatabases.reset(applicationContext) + SampleDatabases.awaitWarmUp() + return buildString { + append("Dropped tables: cleared $cleared database file(s).") + if (SampleDatabases.warmUpErrors.isNotEmpty()) { + append("\n\n") + append(SampleDatabases.warmUpErrors) + } + } + } + + private companion object { + + private const val TAG = "SQLiteActivity" + + /** Demo SQL shorter than this won't visibly disable the reset button. */ + private const val RESET_DISABLE_DEBOUNCE_MS = 300L + + /** + * Builds a fresh sentry-trace header ("--") representing this screen + * visit's trace. The trailing "-1" marks it sampled so the whole session is kept. + */ + private fun newScreenTrace(): String = "${SentryId()}-${SpanId()}-1" + } +} + +private fun String.looksLikeError(): Boolean = contains("failed", ignoreCase = true) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt new file mode 100644 index 00000000000..f01a529499d --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -0,0 +1,368 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import android.util.Log +import androidx.room.Room +import androidx.room3.Room as Room3 +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.sqlite.driver.SupportSQLiteDriver +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper +import io.sentry.samples.android.BuildConfig +import io.sentry.samples.android.sqlite.SampleDatabases.driverDirectLock +import io.sentry.samples.android.sqlite.SampleDatabases.openHelperDirectLock +import io.sentry.samples.android.sqlite.SampleDatabases.reset +import io.sentry.samples.android.sqlite.SampleDatabases.warmUp +import io.sentry.sqlite.SentrySQLiteDriver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Process-lifetime holder for the demo databases used by [SQLiteActivity]. + * + * Real apps open a database once (commonly a DI singleton) and keep it open for the process, so a + * screen that touches the DB almost always finds it already "warm". We model that here: [warmUp] is + * called from `MyApplication` at launch, off the main thread, so the one-time open + Room + * connection-pool bootstrap happens with no active transaction — those `db.sql.query` spans have + * nothing to attach to and are dropped. Every screen afterward reuses the warm handle and records + * only its statements of interest. + * + * Handles are held for the whole process: Android has no reliable "app closed" callback, and the OS + * reclaims the connections on process death, so we never close them except via [reset] (the "Drop + * all tables" button), which closes, deletes the files, and re-warms. + * + * The two "direct" handles wrap a single raw connection that isn't safe for concurrent use, so + * callers serialize their whole unit of work via [driverDirectLock] / [openHelperDirectLock]. Room + * and SQLDelight manage their own connection pools and don't need one. + */ +object SampleDatabases { + + private const val TAG = "SampleDatabases" + + /** Non-empty when one or more warm-up steps failed; shown on [SQLiteActivity]. */ + @Volatile + var warmUpErrors: String = "" + private set + + @Volatile private var warmUpComplete = false + @Volatile private var warmUpGeneration = 0 + @Volatile private var warmUpJob: Job? = null + + fun isWarmUpComplete(): Boolean = warmUpComplete + + /** Blocks until the in-flight [warmUp] job (if any) finishes. */ + suspend fun awaitWarmUp() { + warmUpJob?.join() + } + + private val sqlAccess = Mutex() + + val driverDirectLock = Any() + val bridgeDirectLock = Any() + val openHelperDirectLock = Any() + + /** Serializes demo SQL and [reset] so handles are never closed mid-statement. */ + suspend fun withSqlAccess(block: suspend () -> T): T = sqlAccess.withLock { block() } + + @Volatile private var driverConnection: SQLiteConnection? = null + @Volatile private var bridgeConnection: SQLiteConnection? = null + @Volatile private var driverRoom2Db: SampleRoom2Database? = null + @Volatile private var bridgeRoom2Db: SampleRoom2Database? = null + @Volatile private var driverRoom3Db: SampleRoom3Database? = null + @Volatile private var directHelper: SupportSQLiteOpenHelper? = null + @Volatile private var bridgeDirectHelper: SupportSQLiteOpenHelper? = null + @Volatile private var openHelperRoomDb: SampleRoom2Database? = null + @Volatile private var sqlDelightDriver: AndroidSqliteDriver? = null + + fun driverConnection(context: Context): SQLiteConnection = + synchronized(driverDirectLock) { + driverConnection + ?: wrapDriver(BundledSQLiteDriver()).open(databaseFile(context, "driver_direct.db")).also { + it.execSQL(SqlStatements.CREATE_SONG) // one-time table setup, at open + driverConnection = it + } + } + + /** + * The Room 2.7+ duplicate-span scenario: a Sentry-wrapped open helper bridged to + * [SupportSQLiteDriver], then passed to [SentrySQLiteDriver.create] (which no-ops on the bridge). + */ + fun bridgeConnection(context: Context): SQLiteConnection = + synchronized(bridgeDirectLock) { + bridgeConnection + ?: run { + // SupportSQLiteDriver.open() requires fileName to match the helper's databaseName(); + // use the absolute path Room and the direct driver path both pass to open(). + val dbPath = databaseFile(context, "bridge_direct.db") + wrapDriver(SupportSQLiteDriver(buildBridgeDirectHelper(context, dbPath))) + .open(dbPath) + .also { + it.execSQL(SqlStatements.CREATE_SONG) + bridgeConnection = it + } + } + } + + fun bridgeRoom2Db(context: Context): SampleRoom2Database = + synchronized(this) { + bridgeRoom2Db + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "bridge_room2.db", + ) + .setDriver( + wrapDriver(SupportSQLiteDriver(buildBridgeRoom2Helper(context.applicationContext))) + ) + .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(true) + .build() + .also { bridgeRoom2Db = it } + } + + fun driverRoom2Db(context: Context): SampleRoom2Database = + synchronized(this) { + driverRoom2Db + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "driver_room2.db", + ) + .setDriver(wrapDriver(BundledSQLiteDriver())) + .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(true) + .build() + .also { driverRoom2Db = it } + } + + fun driverRoom3Db(context: Context): SampleRoom3Database = + synchronized(this) { + driverRoom3Db + ?: Room3.databaseBuilder(context.applicationContext, "driver_room3.db") + .setDriver(wrapDriver(BundledSQLiteDriver())) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + .also { driverRoom3Db = it } + } + + fun directHelper(context: Context): SupportSQLiteOpenHelper = + synchronized(openHelperDirectLock) { + directHelper ?: buildDirectHelper(context).also { directHelper = it } + } + + fun openHelperRoomDb(context: Context): SampleRoom2Database = + synchronized(this) { + openHelperRoomDb + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "openhelper_room.db", + ) + .openHelperFactory { configuration -> + wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) + } + .fallbackToDestructiveMigration(true) + .build() + .also { openHelperRoomDb = it } + } + + fun sqlDelightDriver(context: Context): AndroidSqliteDriver = + synchronized(this) { + sqlDelightDriver + ?: AndroidSqliteDriver( + schema = SampleSQLDelightDatabase.Schema, + context = context.applicationContext, + name = "openhelper_sqldelight.db", + factory = + SupportSQLiteOpenHelper.Factory { configuration -> + wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) + }, + ) + .also { sqlDelightDriver = it } + } + + private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper = + buildSentryHelper(context, "openhelper_direct.db").also { directHelper = it } + + private fun buildBridgeDirectHelper(context: Context, dbPath: String): SupportSQLiteOpenHelper = + buildSentryHelper(context, dbPath).also { bridgeDirectHelper = it } + + /** + * Open helper for the Bridge + Room 2 stack. Must not create tables in [onCreate] — Room owns the + * schema when [setDriver] is used. Room also passes [SupportSQLiteOpenHelper.databaseName] (the + * short name below), not an absolute path, to [SupportSQLiteDriver.open]. + * + * The callback version must be 1 (FrameworkSQLiteOpenHelper rejects < 1). That sets `PRAGMA + * user_version = 1` before Room opens, so Room would skip [onCreate] and validate the empty file + * as pre-packaged → "invalid schema". [onOpen] clears user_version back to 0 until + * [ROOM_MASTER_TABLE] exists. + */ + private fun buildBridgeRoom2Helper(context: Context): SupportSQLiteOpenHelper { + val configuration = + SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) + .name("bridge_room2.db") + .callback( + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = Unit + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = + Unit + + override fun onOpen(db: SupportSQLiteDatabase) { + if (!db.hasRoomMasterTable()) { + db.execSQL("PRAGMA user_version = 0") + } + } + } + ) + .build() + return wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) + } + + private fun buildSentryHelper(context: Context, dbName: String): SupportSQLiteOpenHelper { + val configuration = + SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) + .name(dbName) + .callback( + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL(SqlStatements.CREATE_SONG) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = + Unit + } + ) + .build() + return wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) + } + + private fun wrapDriver(driver: SQLiteDriver): SQLiteDriver = + if (BuildConfig.USE_SAGP) driver else SentrySQLiteDriver.create(driver) + + private fun wrapOpenHelper(delegate: SupportSQLiteOpenHelper): SupportSQLiteOpenHelper = + if (BuildConfig.USE_SAGP) delegate else SentrySupportSQLiteOpenHelper.create(delegate) + + /** Opens every database on a background thread, forcing the one-time open + bootstrap to run. */ + fun warmUp(context: Context) { + val appContext = context.applicationContext + val generation = ++warmUpGeneration + warmUpComplete = false + warmUpErrors = "" + Log.i(TAG, "Warm-up starting (USE_SAGP=${BuildConfig.USE_SAGP})") + // Fire-and-forget: the warm-up outlives no particular screen, so a bare scope is fine here. + warmUpJob = + CoroutineScope(Dispatchers.IO).launch { + val failures = mutableListOf() + runWarmUpStep("driver direct", failures) { driverConnection(appContext) } + runWarmUpStep("bridge direct", failures) { bridgeConnection(appContext) } + // primeWriter() + count() opens both Room pool connections (writer + reader), so the first + // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its + // transaction. + runWarmUpStep("driver Room 2", failures) { + driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("bridge Room 2", failures) { + bridgeRoom2Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("driver Room 3", failures) { + driverRoom3Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("open helper direct", failures) { directHelper(appContext).writableDatabase } + runWarmUpStep("open helper Room", failures) { + openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("SQLDelight", failures) { + SampleSQLDelightDatabase(sqlDelightDriver(appContext)) + .songQueries + .countSongs() + .executeAsOne() + } + if (generation == warmUpGeneration) { + warmUpErrors = failures.joinToString("\n") { "Warm-up failed: $it" } + warmUpComplete = true + } + } + } + + private inline fun runWarmUpStep(step: String, failures: MutableList, block: () -> Unit) { + try { + block() + } catch (t: Throwable) { + Log.e(TAG, "Warm-up failed: $step", t) + failures.add("$step: ${t.message ?: t.javaClass.simpleName}") + } + } + + /** + * Closes the open handles, deletes every demo database file, then re-warms. Returns the number of + * files cleared. Waits for any in-flight demo SQL (including [UiLoadActivity]) to finish first. + */ + suspend fun reset(context: Context): Int = withSqlAccess { + closeAll() + val appContext = context.applicationContext + val names = + listOf( + "driver_direct.db", + "bridge_direct.db", + "driver_room2.db", + "bridge_room2.db", + "driver_room3.db", + "openhelper_direct.db", + "openhelper_room.db", + "openhelper_sqldelight.db", + ) + val cleared = names.count { appContext.deleteDatabase(it) } + warmUp(appContext) + cleared + } + + private fun closeAll() { + synchronized(driverDirectLock) { + driverConnection?.close() + driverConnection = null + } + synchronized(bridgeDirectLock) { + bridgeConnection?.close() + bridgeConnection = null + bridgeDirectHelper?.close() + bridgeDirectHelper = null + } + synchronized(openHelperDirectLock) { + directHelper?.close() + directHelper = null + } + synchronized(this) { + driverRoom2Db?.close() + driverRoom2Db = null + bridgeRoom2Db?.close() + bridgeRoom2Db = null + driverRoom3Db?.close() + driverRoom3Db = null + openHelperRoomDb?.close() + openHelperRoomDb = null + sqlDelightDriver?.close() + sqlDelightDriver = null + } + } + + private fun databaseFile(context: Context, name: String): String = + context.applicationContext.getDatabasePath(name).also { it.parentFile?.mkdirs() }.absolutePath + + private fun SupportSQLiteDatabase.hasRoomMasterTable(): Boolean = + query("SELECT 1 FROM sqlite_master WHERE name = '$ROOM_MASTER_TABLE' LIMIT 1").use { + it.moveToFirst() + } +} + +private const val ROOM_MASTER_TABLE = "room_master_table" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq new file mode 100644 index 00000000000..345e55a3582 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq @@ -0,0 +1,17 @@ +CREATE TABLE song ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + artist TEXT NOT NULL +); + +insertSong: +INSERT INTO song(title, artist) +VALUES (?, ?); + +selectAll: +SELECT * +FROM song; + +countSongs: +SELECT count(*) +FROM song; diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt new file mode 100644 index 00000000000..9bd2d624694 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt @@ -0,0 +1,261 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Rows inserted (and then consumed + processed) per demo when "heavy application-level work" is + * enabled. + */ +private const val HEAVY_ROW_COUNT = 50 + +/** + * Identifies a single SQLite demo: one of the two integrations crossed with the way it's used + * (raw/direct, Room, or SQLDelight). Used to dispatch the same SQL from both trace styles. + */ +enum class SqlDemo { + DRIVER_DIRECT, + DRIVER_ROOM2, + DRIVER_ROOM3, + BRIDGE_DIRECT, + BRIDGE_ROOM2, + OPENHELPER_DIRECT, + OPENHELPER_ROOM, + OPENHELPER_SQLDELIGHT, +} + +/** + * Executable SQL and demo runners for the SQLite sample screens. The human-readable "SQL run" + * summaries shown in the UI live in the per-demo [DisplayInfo] constants; keep those in lockstep + * with the statements here. + * + * The actual SQL each demo runs is kept separate from how its trace is created so the two screens + * can share it: + * - [SQLiteActivity]: Wraps [execute] in a manual `Sentry.startTransaction(…)`. + * - [UiLoadActivity]: Calls the same [execute] with no manual transaction, so the screen's auto + * `ui.load` transaction owns the resulting `db.sql.query` spans. + * + * All demos read the shared, already-warm handles from [SampleDatabases] and return a short status + * line. [heavy] mirrors the screen's "heavy app-level work" toggle. When enabled, each demo also + * batch inserts [HEAVY_ROW_COUNT] rows and consumes them with per-row [appWork]. + */ +object SqlStatements { + + const val CREATE_SONG = + "CREATE TABLE IF NOT EXISTS song(id INTEGER PRIMARY KEY, title TEXT, artist TEXT)" + const val INSERT_SONG = "INSERT INTO song(title, artist) VALUES (?, ?)" + const val SELECT_SONGS = "SELECT id, title, artist FROM song" + const val COUNT_SONGS = "SELECT count(*) FROM song" + + /** + * A single multi-row INSERT for [rowCount] songs, bound with [batchSongArgs]. One statement <> + * one round-trip, which is the realistic way to add a known batch of rows, rather than a loop of + * [rowCount] single-row inserts. + */ + fun insertSongsBatch(rowCount: Int): String = + "INSERT INTO song(title, artist) VALUES " + List(rowCount) { "(?, ?)" }.joinToString(", ") + + /** Flattened title/artist bind args for [insertSongsBatch]: "song 0", "artist 0", "song 1", … */ + fun batchSongArgs(rowCount: Int): Array = + Array(rowCount * 2) { i -> if (i % 2 == 0) "song ${i / 2}" else "artist ${i / 2}" } + + suspend fun execute(context: Context, demo: SqlDemo, heavy: Boolean): String = + SampleDatabases.withSqlAccess { + when (demo) { + SqlDemo.DRIVER_DIRECT -> driverDirect(context, heavy) + SqlDemo.DRIVER_ROOM2 -> driverWithRoom2(context, heavy) + SqlDemo.DRIVER_ROOM3 -> driverWithRoom3(context, heavy) + SqlDemo.BRIDGE_DIRECT -> bridgeDirect(context, heavy) + SqlDemo.BRIDGE_ROOM2 -> bridgeWithRoom2(context, heavy) + SqlDemo.OPENHELPER_DIRECT -> openHelperDirect(context, heavy) + SqlDemo.OPENHELPER_ROOM -> openHelperWithRoom(context, heavy) + SqlDemo.OPENHELPER_SQLDELIGHT -> openHelperWithSqlDelight(context, heavy) + } + } + + // --- 1. SentrySQLiteDriver, used directly ------------------------------------------------- + + private fun driverDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.driverDirectLock) { + val connection = SampleDatabases.driverConnection(context) + insert(connection, "Mishima / Closing", "Philip Glass") + insert(connection, "School of Velocity, op 299 no 1, ", "Carl Czerny") + + if (heavy) { + // One multi-row INSERT for all HEAVY_ROWS rows, rather than a naive loop of single-row + // inserts. + connection.prepare(insertSongsBatch(HEAVY_ROW_COUNT)).use { statement -> + var param = 1 + repeat(HEAVY_ROW_COUNT) { row -> + statement.bindText(param++, "song $row") + statement.bindText(param++, "artist $row") + } + statement.step() + } + + connection.prepare(SELECT_SONGS).use { statement -> + while (statement.step()) { + // Consumption: pull each column across the JNI boundary into the ART heap. + val row = "${statement.getLong(0)}:${statement.getText(1)}:${statement.getText(2)}" + // Application work: e.g. per-row decryption. + appWork(row) + } + } + } + "Driver (Direct): ${count(connection)} rows." + } + + private fun insert(connection: SQLiteConnection, title: String, artist: String) { + connection.prepare(INSERT_SONG).use { statement -> + statement.bindText(1, title) + statement.bindText(2, artist) + statement.step() + } + } + + private fun count(connection: SQLiteConnection): Long = + connection.prepare(COUNT_SONGS).use { statement -> + if (statement.step()) statement.getLong(0) else 0 + } + + // --- 1b. SupportSQLiteDriver bridge (helper + driver both wrapped; SDK skips driver wrap) -- + + private fun bridgeDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.bridgeDirectLock) { + val connection = SampleDatabases.bridgeConnection(context) + insert(connection, "Mishima / Closing", "Philip Glass") + insert(connection, "School of Velocity, op 299 no 1, ", "Carl Czerny") + + if (heavy) { + connection.prepare(insertSongsBatch(HEAVY_ROW_COUNT)).use { statement -> + var param = 1 + repeat(HEAVY_ROW_COUNT) { row -> + statement.bindText(param++, "song $row") + statement.bindText(param++, "artist $row") + } + statement.step() + } + + connection.prepare(SELECT_SONGS).use { statement -> + while (statement.step()) { + val row = "${statement.getLong(0)}:${statement.getText(1)}:${statement.getText(2)}" + appWork(row) + } + } + } + "Bridge (Direct): ${count(connection)} rows." + } + + private suspend fun bridgeWithRoom2(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.bridgeRoom2Db(context).songDao(), "Bridge (Room 2)", heavy) + + // --- 2. SentrySQLiteDriver, used through Room 2.7+ ---------------------------------------- + + private suspend fun driverWithRoom2(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.driverRoom2Db(context).songDao(), "Driver (Room 2)", heavy) + + /** + * Shared Room 2 demo so the driver and open-helper paths run *identical* SQL. The only difference + * is how each integration instruments it: the driver spans every read, while the open helper's + * Room reads go via `moveToNext()` and emit no span, so only the INSERTs are spanned. + */ + private suspend fun roomDemo(dao: SongDao, label: String, heavy: Boolean): String { + dao.insert(SongEntity(title = "Spiders (Kidsmoke)", artist = "Wilco")) + if (heavy) { + // Batch insert: one insertAll() runs all rows in a single transaction, vs. a per-row loop. + dao.insertAll(List(HEAVY_ROW_COUNT) { SongEntity(title = "song $it", artist = "artist $it") }) + dao.getAll().forEach { appWork("${it.id}:${it.title}:${it.artist}") } + } + return "$label: ${dao.count()} rows." + } + + // --- 2b. SentrySQLiteDriver, used through Room 3.0+ (androidx.room3) ----------------------- + + private suspend fun driverWithRoom3(context: Context, heavy: Boolean): String { + val dao = SampleDatabases.driverRoom3Db(context).songDao() + dao.insert(SongEntity3(title = "What's Up", artist = "4 Non Blondes")) + if (heavy) { + // Batch insert: one insertAll() runs all rows in a single transaction, vs. a naive per-row + // loop. + dao.insertAll( + List(HEAVY_ROW_COUNT) { SongEntity3(title = "song $it", artist = "artist $it") } + ) + dao.getAll().forEach { appWork("${it.id}:${it.title}:${it.artist}") } + } + return "Driver (Room 3): ${dao.count()} rows." + } + + // --- 3. SentrySupportSQLiteOpenHelper, used directly -------------------------------------- + + private fun openHelperDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.openHelperDirectLock) { + // Runs the *same* SQL as driverDirect(), so the only difference you see in the Sentry UI is + // how each integration instruments identical statements. + val db = SampleDatabases.directHelper(context).writableDatabase + db.execSQL(INSERT_SONG, arrayOf("Mishima / Closing", "Philip Glass")) + db.execSQL(INSERT_SONG, arrayOf("School of Velocity, op 299 no 1, ", "Carl Czerny")) + if (heavy) { + // One multi-row INSERT for all HEAVY_ROWS rows, rather than a naive loop of single-row + // inserts. + db.execSQL(insertSongsBatch(HEAVY_ROW_COUNT), batchSongArgs(HEAVY_ROW_COUNT)) + db.query(SELECT_SONGS).use { cursor -> + while (cursor.moveToNext()) { + // Consumption: read each column out of the cursor window. + val row = "${cursor.getLong(0)}:${cursor.getString(1)}:${cursor.getString(2)}" + // Application work: e.g. per-row decryption. + appWork(row) + } + } + } + "OpenHelper (Direct): ${querySongCount(db)} rows." + } + + /** + * Runs the shared `SELECT count(*)` through the open helper and returns the value, read the + * normal way: moveToFirst() + getInt(). These are delegated straight to the underlying cursor + * (the open helper only instruments getCount()/onMove()/fillWindow()), so this read produces no + * `db.sql.query` span — the same as a real app reading a scalar count. + */ + private fun querySongCount(db: SupportSQLiteDatabase): Int = + db.query(COUNT_SONGS).use { cursor -> + cursor.moveToFirst() + cursor.getInt(0) + } + + // --- 4. SentrySupportSQLiteOpenHelper, used through Room ---------------------------------- + + // Runs the same [roomDemo] SQL as the driver path; only the instrumentation differs. + private suspend fun openHelperWithRoom(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.openHelperRoomDb(context).songDao(), "OpenHelper (Room)", heavy) + + // --- 5. SentrySupportSQLiteOpenHelper, used through SQLDelight ---------------------------- + + private fun openHelperWithSqlDelight(context: Context, heavy: Boolean): String { + val database = SampleSQLDelightDatabase(SampleDatabases.sqlDelightDriver(context)) + database.songQueries.insertSong("Nightcall", "Kavinsky") + if (heavy) { + // Wrap the batch in one transaction, vs. each insertSong() naively committing on its own. + database.transaction { + repeat(HEAVY_ROW_COUNT) { database.songQueries.insertSong("song $it", "artist $it") } + } + database.songQueries.selectAll().executeAsList().forEach { + appWork("${it.id}:${it.title}:${it.artist}") + } + } + // SQLDelight reads its cursor only via moveToNext(), which is delegated past the wrapper, so + // this count read produces no span. + val count = database.songQueries.countSongs().executeAsOne() + return "OpenHelper (SQLDelight): $count rows." + } + + /** + * Simulates per-row application-level work (e.g. decrypting a column) on consumed results. This + * is deliberately CPU-heavy and unrelated to the SQLite engine. + */ + private fun appWork(value: String) { + val digest = java.security.MessageDigest.getInstance("SHA-256") + var bytes = value.toByteArray() + repeat(500) { bytes = digest.digest(bytes) } + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt new file mode 100644 index 00000000000..3cc6d394daa --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt @@ -0,0 +1,72 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.lifecycleScope +import io.sentry.Sentry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Activity that lets us simulate SDK auto-generation of a `ui.load` transaction + attach SQLite + * statement spans to it. + * + * Timing note: the work runs off the main thread, so it finishes after the screen is first drawn. + * Time-to-full-display tracing (enabled in the manifest) keeps the `ui.load` transaction open until + * [Sentry.reportFullyDisplayed], which we call once the work completes — otherwise the transaction + * would auto-finish at first display and the late db spans would have nowhere to attach. + */ +class UiLoadActivity : ComponentActivity() { + + private var status by mutableStateOf("Running under the screen's auto ui.load transaction…") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val id = + SqlDemo.entries.find { it.name == intent.getStringExtra(EXTRA_DEMO_ID) } + ?: run { + finish() + return + } + val heavy = intent.getBooleanExtra(EXTRA_HEAVY, false) + + setContent { UiLoadScreen(status = status, onClose = ::finish) } + + // No Sentry.startTransaction(): the work runs under the auto ui.load:UiLoadActivity span. + lifecycleScope.launch { + status = + try { + val result = + withContext(Dispatchers.IO) { SqlStatements.execute(applicationContext, id, heavy) } + "$result\n\nRan under the auto ui.load transaction." + } catch (t: Throwable) { + Log.e(TAG, "Load failed", t) + "Load failed: ${t.message ?: t.javaClass.simpleName}" + } finally { + // Close the TTFD window so the ui.load transaction finishes with the db spans attached. + Sentry.reportFullyDisplayed() + } + } + } + + companion object { + private const val TAG = "UiLoadActivity" + private const val EXTRA_DEMO_ID = "demo_id" + private const val EXTRA_HEAVY = "heavy" + + /** Builds the intent that runs [id] (honoring the [heavy] toggle) on this UiLoadScreen. */ + fun intent(context: Context, id: SqlDemo, heavy: Boolean): Intent = + Intent(context, UiLoadActivity::class.java) + .putExtra(EXTRA_DEMO_ID, id.name) + .putExtra(EXTRA_HEAVY, heavy) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt new file mode 100644 index 00000000000..6495726448d --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt @@ -0,0 +1,110 @@ +package io.sentry.samples.android.sqlite + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.sentry.samples.android.R + +private val ShimmerHighlight = Color(0xFFBDBDBD) + +@Composable +fun UiLoadScreen(status: String, onClose: () -> Unit) { + MaterialTheme { + Surface { + Box( + modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding().padding(24.dp) + ) { + Column( + modifier = Modifier.align(Alignment.Center).fillMaxWidth().offset(y = (-48).dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShimmerSentryGlyph(modifier = Modifier.size(96.dp)) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = status, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } + + Button( + onClick = onClose, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), + colors = + ButtonDefaults.buttonColors(containerColor = Color.Black, contentColor = Color.White), + ) { + Text("Close") + } + } + } + } +} + +@Composable +private fun ShimmerSentryGlyph(modifier: Modifier = Modifier) { + val progress = remember { Animatable(0f) } + LaunchedEffect(Unit) { + progress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 700, delayMillis = 250, easing = LinearEasing), + ) + } + + Image( + painter = painterResource(R.drawable.sentry_glyph), + contentDescription = "Sentry", + modifier = + modifier + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val p = progress.value + val band = size.width * 0.5f + // Sweep the highlight band diagonally from off the bottom-left corner (p=0) to off the + // top-right corner (p=1): x travels left→right, y travels bottom→top. + val x = -band + (size.width + 2f * band) * p + val y = (size.height + band) - (size.height + 2f * band) * p + drawRect( + brush = + Brush.linearGradient( + colors = listOf(Color.Black, ShimmerHighlight, Color.Black), + start = Offset(x, y), + end = Offset(x + band, y - band), + ), + blendMode = BlendMode.SrcAtop, + ) + }, + ) +} diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml new file mode 100644 index 00000000000..b2dc323d185 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml @@ -0,0 +1,32 @@ + + + + + +