diff --git a/.craft.yml b/.craft.yml index d2177bc98e8..7dbf0382589 100644 --- a/.craft.yml +++ b/.craft.yml @@ -19,13 +19,13 @@ targets: maven:io.sentry:sentry: maven:io.sentry:sentry-spring: maven:io.sentry:sentry-spring-jakarta: -# maven:io.sentry:sentry-spring-7: + maven:io.sentry:sentry-spring-7: maven:io.sentry:sentry-spring-boot: maven:io.sentry:sentry-spring-boot-jakarta: maven:io.sentry:sentry-spring-boot-starter: maven:io.sentry:sentry-spring-boot-starter-jakarta: -# maven:io.sentry:sentry-spring-boot-4: -# maven:io.sentry:sentry-spring-boot-4-starter: + maven:io.sentry:sentry-spring-boot-4: + maven:io.sentry:sentry-spring-boot-4-starter: maven:io.sentry:sentry-servlet: maven:io.sentry:sentry-servlet-jakarta: maven:io.sentry:sentry-logback: diff --git a/.cursor/rules/new_module.mdc b/.cursor/rules/new_module.mdc new file mode 100644 index 00000000000..5bf2c70c2f7 --- /dev/null +++ b/.cursor/rules/new_module.mdc @@ -0,0 +1,88 @@ +--- +description: Module Addition Rules for sentry-java +alwaysApply: false +--- +# Module Addition Rules for sentry-java + +## Overview + +This document outlines the complete process for adding a new module to the sentry-java repository. Follow these steps in order to ensure proper integration and release management. + +## Step-by-Step Process + +### 1. Create the Module Structure + +1. Create the new module, conforming to the existing naming conventions and build scripts + +2. Add the module to the include list in `settings.gradle.kts` + +If adding a `sentry-samples` module, also add it to the `ignoredProjects` list in the root `build.gradle.kts`: + +```kotlin +ignoredProjects.addAll( + listOf( + // ... existing projects ... + "sentry-samples-{module-name}" + ) +) +``` + +3. If adding a JVM sample, add E2E (system) tests, following the structure we have in the existing JVM examples. + The test should then be added to `test/system-test-runner.py` and `.github/workflows/system-tests-backend.yml`. + +### 2. Create Module Documentation + +Create a `README.md` in the module directory with the following structure: + +```markdown +# sentry-{module-name} + +This module provides an integration for [Technology/Framework Name]. + +Please consult the documentation on how to install and use this integration in the Sentry Docs for [Android](https://docs.sentry.io/platforms/android/integrations/{module-name}/) or [Java](https://docs.sentry.io/platforms/java/tracing/instrumentation/{module-name}/). +``` + +The following tasks are required only when adding a module that isn't a sample. + +### 3. Update Main README.md + +Add the new module to the packages table in the main `README.md` with a placeholder link to the badge: + +```markdown +| sentry-{module-name} | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-{module-name}/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-{module-name}) | | +``` + +Note that the badge will only work after the module is released to Maven Central. + +### 4. Add Documentation to docs.sentry.io + +Add the necessary documentation to [docs.sentry.io](https://docs.sentry.io): +- For Java modules: Add to Java platform docs, usually in integrations section +- For Android modules: Add to Android platform docs, usually in integrations section +- Include installation instructions, configuration options, and usage examples + +### 5. Post release tasks + +Remind the user to perform the following tasks after the module is merged and released: + +1. Add the SDK to the Sentry release registry, following the instructions in the [sentry-release-registry README](https://github.com/getsentry/sentry-release-registry#adding-new-sdks) + +2. Add the module to `.craft.yml` in the `sdks` section: + ```yaml + sdks: + # ... existing modules ... + maven:io.sentry:sentry-{module-name}: + ``` + +## Module Naming Conventions + +- Use kebab-case for module names: `sentry-{module-name}` +- Follow existing patterns: `sentry-okhttp`, `sentry-apollo-4`, `sentry-spring-boot` +- For version-specific modules, include the version: `sentry-apollo-3`, `sentry-apollo-4` + +## Important Notes + +1. **API Files**: Do not modify `.api` files manually. Run `./gradlew apiDump` to regenerate them +2. **Backwards Compatibility**: Ensure new features are opt-in by default +3. **Testing**: Write comprehensive tests for all new functionality +4. **Documentation**: Always include proper documentation and examples diff --git a/.cursor/rules/overview_dev.mdc b/.cursor/rules/overview_dev.mdc index 2fc9687b5ff..89c70e2c158 100644 --- a/.cursor/rules/overview_dev.mdc +++ b/.cursor/rules/overview_dev.mdc @@ -42,6 +42,8 @@ Use the `fetch_rules` tool to include these rules when working on specific areas - `OtelSpanFactory`, `SentrySpanExporter` - Tracing, distributed tracing +- **`new_module`**: Use when adding a new integration or sample module + ### Testing - **`e2e_tests`**: Use when working with: - System tests, sample applications @@ -58,5 +60,6 @@ Use the `fetch_rules` tool to include these rules when working on specific areas - Scope/Hub/forking → `scopes` - Duplicate/dedup → `deduplication` - OpenTelemetry/tracing/spans → `opentelemetry` + - new module/integration/sample → `new_module` - Cache/offline/network → `offline` - System test/e2e/sample → `e2e_tests` diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ba7891ff9a9..b337ac9ea4e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,6 +6,10 @@ + ## :green_heart: How did you test it? @@ -13,6 +17,7 @@ ## :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. diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index ecfd71de126..da4b5cf0837 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -39,7 +39,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 31520ecea77..028cd65f424 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/changes-in-high-risk-code.yml b/.github/workflows/changes-in-high-risk-code.yml index 190fead4b46..e9c436ea253 100644 --- a/.github/workflows/changes-in-high-risk-code.yml +++ b/.github/workflows/changes-in-high-risk-code.yml @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Comment on PR to notify of changes in high risk files - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: high_risk_code: ${{ needs.files-changed.outputs.high_risk_code_files }} with: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f3108abb0c4..8111b344955 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -31,12 +31,12 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Initialize CodeQL - uses: github/codeql-action/init@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # pin@v2 + uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # pin@v2 with: languages: 'java' @@ -45,4 +45,4 @@ jobs: ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # pin@v2 + uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # pin@v2 diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index ee8e4c74144..9c31d4dba9f 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a - name: Set up Java uses: actions/setup-java@v5 @@ -20,7 +20,7 @@ jobs: java-version: '17' - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 # 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 c69ce46c445..64f022a4b1b 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -19,7 +19,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index e050cbca6fb..f94dffe3778 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -20,7 +20,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a - name: Generate Aggregate Javadocs run: | diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 54b757edc08..c005b6a6286 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -38,7 +38,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -88,7 +88,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a 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 ea7b1cd79b5..fb01f40f82d 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -36,7 +36,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 2ffb8167cfa..aee2808f529 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -33,7 +33,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 041555c3681..174c9a27779 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -26,7 +26,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a - name: Build artifacts run: make publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c61cea3b56..1dc43dc1b13 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml new file mode 100644 index 00000000000..2e249c3fd29 --- /dev/null +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -0,0 +1,176 @@ +name: Spring Boot 2.x Matrix + +on: + push: + branches: + - main + paths-ignore: + - '**/sentry-android/**' + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + spring-boot-2-matrix: + timeout-minutes: 45 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + springboot-version: [ '2.1.0', '2.2.5', '2.4.13', '2.5.15', '2.6.15', '2.7.0', '2.7.18' ] + + name: Spring Boot ${{ matrix.springboot-version }} + env: + SENTRY_URL: http://127.0.0.1:8000 + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + steps: + - name: Checkout Repo + uses: actions/checkout@v5 + with: + submodules: 'recursive' + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10.5' + + - name: Install Python dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + + # Workaround for https://github.com/gradle/actions/issues/21 to use config cache + - name: Cache buildSrc + uses: actions/cache@v4 + 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@ed408507eac070d1f99cc633dbcf757c94c7933a + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: Update Spring Boot 2.x version + run: | + sed -i 's/^springboot2=.*/springboot2=${{ matrix.springboot-version }}/' gradle/libs.versions.toml + echo "Updated Spring Boot 2.x version to ${{ matrix.springboot-version }}" + + - name: Exclude android modules from build + run: | + sed -i \ + -e '/.*"sentry-android-ndk",/d' \ + -e '/.*"sentry-android",/d' \ + -e '/.*"sentry-compose",/d' \ + -e '/.*"sentry-android-core",/d' \ + -e '/.*"sentry-android-fragment",/d' \ + -e '/.*"sentry-android-navigation",/d' \ + -e '/.*"sentry-android-sqlite",/d' \ + -e '/.*"sentry-android-timber",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ + -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ + -e '/.*"sentry-samples:sentry-samples-android",/d' \ + -e '/.*"sentry-android-replay",/d' \ + settings.gradle.kts + + - name: Exclude android modules from ignore list + run: | + sed -i \ + -e '/.*"sentry-uitest-android",/d' \ + -e '/.*"sentry-uitest-android-benchmark",/d' \ + -e '/.*"sentry-uitest-android-critical",/d' \ + -e '/.*"test-app-sentry",/d' \ + -e '/.*"sentry-samples-android",/d' \ + build.gradle.kts + + - name: Build SDK + run: | + ./gradlew assemble --parallel + + - 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" + + - 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" + + - 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" + + - 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" + + - 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" + + - name: Test sentry-samples-spring + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring" \ + --agent false \ + --auto-init "true" \ + --build "true" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-springboot-2-${{ matrix.springboot-version }} + path: | + **/build/reports/* + **/build/test-results/**/*.xml + sentry-mock-server.txt + spring-server.txt + + - name: Test Report + uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + if: always() + with: + name: JUnit Spring Boot 2.x ${{ matrix.springboot-version }} + path: | + **/build/test-results/**/*.xml + reporter: java-junit + output-to: step-summary + fail-on-error: false + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml new file mode 100644 index 00000000000..3195fc9c4e8 --- /dev/null +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -0,0 +1,176 @@ +name: Spring Boot 3.x Matrix + +on: + push: + branches: + - main + paths-ignore: + - '**/sentry-android/**' + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + spring-boot-3-matrix: + timeout-minutes: 45 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + springboot-version: [ '3.0.0', '3.2.10', '3.3.5', '3.4.5', '3.5.6' ] + + name: Spring Boot ${{ matrix.springboot-version }} + env: + SENTRY_URL: http://127.0.0.1:8000 + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + steps: + - name: Checkout Repo + uses: actions/checkout@v5 + with: + submodules: 'recursive' + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10.5' + + - name: Install Python dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + + # Workaround for https://github.com/gradle/actions/issues/21 to use config cache + - name: Cache buildSrc + uses: actions/cache@v4 + 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@ed408507eac070d1f99cc633dbcf757c94c7933a + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: Update Spring Boot 3.x version + run: | + sed -i 's/^springboot3=.*/springboot3=${{ matrix.springboot-version }}/' gradle/libs.versions.toml + echo "Updated Spring Boot 3.x version to ${{ matrix.springboot-version }}" + + - 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 SDK + run: | + ./gradlew assemble --parallel + + - 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" + + - 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" + + - 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" + + - 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" + + - 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" + + - 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" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-springboot-3-${{ matrix.springboot-version }} + path: | + **/build/reports/* + **/build/test-results/**/*.xml + sentry-mock-server.txt + spring-server.txt + + - name: Test Report + uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + if: always() + with: + name: JUnit Spring Boot 3.x ${{ matrix.springboot-version }} + path: | + **/build/test-results/**/*.xml + reporter: java-junit + output-to: step-summary + fail-on-error: false + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml new file mode 100644 index 00000000000..6c980e10646 --- /dev/null +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -0,0 +1,177 @@ +name: Spring Boot 4.x Matrix + +on: + push: + branches: + - main + paths-ignore: + - '**/sentry-android/**' + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + spring-boot-4-matrix: + timeout-minutes: 45 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + springboot-version: [ '4.0.0-M1', '4.0.0-M2', '4.0.0-M3' ] + + name: Spring Boot ${{ matrix.springboot-version }} + env: + SENTRY_URL: http://127.0.0.1:8000 + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + steps: + - name: Checkout Repo + uses: actions/checkout@v5 + with: + submodules: 'recursive' + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10.5' + + - name: Install Python dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + + # Workaround for https://github.com/gradle/actions/issues/21 to use config cache + - name: Cache buildSrc + uses: actions/cache@v4 + 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@ed408507eac070d1f99cc633dbcf757c94c7933a + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: Update Spring Boot 4.x version + run: | + sed -i 's/^springboot4=.*/springboot4=${{ matrix.springboot-version }}/' gradle/libs.versions.toml + echo "Updated Spring Boot 4.x version to ${{ matrix.springboot-version }}" + + - 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 SDK + run: | + ./gradlew assemble --parallel + + - 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" + + - 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" + + - 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" + + - name: Run sentry-samples-spring-boot-4-opentelemetry agent init false + run: | + python3 test/system-test-runner.py test \ + --module "sentry-samples-spring-boot-4-opentelemetry" \ + --agent true \ + --auto-init "false" \ + --build "true" + +# needs a fix in opentelemetry-spring-boot-starter +# - name: Run sentry-samples-spring-boot-4-opentelemetry-noagent +# run: | +# python3 test/system-test-runner.py test \ +# --module "sentry-samples-spring-boot-4-opentelemetry-noagent" \ +# --agent false \ +# --auto-init "true" \ +# --build "true" + + - 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" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-springboot-4-${{ matrix.springboot-version }} + path: | + **/build/reports/* + **/build/test-results/**/*.xml + sentry-mock-server.txt + spring-server.txt + + - name: Test Report + uses: phoenix-actions/test-reporting@f957cd93fc2d848d556fa0d03c57bc79127b6b5e # pin@v15 + if: always() + with: + name: JUnit Spring Boot 4.x ${{ matrix.springboot-version }} + path: | + **/build/test-results/**/*.xml + reporter: java-junit + output-to: step-summary + fail-on-error: false + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index b3d72dd2dab..43f69a69889 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -78,15 +78,21 @@ jobs: - sample: "sentry-samples-spring-boot-4-opentelemetry" agent: "true" agent-auto-init: "false" + - sample: "sentry-samples-spring-7" + agent: "false" + agent-auto-init: "true" - sample: "sentry-samples-spring-jakarta" agent: "false" agent-auto-init: "true" + - sample: "sentry-samples-spring" + agent: "false" + agent-auto-init: "true" steps: - uses: actions/checkout@v5 with: submodules: 'recursive' - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.10.5' @@ -102,7 +108,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 33e972d35ee..38a7013473d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ # Changelog +## 8.23.0 + +### Various fixes & improvements + +- Add session replay id to Sentry Logs (#4740) by @stefanosiano +- Feat/poc continuous profiling (#4556) by @lbloder +- Start performance collection on AppStart continuous profiling (#4752) by @stefanosiano +- Fix(compose): Preserve modifiers in SentryNavigable (#4757) by @VleemingM +- build(deps): bump github/codeql-action from 3.30.3 to 3.30.5 (#4761) by @dependabot +- Handle `RejectedExecutionException` everywhere (#4747) by @lcian +- Mark `SentryEnvelope` as not internal (#4748) by @lcian + +## Features + +- Add session replay id to Sentry Logs ([#4740](https://github.com/getsentry/sentry-java/pull/4740)) +- Add support for continuous profiling of JVM applications on macOS and Linux ([#4556](https://github.com/getsentry/sentry-java/pull/4556)) + - [Sentry continuous profiling](https://docs.sentry.io/product/explore/profiling/) on the JVM is using async-profiler under the hood. + - By default this feature is disabled. Set a profile sample rate and chose a lifecycle (see below) to enable it. + - Add the `sentry-async-profiler` dependency to your project + - Set a sample rate for profiles, e.g. `1.0` to send all of them. You may use `options.setProfileSessionSampleRate(1.0)` in code or `profile-session-sample-rate=1.0` in `sentry.properties` + - Set a profile lifecycle via `options.setProfileLifecycle(ProfileLifecycle.TRACE)` in code or `profile-lifecycle=TRACE` in `sentry.properties` + - By default the lifecycle is set to `MANUAL`, meaning you have to explicitly call `Sentry.startProfiler()` and `Sentry.stopProfiler()` + - You may change it to `TRACE` which will create a profile for each transaction + - To automatically upload Profiles for each transaction in a Spring Boot application + - set `sentry.profile-session-sample-rate=1.0` and `sentry.profile-lifecycle=TRACE` in `application.properties` + - or set `sentry.profile-session-sample-rate: 1.0` and `sentry.profile-lifecycle: TRACE` in `application.yml` + - Profiling can also be combined with our OpenTelemetry integration + +### Fixes + +- Start performance collection on AppStart continuous profiling ([#4752](https://github.com/getsentry/sentry-java/pull/4752)) +- Preserve modifiers in `SentryTraced` ([#4757](https://github.com/getsentry/sentry-java/pull/4757)) + +### Improvements + +- Handle `RejectedExecutionException` everywhere ([#4747](https://github.com/getsentry/sentry-java/pull/4747)) +- Mark `SentryEnvelope` as not internal ([#4748](https://github.com/getsentry/sentry-java/pull/4748)) + +## 8.22.0 + +### Features + +- Move SentryLogs out of experimental ([#4710](https://github.com/getsentry/sentry-java/pull/4710)) +- Add support for w3c traceparent header ([#4671](https://github.com/getsentry/sentry-java/pull/4671)) + - This feature is disabled by default. If enabled, outgoing requests will include the w3c `traceparent` header. + - See https://develop.sentry.dev/sdk/telemetry/traces/distributed-tracing/#w3c-trace-context-header for more details. + ```kotlin + Sentry(Android).init(context) { options -> + // ... + options.isPropagateTraceparent = true + } + ``` +- Sentry now supports Spring Boot 4 M3 pre-release ([#4739](https://github.com/getsentry/sentry-java/pull/4739)) + +### Improvements + +- Remove internal API status from get/setDistinctId ([#4708](https://github.com/getsentry/sentry-java/pull/4708)) +- Remove ApiStatus.Experimental annotation from check-in API ([#4721](https://github.com/getsentry/sentry-java/pull/4721)) + +### Fixes + +- Session Replay: Fix `NoSuchElementException` in `BufferCaptureStrategy` ([#4717](https://github.com/getsentry/sentry-java/pull/4717)) +- Session Replay: Fix continue recording in Session mode after Buffer is triggered ([#4719](https://github.com/getsentry/sentry-java/pull/4719)) + +### Dependencies + +- Bump Native SDK from v0.10.0 to v0.10.1 ([#4695](https://github.com/getsentry/sentry-java/pull/4695)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0101) + - [diff](https://github.com/getsentry/sentry-native/compare/0.10.0...0.10.1) + ## 8.21.1 ### Fixes diff --git a/README.md b/README.md index 5f06d14f9ee..096ecf7d19a 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,13 @@ Sentry SDK for Java and Android | sentry-servlet-jakarta | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-servlet-jakarta/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-servlet-jakarta) | | | sentry-spring-boot | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot) | | sentry-spring-boot-jakarta | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-jakarta/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-jakarta) | +| sentry-spring-boot-4 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-4/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-4) | +| sentry-spring-boot-4-starter | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-4-starter/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-4-starter) | | sentry-spring-boot-starter | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-starter/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-starter) | | sentry-spring-boot-starter-jakarta | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-starter-jakarta/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-boot-starter-jakarta) | | sentry-spring | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring) | | sentry-spring-jakarta | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-jakarta/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-jakarta) | +| sentry-spring-7 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-7/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-spring-7) | | sentry-logback | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-logback/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-logback) | | sentry-log4j2 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-log4j2/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-log4j2) | | sentry-bom | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-bom/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-bom) | @@ -58,7 +61,7 @@ Sentry SDK for Java and Android | sentry-opentelemetry-agentcustomization | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agentcustomization/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agentcustomization) | | sentry-opentelemetry-core | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core) | | sentry-okhttp | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp) | -| sentry-reactor | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-reactor/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-reactor) | +| sentry-reactor | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-reactor/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-reactor) | # Releases diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 8f373df14dd..9ffe5ac3117 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -83,6 +83,7 @@ object Config { val SENTRY_OKHTTP_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.okhttp" val SENTRY_REACTOR_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.reactor" val SENTRY_KOTLIN_EXTENSIONS_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.kotlin-extensions" + val SENTRY_ASYNC_PROFILER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.async-profiler" val SENTRY_KTOR_CLIENT_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.ktor-client" val group = "io.sentry" val description = "SDK for sentry.io" diff --git a/codecov.yml b/codecov.yml index 9b1af61b4c6..3a53b1f7b3f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -20,3 +20,4 @@ ignore: - "sentry-system-test-support/*" - "sentry-test-support/*" - "sentry-samples/*" + - "sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/**" diff --git a/gradle.properties b/gradle.properties index 1cc4209e661..5637a2f35c0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled android.useAndroidX=true # Release information -versionName=8.21.1 +versionName=8.23.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 ffbe1c3d735..60c163373a7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,12 +31,13 @@ retrofit = "2.9.0" slf4j = "1.7.30" springboot2 = "2.7.18" springboot3 = "3.5.0" -springboot4 = "4.0.0-M2" +springboot4 = "4.0.0-M3" # Android targetSdk = "34" compileSdk = "34" minSdk = "21" spotless = "7.0.4" +gummyBears = "0.12.0" [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } @@ -62,6 +63,7 @@ springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" } springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.0.11.RELEASE" } gretty = { id = "org.gretty", version = "4.0.0" } +animalsniffer = { id = "ru.vyarus.animalsniffer", version = "2.0.1" } [libraries] apache-httpclient = { module = "org.apache.httpcomponents.client5:httpclient5", version = "5.0.4" } @@ -97,6 +99,7 @@ feign-gson = { module = "io.github.openfeign:feign-gson", version.ref = "feign" graphql-java17 = { module = "com.graphql-java:graphql-java", version = "17.3" } graphql-java22 = { module = "com.graphql-java:graphql-java", version = "22.1" } graphql-java24 = { module = "com.graphql-java:graphql-java", version = "24.0" } +jackson-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jackson" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } jetbrains-annotations = { module = "org.jetbrains:annotations", version = "23.0.0" } @@ -129,7 +132,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.10.0" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.10.1" } 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" } @@ -167,7 +170,7 @@ springboot4-starter-test = { module = "org.springframework.boot:spring-boot-star springboot4-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "springboot4" } springboot4-starter-websocket = { module = "org.springframework.boot:spring-boot-starter-websocket", version.ref = "springboot4" } springboot4-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "springboot4" } -springboot4-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop", version.ref = "springboot4" } +springboot4-starter-aspectj = { module = "org.springframework.boot:spring-boot-starter-aspectj", version.ref = "springboot4" } springboot4-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "springboot4" } springboot4-starter-restclient = { module = "org.springframework.boot:spring-boot-starter-restclient", version.ref = "springboot4" } springboot4-starter-webclient = { module = "org.springframework.boot:spring-boot-starter-webclient", version.ref = "springboot4" } @@ -175,7 +178,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" } 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" } + # tomcat libraries +tomcat-catalina = { module = "org.apache.tomcat:tomcat-catalina", version = "9.0.108" } +tomcat-embed-jasper = { module = "org.apache.tomcat.embed:tomcat-embed-jasper", version = "9.0.108" } tomcat-catalina-jakarta = { module = "org.apache.tomcat:tomcat-catalina", version = "11.0.10" } tomcat-embed-jasper-jakarta = { module = "org.apache.tomcat.embed:tomcat-embed-jasper", version = "11.0.10" } diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 5b416486576..0712c78ce91 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -43,6 +43,7 @@ public final class io/sentry/android/core/ActivityLifecycleIntegration : android public class io/sentry/android/core/AndroidContinuousProfiler : io/sentry/IContinuousProfiler, io/sentry/transport/RateLimiter$IRateLimitObserver { public fun (Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/ISentryExecutorService;)V public fun close (Z)V + public fun getChunkId ()Lio/sentry/protocol/SentryId; public fun getProfilerId ()Lio/sentry/protocol/SentryId; public fun getRootSpanCounter ()I public fun isRunning ()Z diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 802a8bfb118..99d6b5115c8 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -77,6 +77,7 @@ dependencies { compileOnly(projects.sentryAndroidTimber) compileOnly(projects.sentryAndroidReplay) compileOnly(projects.sentryCompose) + compileOnly(projects.sentryAndroidDistribution) // lifecycle processor, session tracking implementation(libs.androidx.lifecycle.common.java8) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 0c6d47e5ecb..5ebad5ac0c8 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -78,3 +78,8 @@ -dontwarn io.sentry.android.replay.DefaultReplayBreadcrumbConverter -keepnames class io.sentry.android.replay.ReplayIntegration ##---------------End: proguard configuration for sentry-android-replay ---------- + +##---------------Begin: proguard configuration for sentry-android-distribution ---------- +-dontwarn io.sentry.android.distribution.DistributionIntegration +-keepnames class io.sentry.android.distribution.DistributionIntegration +##---------------End: proguard configuration for sentry-android-distribution ---------- 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 a3fb6f6c8db..3087c876a94 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 @@ -208,11 +208,11 @@ private void start() { isRunning = true; - if (profilerId == SentryId.EMPTY_ID) { + if (profilerId.equals(SentryId.EMPTY_ID)) { profilerId = new SentryId(); } - if (chunkId == SentryId.EMPTY_ID) { + if (chunkId.equals(SentryId.EMPTY_ID)) { chunkId = new SentryId(); } @@ -300,7 +300,8 @@ private void stop(final boolean restartProfiler) { chunkId, endData.measurementsMap, endData.traceFile, - startProfileChunkTimestamp)); + startProfileChunkTimestamp, + ProfileChunk.PLATFORM_ANDROID)); } } @@ -344,6 +345,11 @@ public void close(final boolean isTerminating) { return profilerId; } + @Override + public @NotNull SentryId getChunkId() { + return chunkId; + } + private void sendChunks(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { try { options 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 21dde74d3ee..70c76c72824 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 @@ -5,6 +5,7 @@ import android.app.Application; import android.content.Context; import android.content.pm.PackageInfo; +import io.sentry.CompositePerformanceCollector; import io.sentry.DeduplicateMultithreadedEventProcessor; import io.sentry.DefaultCompositePerformanceCollector; import io.sentry.DefaultVersionDetector; @@ -32,6 +33,7 @@ import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.distribution.DistributionIntegration; import io.sentry.android.fragment.FragmentLifecycleIntegration; import io.sentry.android.replay.DefaultReplayBreadcrumbConverter; import io.sentry.android.replay.ReplayIntegration; @@ -44,6 +46,7 @@ import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.modules.NoOpModulesLoader; import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; +import io.sentry.protocol.SentryId; import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.transport.NoOpTransportGate; @@ -179,25 +182,7 @@ static void initializeIntegrationsAndProcessors( options.setTransportGate(new AndroidTransportGate(options)); } - // Check if the profiler was already instantiated in the app start. - // We use the Android profiler, that uses a global start/stop api, so we need to preserve the - // state of the profiler, and it's only possible retaining the instance. final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); - final @Nullable ITransactionProfiler appStartTransactionProfiler; - final @Nullable IContinuousProfiler appStartContinuousProfiler; - try (final @NotNull ISentryLifecycleToken ignored = AppStartMetrics.staticLock.acquire()) { - appStartTransactionProfiler = appStartMetrics.getAppStartProfiler(); - appStartContinuousProfiler = appStartMetrics.getAppStartContinuousProfiler(); - appStartMetrics.setAppStartProfiler(null); - appStartMetrics.setAppStartContinuousProfiler(null); - } - - setupProfiler( - options, - context, - buildInfoProvider, - appStartTransactionProfiler, - appStartContinuousProfiler); if (options.getModulesLoader() instanceof NoOpModulesLoader) { options.setModulesLoader(new AssetsModulesLoader(context, options.getLogger())); @@ -261,6 +246,26 @@ static void initializeIntegrationsAndProcessors( if (options.getCompositePerformanceCollector() instanceof NoOpCompositePerformanceCollector) { options.setCompositePerformanceCollector(new DefaultCompositePerformanceCollector(options)); } + + // Check if the profiler was already instantiated in the app start. + // We use the Android profiler, that uses a global start/stop api, so we need to preserve the + // state of the profiler, and it's only possible retaining the instance. + final @Nullable ITransactionProfiler appStartTransactionProfiler; + final @Nullable IContinuousProfiler appStartContinuousProfiler; + try (final @NotNull ISentryLifecycleToken ignored = AppStartMetrics.staticLock.acquire()) { + appStartTransactionProfiler = appStartMetrics.getAppStartProfiler(); + appStartContinuousProfiler = appStartMetrics.getAppStartContinuousProfiler(); + appStartMetrics.setAppStartProfiler(null); + appStartMetrics.setAppStartContinuousProfiler(null); + } + + setupProfiler( + options, + context, + buildInfoProvider, + appStartTransactionProfiler, + appStartContinuousProfiler, + options.getCompositePerformanceCollector()); } /** Setup the correct profiler (transaction or continuous) based on the options. */ @@ -269,7 +274,8 @@ private static void setupProfiler( final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider, final @Nullable ITransactionProfiler appStartTransactionProfiler, - final @Nullable IContinuousProfiler appStartContinuousProfiler) { + final @Nullable IContinuousProfiler appStartContinuousProfiler, + final @NotNull CompositePerformanceCollector performanceCollector) { if (options.isProfilingEnabled() || options.getProfilesSampleRate() != null) { options.setContinuousProfiler(NoOpContinuousProfiler.getInstance()); // This is a safeguard, but it should never happen, as the app start profiler should be the @@ -298,6 +304,12 @@ private static void setupProfiler( } if (appStartContinuousProfiler != null) { options.setContinuousProfiler(appStartContinuousProfiler); + // If the profiler is running, we start the performance collector too, otherwise we'd miss + // measurements in app launch profiles + final @NotNull SentryId chunkId = appStartContinuousProfiler.getChunkId(); + if (appStartContinuousProfiler.isRunning() && !chunkId.equals(SentryId.EMPTY_ID)) { + performanceCollector.start(chunkId.toString()); + } } else { options.setContinuousProfiler( new AndroidContinuousProfiler( @@ -321,7 +333,8 @@ static void installDefaultIntegrations( final @NotNull ActivityFramesTracker activityFramesTracker, final boolean isFragmentAvailable, final boolean isTimberAvailable, - final boolean isReplayAvailable) { + final boolean isReplayAvailable, + final boolean isDistributionAvailable) { // Integration MUST NOT cache option values in ctor, as they will be configured later by the // user @@ -391,6 +404,11 @@ static void installDefaultIntegrations( options.addIntegration(replay); options.setReplayController(replay); } + if (isDistributionAvailable) { + final DistributionIntegration distribution = new DistributionIntegration((context)); + options.setDistributionController(distribution); + options.addIntegration(distribution); + } options .getFeedbackOptions() .setDialogHandler(new SentryAndroidOptions.AndroidUserFeedbackIDialogHandler()); 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 9409c29b0d8..1e37916aaee 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 @@ -4,18 +4,7 @@ import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.os.Build; -import io.sentry.DateUtils; -import io.sentry.EventProcessor; -import io.sentry.Hint; -import io.sentry.IpAddressUtils; -import io.sentry.NoOpLogger; -import io.sentry.SentryAttributeType; -import io.sentry.SentryBaseEvent; -import io.sentry.SentryEvent; -import io.sentry.SentryLevel; -import io.sentry.SentryLogEvent; -import io.sentry.SentryLogEventAttributeValue; -import io.sentry.SentryReplayEvent; +import io.sentry.*; import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; @@ -37,6 +26,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -47,7 +37,7 @@ final class DefaultAndroidEventProcessor implements EventProcessor { private final @NotNull BuildInfoProvider buildInfoProvider; private final @NotNull SentryAndroidOptions options; - private final @NotNull Future deviceInfoUtil; + private final @Nullable Future deviceInfoUtil; private final @NotNull LazyEvaluator deviceFamily = new LazyEvaluator<>(() -> ContextUtils.getFamily(NoOpLogger.getInstance())); @@ -65,9 +55,16 @@ public DefaultAndroidEventProcessor( // don't ref. to method reference, theres a bug on it // 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(); - this.deviceInfoUtil = - executorService.submit(() -> DeviceInfoUtil.getInstance(this.context, options)); + try { + deviceInfoUtil = + executorService.submit(() -> DeviceInfoUtil.getInstance(this.context, options)); + } catch (RejectedExecutionException e) { + deviceInfoUtil = null; + options.getLogger().log(SentryLevel.WARNING, "Device info caching task rejected.", e); + } + this.deviceInfoUtil = deviceInfoUtil; executorService.shutdown(); } @@ -181,12 +178,16 @@ private void setDevice( final boolean errorEvent, final boolean applyScopeData) { if (event.getContexts().getDevice() == null) { - try { - event - .getContexts() - .setDevice(deviceInfoUtil.get().collectDeviceInformation(errorEvent, applyScopeData)); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info", e); + if (deviceInfoUtil != null) { + try { + event + .getContexts() + .setDevice(deviceInfoUtil.get().collectDeviceInformation(errorEvent, applyScopeData)); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info", e); + } + } else { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info"); } mergeOS(event); } @@ -194,12 +195,17 @@ private void setDevice( private void mergeOS(final @NotNull SentryBaseEvent event) { final OperatingSystem currentOS = event.getContexts().getOperatingSystem(); - try { - final OperatingSystem androidOS = deviceInfoUtil.get().getOperatingSystem(); - // make Android OS the main OS using the 'os' key - event.getContexts().setOperatingSystem(androidOS); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve os system", e); + + if (deviceInfoUtil != null) { + try { + final OperatingSystem androidOS = deviceInfoUtil.get().getOperatingSystem(); + // make Android OS the main OS using the 'os' key + event.getContexts().setOperatingSystem(androidOS); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve os system", e); + } + } else { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info"); } if (currentOS != null) { @@ -284,10 +290,14 @@ private void setPackageInfo(final @NotNull SentryBaseEvent event, final @NotNull setDist(event, versionCode); @Nullable DeviceInfoUtil deviceInfoUtil = null; - try { - deviceInfoUtil = this.deviceInfoUtil.get(); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info", e); + if (this.deviceInfoUtil != null) { + try { + deviceInfoUtil = this.deviceInfoUtil.get(); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info", e); + } + } else { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info"); } ContextUtils.setAppPackageInfo(packageInfo, buildInfoProvider, deviceInfoUtil, app); @@ -331,16 +341,20 @@ private void setAppExtras(final @NotNull App app, final @NotNull Hint hint) { } private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { - try { - final ContextUtils.SideLoadedInfo sideLoadedInfo = deviceInfoUtil.get().getSideLoadedInfo(); - if (sideLoadedInfo != null) { - final @NotNull Map tags = sideLoadedInfo.asTags(); - for (Map.Entry entry : tags.entrySet()) { - event.setTag(entry.getKey(), entry.getValue()); + if (deviceInfoUtil != null) { + try { + final ContextUtils.SideLoadedInfo sideLoadedInfo = deviceInfoUtil.get().getSideLoadedInfo(); + if (sideLoadedInfo != null) { + final @NotNull Map tags = sideLoadedInfo.asTags(); + for (Map.Entry entry : tags.entrySet()) { + event.setTag(entry.getKey(), entry.getValue()); + } } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting side loaded info.", e); } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting side loaded info.", e); + } else { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve device info"); } } 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 d183d4c45be..82263ebcbda 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 @@ -41,6 +41,9 @@ public final class SentryAndroid { static final String SENTRY_REPLAY_INTEGRATION_CLASS_NAME = "io.sentry.android.replay.ReplayIntegration"; + static final String SENTRY_DISTRIBUTION_INTEGRATION_CLASS_NAME = + "io.sentry.android.distribution.DistributionIntegration"; + private static final String TIMBER_CLASS_NAME = "timber.log.Timber"; private static final String FRAGMENT_CLASS_NAME = "androidx.fragment.app.FragmentManager$FragmentLifecycleCallbacks"; @@ -111,6 +114,8 @@ public static void init( && classLoader.isClassAvailable(SENTRY_TIMBER_INTEGRATION_CLASS_NAME, options)); final boolean isReplayAvailable = classLoader.isClassAvailable(SENTRY_REPLAY_INTEGRATION_CLASS_NAME, options); + final boolean isDistributionAvailable = + classLoader.isClassAvailable(SENTRY_DISTRIBUTION_INTEGRATION_CLASS_NAME, options); final BuildInfoProvider buildInfoProvider = new BuildInfoProvider(logger); final io.sentry.util.LoadClass loadClass = new io.sentry.util.LoadClass(); @@ -131,7 +136,8 @@ public static void init( activityFramesTracker, isFragmentAvailable, isTimberAvailable, - isReplayAvailable); + isReplayAvailable, + isDistributionAvailable); try { configuration.configure(options); 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 8539d271430..60a5ab530fc 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 @@ -30,6 +30,7 @@ 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 @@ -124,6 +125,7 @@ class AndroidContinuousProfilerTest { false, false, false, + false, ) AndroidOptionsInitializer.initializeIntegrationsAndProcessors( @@ -166,9 +168,13 @@ class AndroidContinuousProfilerTest { // We are scheduling the profiler to stop at the end of the chunk, so it should still be running 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) } @Test @@ -396,6 +402,7 @@ class AndroidContinuousProfilerTest { val profiler = fixture.getSut() profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertTrue(profiler.isRunning) + val oldChunkId = profiler.chunkId fixture.executor.runAll() verify(fixture.mockLogger) @@ -406,6 +413,7 @@ class AndroidContinuousProfilerTest { 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 @@ -507,6 +515,7 @@ class AndroidContinuousProfilerTest { 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.")) } @@ -522,6 +531,7 @@ class AndroidContinuousProfilerTest { 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.")) } @@ -540,6 +550,7 @@ class AndroidContinuousProfilerTest { 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.")) } 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 cd1a7cc26de..d49a905772d 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 @@ -12,6 +12,7 @@ import io.sentry.IConnectionStatusProvider import io.sentry.IContinuousProfiler import io.sentry.ILogger import io.sentry.ISocketTagger +import io.sentry.ITransaction import io.sentry.ITransactionProfiler import io.sentry.MainEventProcessor import io.sentry.NoOpContinuousProfiler @@ -34,6 +35,7 @@ import io.sentry.cache.PersistingScopeObserver import io.sentry.compose.gestures.ComposeGestureTargetLocator import io.sentry.internal.debugmeta.IDebugMetaLoader import io.sentry.internal.modules.IModulesLoader +import io.sentry.protocol.SentryId import io.sentry.test.ImmediateExecutorService import io.sentry.transport.ITransportGate import io.sentry.util.thread.IThreadChecker @@ -105,6 +107,7 @@ class AndroidOptionsInitializerTest { false, false, false, + false, ) sentryOptions.configureOptions() @@ -149,6 +152,7 @@ class AndroidOptionsInitializerTest { isFragmentAvailable, isTimberAvailable, isReplayAvailable, + false, ) AndroidOptionsInitializer.initializeIntegrationsAndProcessors( @@ -424,6 +428,33 @@ class AndroidOptionsInitializerTest { assertNull(AppStartMetrics.getInstance().appStartContinuousProfiler) } + @Test + fun `init starts performance collector if continuous profiler of appStartMetrics is running`() { + val appStartContinuousProfiler = mock() + val mockPerformanceCollector = mock() + val chunkId = SentryId() + whenever(appStartContinuousProfiler.isRunning()).thenReturn(true) + whenever(appStartContinuousProfiler.chunkId).thenReturn(chunkId) + + AppStartMetrics.getInstance().appStartContinuousProfiler = appStartContinuousProfiler + fixture.initSut(configureOptions = { compositePerformanceCollector = mockPerformanceCollector }) + + verify(mockPerformanceCollector).start(eq(chunkId.toString())) + } + + @Test + fun `init does not start performance collector if transaction profiler of appStartMetrics is running`() { + val appStartTransactionProfiler = mock() + val mockPerformanceCollector = mock() + whenever(appStartTransactionProfiler.isRunning()).thenReturn(true) + + AppStartMetrics.getInstance().appStartProfiler = appStartTransactionProfiler + fixture.initSut(configureOptions = { compositePerformanceCollector = mockPerformanceCollector }) + + verify(mockPerformanceCollector, never()).start(any()) + verify(mockPerformanceCollector, never()).start(any()) + } + @Test fun `init with transaction profiling closes continuous profiler of appStartMetrics`() { val appStartContinuousProfiler = mock() @@ -820,6 +851,7 @@ class AndroidOptionsInitializerTest { false, false, false, + false, ) verify(mockOptions, never()).outboxPath verify(mockOptions, never()).cacheDirPath 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 f4b4da814b8..dce66ae2b5e 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 @@ -127,6 +127,7 @@ class AndroidProfilerTest { false, false, false, + false, ) AndroidOptionsInitializer.initializeIntegrationsAndProcessors( 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 ec6ba18d65e..50c7ba3c7d3 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 @@ -143,6 +143,7 @@ class AndroidTransactionProfilerTest { false, false, false, + false, ) AndroidOptionsInitializer.initializeIntegrationsAndProcessors( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt index fec0649f2fc..63a6ff8cb60 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt @@ -160,6 +160,7 @@ class SentryInitProviderTest { false, false, false, + false, ) AndroidOptionsInitializer.initializeIntegrationsAndProcessors( diff --git a/sentry-android-distribution/api/sentry-android-distribution.api b/sentry-android-distribution/api/sentry-android-distribution.api index 020a99620bd..d9c7ced1cfe 100644 --- a/sentry-android-distribution/api/sentry-android-distribution.api +++ b/sentry-android-distribution/api/sentry-android-distribution.api @@ -1,4 +1,8 @@ -public final class io/sentry/android/distribution/Distribution { - public fun ()V +public final class io/sentry/android/distribution/DistributionIntegration : io/sentry/IDistributionApi, io/sentry/Integration { + public fun (Landroid/content/Context;)V + public fun checkForUpdate (Lio/sentry/IDistributionApi$UpdateCallback;)V + public fun checkForUpdateBlocking ()Lio/sentry/UpdateStatus; + public fun downloadUpdate (Lio/sentry/UpdateInfo;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } diff --git a/sentry-android-distribution/build.gradle.kts b/sentry-android-distribution/build.gradle.kts index aff4a7b9230..2d23bf3ab74 100644 --- a/sentry-android-distribution/build.gradle.kts +++ b/sentry-android-distribution/build.gradle.kts @@ -11,6 +11,13 @@ android { defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } buildFeatures { buildConfig = false } + + testOptions { + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } } kotlin { @@ -25,5 +32,12 @@ androidComponents.beforeVariants { dependencies { implementation(projects.sentry) + implementation( + libs.jetbrains.annotations + ) // Use implementation instead of compileOnly to override kotlin stdlib's version implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid)) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.roboelectric) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.androidx.test.core) } diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/Distribution.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/Distribution.kt deleted file mode 100644 index 396cb02e131..00000000000 --- a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/Distribution.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.sentry.android.distribution - -public class Distribution {} diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt new file mode 100644 index 00000000000..5cb7724908a --- /dev/null +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt @@ -0,0 +1,116 @@ +package io.sentry.android.distribution + +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import javax.net.ssl.HttpsURLConnection + +/** HTTP client for making requests to Sentry's distribution API. */ +internal class DistributionHttpClient(private val options: SentryOptions) { + + /** Represents the result of an HTTP request. */ + data class HttpResponse( + val statusCode: Int, + val body: String, + val isSuccessful: Boolean = statusCode in 200..299, + ) + + /** Parameters for checking updates. */ + data class UpdateCheckParams( + val mainBinaryIdentifier: String, + val appId: String, + val platform: String = "android", + val versionCode: Long, + val versionName: String, + ) + + /** + * Makes a GET request to the distribution API to check for updates. + * + * @param params Update check parameters + * @return HttpResponse containing the response details + */ + fun checkForUpdates(params: UpdateCheckParams): HttpResponse { + val distributionOptions = options.distribution + val orgSlug = distributionOptions.orgSlug + val projectSlug = distributionOptions.projectSlug + val authToken = distributionOptions.orgAuthToken + val baseUrl = distributionOptions.sentryBaseUrl + + if (orgSlug.isNullOrEmpty() || projectSlug.isNullOrEmpty() || authToken.isNullOrEmpty()) { + throw IllegalStateException( + "Missing required distribution configuration: orgSlug, projectSlug, or orgAuthToken" + ) + } + + val urlString = buildString { + append(baseUrl.trimEnd('/')) + append( + "/api/0/projects/${URLEncoder.encode(orgSlug, "UTF-8")}/${URLEncoder.encode(projectSlug, "UTF-8")}/preprodartifacts/check-for-updates/" + ) + append("?main_binary_identifier=${URLEncoder.encode(params.mainBinaryIdentifier, "UTF-8")}") + append("&app_id=${URLEncoder.encode(params.appId, "UTF-8")}") + append("&platform=${URLEncoder.encode(params.platform, "UTF-8")}") + append("&build_number=${URLEncoder.encode(params.versionCode.toString(), "UTF-8")}") + append("&build_version=${URLEncoder.encode(params.versionName, "UTF-8")}") + } + val url = URL(urlString) + + return try { + makeRequest(url, authToken) + } catch (e: IOException) { + options.logger.log(SentryLevel.ERROR, e, "Network error while checking for updates") + throw e + } + } + + private fun makeRequest(url: URL, authToken: String): HttpResponse { + val connection = url.openConnection() as HttpURLConnection + + try { + connection.requestMethod = "GET" + connection.setRequestProperty("Authorization", "Bearer $authToken") + connection.setRequestProperty("Accept", "application/json") + connection.setRequestProperty( + "User-Agent", + options.sentryClientName ?: throw IllegalStateException("sentryClientName must be set"), + ) + connection.connectTimeout = options.connectionTimeoutMillis + connection.readTimeout = options.readTimeoutMillis + + if (connection is HttpsURLConnection && options.sslSocketFactory != null) { + connection.sslSocketFactory = options.sslSocketFactory + } + + val responseCode = connection.responseCode + val responseBody = readResponse(connection) + + options.logger.log( + SentryLevel.DEBUG, + "Distribution API request completed with status: $responseCode", + ) + + return HttpResponse(responseCode, responseBody) + } finally { + connection.disconnect() + } + } + + private fun readResponse(connection: HttpURLConnection): String { + val inputStream = + if (connection.responseCode in 200..299) { + connection.inputStream + } else { + connection.errorStream ?: connection.inputStream + } + + return inputStream?.use { stream -> + BufferedReader(InputStreamReader(stream, "UTF-8")).use { reader -> reader.readText() } + } ?: "" + } +} diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt new file mode 100644 index 00000000000..f08522bb643 --- /dev/null +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt @@ -0,0 +1,147 @@ +package io.sentry.android.distribution + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import io.sentry.IDistributionApi +import io.sentry.IScopes +import io.sentry.Integration +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.UpdateInfo +import io.sentry.UpdateStatus +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import org.jetbrains.annotations.ApiStatus + +/** + * The public Android SDK for Sentry Build Distribution. + * + * Provides functionality to check for app updates and download new versions from Sentry's preprod + * artifacts system. + */ +@ApiStatus.Experimental +public class DistributionIntegration(context: Context) : Integration, IDistributionApi { + + private lateinit var scopes: IScopes + private lateinit var sentryOptions: SentryOptions + private val context: Context = context.applicationContext + + private lateinit var httpClient: DistributionHttpClient + private lateinit var responseParser: UpdateResponseParser + + /** + * Registers the Distribution integration with Sentry. + * + * @param scopes the Scopes + * @param options the options + */ + public override fun register(scopes: IScopes, options: SentryOptions) { + // Store scopes and options for use by distribution functionality + this.scopes = scopes + this.sentryOptions = options + + // Initialize HTTP client and response parser + this.httpClient = DistributionHttpClient(options) + this.responseParser = UpdateResponseParser(options) + } + + /** + * Check for available updates synchronously (blocking call). This method will block the calling + * thread while making the network request. Consider using checkForUpdate with callback for + * non-blocking behavior. + * + * @return UpdateStatus indicating if an update is available, up to date, or error + */ + public override fun checkForUpdateBlocking(): UpdateStatus { + return try { + sentryOptions.logger.log(SentryLevel.DEBUG, "Checking for distribution updates") + + val params = createUpdateCheckParams() + val response = httpClient.checkForUpdates(params) + responseParser.parseResponse(response.statusCode, response.body) + } catch (e: IllegalStateException) { + sentryOptions.logger.log(SentryLevel.WARNING, e.message ?: "Configuration error") + UpdateStatus.UpdateError(e.message ?: "Configuration error") + } catch (e: UnknownHostException) { + // UnknownHostException typically indicates no internet connection available + sentryOptions.logger.log( + SentryLevel.ERROR, + e, + "DNS lookup failed - check internet connection", + ) + UpdateStatus.NoNetwork("No internet connection or invalid server URL") + } catch (e: SocketTimeoutException) { + // SocketTimeoutException could indicate either slow network or server issues + sentryOptions.logger.log(SentryLevel.ERROR, e, "Network request timed out") + UpdateStatus.NoNetwork("Request timed out - check network connection") + } catch (e: Exception) { + sentryOptions.logger.log(SentryLevel.ERROR, e, "Unexpected error checking for updates") + UpdateStatus.UpdateError("Unexpected error: ${e.message}") + } + } + + /** + * Check for available updates asynchronously using a callback. + * + * @param onResult Callback that will be called with the UpdateStatus result + */ + public override fun checkForUpdate(onResult: IDistributionApi.UpdateCallback) { + // TODO implement this in a async way + val result = checkForUpdateBlocking() + onResult.onResult(result) + } + + /** + * Download and install the provided update by opening the download URL in the default browser or + * appropriate application. + * + * @param info Information about the update to download + */ + public override fun downloadUpdate(info: UpdateInfo) { + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(info.downloadUrl)) + browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + try { + context.startActivity(browserIntent) + } catch (e: android.content.ActivityNotFoundException) { + // No application can handle the HTTP/HTTPS URL, typically no browser installed + // Silently fail as this is expected behavior in some environments + } + } + + private fun createUpdateCheckParams(): DistributionHttpClient.UpdateCheckParams { + return try { + val packageManager = context.packageManager + val packageName = context.packageName + val packageInfo = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, 0) + } + + val versionName = packageInfo.versionName ?: "unknown" + val versionCode = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") packageInfo.versionCode.toLong() + } + val appId = context.applicationInfo.packageName + + DistributionHttpClient.UpdateCheckParams( + mainBinaryIdentifier = appId, + appId = appId, + platform = "android", + versionCode = versionCode, + versionName = versionName, + ) + } catch (e: PackageManager.NameNotFoundException) { + sentryOptions.logger.log(SentryLevel.ERROR, e, "Failed to get package info") + throw IllegalStateException("Unable to get app package information", e) + } + } +} diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt new file mode 100644 index 00000000000..a1396cae462 --- /dev/null +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt @@ -0,0 +1,82 @@ +package io.sentry.android.distribution + +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.UpdateInfo +import io.sentry.UpdateStatus +import org.json.JSONException +import org.json.JSONObject + +/** Parser for distribution API responses. */ +internal class UpdateResponseParser(private val options: SentryOptions) { + + /** + * Parses the API response and returns the appropriate UpdateStatus. + * + * @param statusCode HTTP status code + * @param responseBody Response body as string + * @return UpdateStatus indicating the result + */ + fun parseResponse(statusCode: Int, responseBody: String): UpdateStatus { + return when (statusCode) { + 200 -> parseSuccessResponse(responseBody) + in 400..499 -> UpdateStatus.UpdateError("Client error: $statusCode") + in 500..599 -> UpdateStatus.UpdateError("Server error: $statusCode") + else -> UpdateStatus.UpdateError("Unexpected response code: $statusCode") + } + } + + private fun parseSuccessResponse(responseBody: String): UpdateStatus { + return try { + val json = JSONObject(responseBody) + + options.logger.log(SentryLevel.DEBUG, "Parsing distribution API response") + + // Check if there's a new release available + val updateAvailable = json.optBoolean("updateAvailable", false) + + if (updateAvailable) { + val updateInfo = parseUpdateInfo(json) + UpdateStatus.NewRelease(updateInfo) + } else { + UpdateStatus.UpToDate.getInstance() + } + } catch (e: JSONException) { + options.logger.log(SentryLevel.ERROR, e, "Failed to parse API response") + UpdateStatus.UpdateError("Invalid response format: ${e.message}") + } catch (e: Exception) { + options.logger.log(SentryLevel.ERROR, e, "Unexpected error parsing response") + UpdateStatus.UpdateError("Failed to parse response: ${e.message}") + } + } + + private fun parseUpdateInfo(json: JSONObject): UpdateInfo { + val id = json.optString("id", "") + val buildVersion = json.optString("buildVersion", "") + val buildNumber = json.optInt("buildNumber", 0) + val downloadUrl = json.optString("downloadUrl", "") + val appName = json.optString("appName", "") + val createdDate = json.optString("createdDate", "") + + // Validate required fields (optString returns "null" for null values) + val missingFields = mutableListOf() + + if (id.isEmpty() || id == "null") { + missingFields.add("id") + } + if (buildVersion.isEmpty() || buildVersion == "null") { + missingFields.add("buildVersion") + } + if (downloadUrl.isEmpty() || downloadUrl == "null") { + missingFields.add("downloadUrl") + } + + if (missingFields.isNotEmpty()) { + throw IllegalArgumentException( + "Missing required fields in API response: ${missingFields.joinToString(", ")}" + ) + } + + return UpdateInfo(id, buildVersion, buildNumber, downloadUrl, appName, createdDate) + } +} diff --git a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/DistributionHttpClientTest.kt b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/DistributionHttpClientTest.kt new file mode 100644 index 00000000000..9d456ae6f7c --- /dev/null +++ b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/DistributionHttpClientTest.kt @@ -0,0 +1,54 @@ +package io.sentry.android.distribution + +import io.sentry.SentryOptions +import org.junit.Assert.* +import org.junit.Before +import org.junit.Ignore +import org.junit.Test + +class DistributionHttpClientTest { + + private lateinit var options: SentryOptions + private lateinit var httpClient: DistributionHttpClient + + @Before + fun setUp() { + options = + SentryOptions().apply { + connectionTimeoutMillis = 10000 + readTimeoutMillis = 10000 + } + + options.distribution.apply { + orgSlug = "sentry" + projectSlug = "launchpad-test" + orgAuthToken = "DONT_CHECK_THIS_IN" + sentryBaseUrl = "https://sentry.io" + } + + httpClient = DistributionHttpClient(options) + } + + @Test + @Ignore("This is just used for testing against the real API.") + fun `test checkForUpdates with real API`() { + val params = + DistributionHttpClient.UpdateCheckParams( + mainBinaryIdentifier = "com.emergetools.hackernews", + appId = "com.emergetools.hackernews", + versionName = "1.0.0", + versionCode = 5L, + ) + + val response = httpClient.checkForUpdates(params) + + // Print response for debugging + println("HTTP Status: ${response.statusCode}") + println("Response Body: ${response.body}") + println("Is Successful: ${response.isSuccessful}") + + // Basic assertions + assertTrue("Response should have a status code", response.statusCode > 0) + assertNotNull("Response body should not be null", response.body) + } +} 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 new file mode 100644 index 00000000000..1cefdfa7ac2 --- /dev/null +++ b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt @@ -0,0 +1,313 @@ +package io.sentry.android.distribution + +import io.sentry.SentryOptions +import io.sentry.UpdateStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UpdateResponseParserTest { + + private lateinit var options: SentryOptions + private lateinit var parser: UpdateResponseParser + + @Before + fun setUp() { + options = SentryOptions() + parser = UpdateResponseParser(options) + } + + @Test + fun `parseResponse returns NewRelease when update is available`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "update-123", + "buildVersion": "2.0.0", + "buildNumber": 42, + "downloadUrl": "https://example.com/download", + "appName": "Test App", + "createdDate": "2023-10-01T00:00:00Z" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return NewRelease", result is UpdateStatus.NewRelease) + val updateInfo = (result as UpdateStatus.NewRelease).info + assertEquals("update-123", updateInfo.id) + assertEquals("2.0.0", updateInfo.buildVersion) + assertEquals(42, updateInfo.buildNumber) + assertEquals("https://example.com/download", updateInfo.downloadUrl) + assertEquals("Test App", updateInfo.appName) + assertEquals("2023-10-01T00:00:00Z", updateInfo.createdDate) + } + + @Test + fun `parseResponse returns UpToDate when no update is available`() { + val responseBody = + """ + { + "updateAvailable": false + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpToDate", result is UpdateStatus.UpToDate) + } + + @Test + fun `parseResponse returns UpToDate when updateAvailable is missing`() { + val responseBody = + """ + { + "someOtherField": "value" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpToDate", result is UpdateStatus.UpToDate) + } + + @Test + fun `parseResponse returns UpdateError for 4xx status codes`() { + val result = parser.parseResponse(404, "Not found") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertEquals("Client error: 404", error.message) + } + + @Test + fun `parseResponse returns UpdateError for 5xx status codes`() { + val result = parser.parseResponse(500, "Internal server error") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertEquals("Server error: 500", error.message) + } + + @Test + fun `parseResponse returns UpdateError for unexpected status codes`() { + val result = parser.parseResponse(999, "Unknown status") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertEquals("Unexpected response code: 999", error.message) + } + + @Test + fun `parseResponse returns UpdateError for invalid JSON`() { + val result = parser.parseResponse(200, "invalid json {") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention invalid format", + error.message.startsWith("Invalid response format:"), + ) + } + + @Test + fun `parseResponse returns UpdateError when required fields are missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "buildVersion": "2.0.0" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention failed to parse", + error.message.startsWith("Failed to parse response:"), + ) + } + + @Test + fun `parseResponse handles minimal valid update response`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "update-123", + "buildVersion": "2.0.0", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return NewRelease", result is UpdateStatus.NewRelease) + val updateInfo = (result as UpdateStatus.NewRelease).info + assertEquals("update-123", updateInfo.id) + assertEquals("2.0.0", updateInfo.buildVersion) + assertEquals(0, updateInfo.buildNumber) // Default value + assertEquals("https://example.com/download", updateInfo.downloadUrl) + assertEquals("", updateInfo.appName) // Default value + assertEquals("", updateInfo.createdDate) // Default value + } + + @Test + fun `parseResponse handles empty response body`() { + val result = parser.parseResponse(200, "") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention invalid format", + error.message.startsWith("Invalid response format:"), + ) + } + + @Test + fun `parseResponse handles null values in JSON`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": null, + "buildVersion": "2.0.0", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention failed to parse", + error.message.startsWith("Failed to parse response:"), + ) + } + + @Test + fun `parseResponse returns specific error message when id is missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "buildVersion": "2.0.0", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention missing id field", + error.message.contains("Missing required fields in API response: id"), + ) + } + + @Test + fun `parseResponse returns specific error message when buildVersion is missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "update-123", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention missing buildVersion field", + error.message.contains("Missing required fields in API response: buildVersion"), + ) + } + + @Test + fun `parseResponse returns specific error message when downloadUrl is missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "update-123", + "buildVersion": "2.0.0" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention missing downloadUrl field", + error.message.contains("Missing required fields in API response: downloadUrl"), + ) + } + + @Test + fun `parseResponse returns specific error message when multiple fields are missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "buildNumber": 42 + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention all missing required fields", + error.message.contains( + "Missing required fields in API response: id, buildVersion, downloadUrl" + ), + ) + } + + @Test + fun `parseResponse returns specific error message when field is null string`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "null", + "buildVersion": "2.0.0", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention missing id field when value is 'null' string", + error.message.contains("Missing required fields in API response: id"), + ) + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt index 07c9cd391a3..d3a60d2c198 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt @@ -112,20 +112,7 @@ class SdkInitTests : BaseUiTest() { @Test fun doubleInitDoesNotWait() { - relayIdlingResource.increment() - // Let's make the first request timeout - relay.addTimeoutResponse() - - initSentry(true) { options: SentryAndroidOptions -> options.tracesSampleRate = 1.0 } - - Sentry.startTransaction("beforeRestart", "emptyTransaction").finish() - - // We want the SDK to start sending the event. If we don't wait, it's possible we don't send - // anything before the SDK is restarted - waitUntilIdle() - - relayIdlingResource.increment() - relayIdlingResource.increment() + initSentry(true) val beforeRestart = System.currentTimeMillis() // We restart the SDK. This shouldn't block the main thread, but new options (e.g. profiling) @@ -137,18 +124,14 @@ class SdkInitTests : BaseUiTest() { val afterRestart = System.currentTimeMillis() val restartMs = afterRestart - beforeRestart + relayIdlingResource.increment() Sentry.startTransaction("afterRestart", "emptyTransaction").finish() + // We assert for less than 1 second just to account for slow devices in saucelabs or headless // emulator assertTrue(restartMs < 1000, "Expected less than 1000 ms for SDK restart. Got $restartMs ms") relay.assert { - findEnvelope { assertEnvelopeTransaction(it.items.toList()).transaction == "beforeRestart" } - .assert { - it.assertTransaction() - // No profiling item, as in the first init it was not enabled - it.assertNoOtherItems() - } findEnvelope { assertEnvelopeTransaction(it.items.toList()).transaction == "afterRestart" } .assert { it.assertTransaction() 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 4c3e348153c..72d8b7f29fb 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 @@ -42,11 +42,11 @@ public class ReplayCache(private val options: SentryOptions, private val replayI private val isClosed = AtomicBoolean(false) private val encoderLock = AutoClosableReentrantLock() private val lock = AutoClosableReentrantLock() + private val framesLock = AutoClosableReentrantLock() private var encoder: SimpleVideoEncoder? = null internal val replayCacheDir: File? by lazy { makeReplayCacheDir(options, replayId) } - // TODO: maybe account for multi-threaded access internal val frames = mutableListOf() private val ongoingSegment = LinkedHashMap() @@ -98,9 +98,13 @@ public class ReplayCache(private val options: SentryOptions, private val replayI */ public fun addFrame(screenshot: File, frameTimestamp: Long, screen: String? = null) { val frame = ReplayFrame(screenshot, frameTimestamp, screen) - frames += frame + framesLock.acquire().use { frames += frame } } + /** Returns the timestamp of the first frame if available in a thread-safe manner. */ + internal fun firstFrameTimestamp(): Long? = + framesLock.acquire().use { frames.firstOrNull()?.timestamp } + /** * Creates a video out of currently stored [frames] given the start time and duration using the * on-device codecs [android.media.MediaCodec]. The generated video will be stored in [videoFile] @@ -134,7 +138,10 @@ public class ReplayCache(private val options: SentryOptions, private val replayI if (videoFile.exists() && videoFile.length() > 0) { videoFile.delete() } - if (frames.isEmpty()) { + // Work on a snapshot of frames to avoid races with writers + val framesSnapshot = + framesLock.acquire().use { if (frames.isEmpty()) mutableListOf() else frames.toMutableList() } + if (framesSnapshot.isEmpty()) { options.logger.log(DEBUG, "No captured frames, skipping generating a video segment") return null } @@ -156,9 +163,9 @@ public class ReplayCache(private val options: SentryOptions, private val replayI val step = 1000 / frameRate.toLong() var frameCount = 0 - var lastFrame: ReplayFrame? = frames.first() + var lastFrame: ReplayFrame? = framesSnapshot.firstOrNull() for (timestamp in from until (from + (duration)) step step) { - val iter = frames.iterator() + val iter = framesSnapshot.iterator() while (iter.hasNext()) { val frame = iter.next() if (frame.timestamp in (timestamp..timestamp + step)) { @@ -180,7 +187,8 @@ public class ReplayCache(private val options: SentryOptions, private val replayI // if we failed to encode the frame, we delete the screenshot right away as the // likelihood of it being able to be encoded later is low deleteFile(lastFrame.screenshot) - frames.remove(lastFrame) + framesLock.acquire().use { frames.remove(lastFrame) } + framesSnapshot.remove(lastFrame) lastFrame = null } } @@ -240,14 +248,16 @@ public class ReplayCache(private val options: SentryOptions, private val replayI */ internal fun rotate(until: Long): String? { var screen: String? = null - frames.removeAll { - if (it.timestamp < until) { - deleteFile(it.screenshot) - return@removeAll true - } else if (screen == null) { - screen = it.screen + framesLock.acquire().use { + frames.removeAll { + if (it.timestamp < until) { + deleteFile(it.screenshot) + return@removeAll true + } else if (screen == null) { + screen = it.screen + } + return@removeAll false } - return@removeAll false } return screen } 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 ae8f849f9f8..7e76f92aa7e 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 @@ -60,7 +60,7 @@ internal abstract class BaseCaptureStrategy( protected val isTerminating = AtomicBoolean(false) protected var cache: ReplayCache? = null - protected var recorderConfig: ScreenshotRecorderConfig? by + internal var recorderConfig: ScreenshotRecorderConfig? by persistableAtomicNullable(propertyName = "") { _, _, newValue -> if (newValue == null) { // recorderConfig is only nullable on init, but never after 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 bdb3be62237..706a958f3f8 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 @@ -146,6 +146,7 @@ internal class BufferCaptureStrategy( } // we hand over replayExecutor to the new strategy to preserve order of execution val captureStrategy = SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor) + captureStrategy.recorderConfig = recorderConfig captureStrategy.start( segmentId = currentSegment, replayId = currentReplayId, @@ -217,12 +218,10 @@ internal class BufferCaptureStrategy( val errorReplayDuration = options.sessionReplay.errorReplayDuration val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = - if (cache?.frames?.isNotEmpty() == true) { + cache?.firstFrameTimestamp()?.let { // in buffer mode we have to set the timestamp of the first frame as the actual start - DateUtils.getDateTime(cache!!.frames.first().timestamp) - } else { - DateUtils.getDateTime(now - errorReplayDuration) - } + DateUtils.getDateTime(it) + } ?: DateUtils.getDateTime(now - errorReplayDuration) val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId 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 6640cbf9faa..cc007d07067 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 @@ -98,7 +98,7 @@ internal class SessionCaptureStrategy( } if (currentConfig == null) { - options.logger.log(DEBUG, "Recorder config is not set, not recording frame") + options.logger.log(DEBUG, "Recorder config is not set, not capturing a segment") return@submitSafely } 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 452dd343fd1..257941a9114 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 @@ -22,6 +22,8 @@ import io.sentry.rrweb.RRWebInteractionEvent 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.atomic.AtomicReference import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -493,4 +495,106 @@ class ReplayCacheTest { assertTrue(replayCache.frames.isEmpty()) assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.extension == "jpg" }) } + + @Test + fun `firstFrameTimestamp returns first timestamp when available`() { + val replayCache = fixture.getSut(tmpDir) + + assertNull(replayCache.firstFrameTimestamp()) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 42) + replayCache.addFrame(bitmap, 1001) + + assertEquals(42L, replayCache.firstFrameTimestamp()) + } + + @Test + fun `firstFrameTimestamp is safe under concurrent rotate and add`() { + val replayCache = fixture.getSut(tmpDir) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + repeat(10) { i -> replayCache.addFrame(bitmap, (i + 1).toLong()) } + + val start = CountDownLatch(1) + val done = CountDownLatch(2) + val error = AtomicReference() + + val tReader = Thread { + try { + start.await() + repeat(500) { + replayCache.firstFrameTimestamp() + Thread.yield() + } + } catch (t: Throwable) { + error.set(t) + } finally { + done.countDown() + } + } + + val tWriter = Thread { + try { + start.await() + repeat(500) { i -> + if (i % 2 == 0) { + // delete all frames occasionally + replayCache.rotate(Long.MAX_VALUE) + } else { + // add a fresh frame + replayCache.addFrame(bitmap, System.currentTimeMillis()) + } + } + } catch (t: Throwable) { + error.set(t) + } finally { + done.countDown() + } + } + + tReader.start() + tWriter.start() + start.countDown() + done.await() + + // No crash is success + assertNull(error.get()) + } + + @Test + fun `createVideoOf tolerates concurrent rotate without crashing`() { + ReplayShadowMediaCodec.framesToEncode = 3 + val replayCache = fixture.getSut(tmpDir) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + // prepare a few frames that might be deleted during encoding + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 1001) + replayCache.addFrame(bitmap, 2001) + + val start = CountDownLatch(1) + val done = CountDownLatch(1) + val error = AtomicReference() + + val tEncoder = Thread { + try { + start.await() + replayCache.createVideoOf(3000L, 0L, 0, 100, 200, 1, 20_000) + } catch (t: Throwable) { + error.set(t) + } finally { + done.countDown() + } + } + + tEncoder.start() + start.countDown() + // rotate while encoding to simulate concurrent mutation + replayCache.rotate(Long.MAX_VALUE) + done.await() + + // No crash is success + assertNull(error.get()) + } } 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 2ef2f09890e..7b17c77a3b9 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 @@ -27,10 +27,12 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDI import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH +import io.sentry.android.replay.capture.BufferCaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.capture.SessionCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION import io.sentry.android.replay.gestures.GestureRecorder +import io.sentry.android.replay.util.ReplayShadowMediaCodec import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.tape.QueueFile import io.sentry.protocol.SentryException @@ -43,6 +45,7 @@ import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider import io.sentry.transport.RateLimiter +import io.sentry.util.Random import java.io.ByteArrayOutputStream import java.io.File import kotlin.test.BeforeTest @@ -63,13 +66,14 @@ import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.reset import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) -@Config(sdk = [26]) +@Config(sdk = [26], shadows = [ReplayShadowMediaCodec::class]) class ReplayIntegrationTest { @get:Rule val tmpDir = TemporaryFolder() @@ -726,6 +730,58 @@ class ReplayIntegrationTest { verify(recorder).resume() } + @Test + fun `continues recording after converting to session strategy without extra config change`() { + // Force buffer mode at start, but enable onError sample so captureReplay triggers + val recorder = mock() + val replay = + fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { isFullSession -> + // Always start with buffer strategy regardless of sampling + BufferCaptureStrategy( + fixture.options, + fixture.scopes, + // make time jump so session strategy will immediately cut a segment on next frame + ICurrentDateProvider { + System.currentTimeMillis() + fixture.options.sessionReplay.sessionSegmentDuration + }, + Random(), + // run tasks synchronously in tests + mock { + doAnswer { (it.arguments[0] as Runnable).run() } + .whenever(mock) + .submit(any()) + }, + ) { _ -> + fixture.replayCache + } + }, + ) + + fixture.options.sessionReplay.sessionSampleRate = 0.0 // ensure buffer mode initially + fixture.options.sessionReplay.onErrorSampleRate = 1.0 + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + replay.register(fixture.scopes, fixture.options) + replay.start() + + val config = ScreenshotRecorderConfig(100, 200, 1f, 1f, 1, 20_000) + replay.onConfigurationChanged(config) + + // Trigger convert() via captureReplay + replay.captureReplay(false) + + // Now, without invoking another config change, record a frame + // Reset interactions to assert only post-convert capture + reset(fixture.scopes) + replay.onScreenshotRecorded(mock()) + + // Should capture a session segment after conversion without additional config changes + verify(fixture.scopes).captureReplay(any(), any()) + } + @Test fun `closed replay cannot be started`() { val replay = fixture.getSut(context) 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 e0aa07a77c7..b3fb9058a95 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 @@ -254,6 +254,74 @@ class BufferCaptureStrategyTest { assertEquals(ReplayType.BUFFER, converted.replayType) } + @Test + fun `createCurrentSegment uses first frame timestamp when available`() { + val now = System.currentTimeMillis() + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + // Stub first frame timestamp and capture the 'from' argument to createVideoOf + whenever(fixture.replayCache.firstFrameTimestamp()).thenReturn(1234L) + + var capturedFrom: Long = -1 + whenever( + fixture.replayCache.createVideoOf( + anyLong(), + anyLong(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + any(), + ) + ) + .thenAnswer { invocation -> + capturedFrom = invocation.arguments[1] as Long + GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION) + } + + strategy.pause() + + assertEquals(1234L, capturedFrom) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `createCurrentSegment falls back to buffer start when no frames`() { + val now = System.currentTimeMillis() + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + // No frames available + whenever(fixture.replayCache.firstFrameTimestamp()).thenReturn(null) + + var capturedFrom: Long = -1 + whenever( + fixture.replayCache.createVideoOf( + anyLong(), + anyLong(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + any(), + ) + ) + .thenAnswer { invocation -> + capturedFrom = invocation.arguments[1] as Long + GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION) + } + + strategy.pause() + + assertEquals(now - fixture.options.sessionReplay.errorReplayDuration, capturedFrom) + assertEquals(1, strategy.currentSegment) + } + @Test fun `captureReplay does not replayId to scope when not sampled`() { val strategy = fixture.getSut(onErrorSampleRate = 0.0) diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index d9971397a2f..8819e0993d4 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + alias(libs.plugins.animalsniffer) } tasks.withType().configureEach { @@ -37,6 +38,9 @@ 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") } } @@ -57,6 +61,7 @@ tasks { check { dependsOn(jacocoTestCoverageVerification) dependsOn(jacocoTestReport) + dependsOn(animalsnifferMain) } } diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index 9b3baca9a38..8337eeb7b15 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -132,6 +132,9 @@ constructor( .toMutableList() .apply { add(HttpHeader(baggageHeader.name, baggageHeader.value)) } } + it.w3cTraceparentHeader?.let { w3cHeader -> + cleanedHeaders.add(HttpHeader(w3cHeader.name, w3cHeader.value)) + } } } diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt index 375479f4d98..8316f6c0f33 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt @@ -23,6 +23,7 @@ import io.sentry.SpanStatus import io.sentry.TraceContext import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext +import io.sentry.W3CTraceparentHeader import io.sentry.apollo3.SentryApollo3HttpInterceptor.BeforeSpanCallback import io.sentry.mockServerRequestTimeoutMillis import io.sentry.protocol.SdkVersion @@ -351,6 +352,26 @@ class SentryApollo3InterceptorTest { verify(fixture.scopes).span } + @Test + fun `adds W3C traceparent header when propagateTraceparent is enabled`() { + fixture.options.isPropagateTraceparent = true + executeQuery() + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[W3CTraceparentHeader.TRACEPARENT_HEADER]) + } + + @Test + fun `does not add W3C traceparent header when propagateTraceparent is disabled`() { + fixture.options.isPropagateTraceparent = false + executeQuery() + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recorderRequest.headers[W3CTraceparentHeader.TRACEPARENT_HEADER]) + } + private fun assertTransactionDetails( it: SentryTransaction, httpStatusCode: Int? = 200, diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index 931a646eb52..85ea2c3b52b 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + alias(libs.plugins.animalsniffer) } configure { @@ -44,6 +45,9 @@ 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") } } @@ -64,6 +68,7 @@ tasks { check { dependsOn(jacocoTestCoverageVerification) dependsOn(jacocoTestReport) + dependsOn(animalsnifferMain) } } diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index 0b9a29f1e33..fcf50564e5a 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -132,6 +132,9 @@ constructor( .toMutableList() .apply { add(HttpHeader(baggageHeader.name, baggageHeader.value)) } } + it.w3cTraceparentHeader?.let { w3cHeader -> + cleanedHeaders.add(HttpHeader(w3cHeader.name, w3cHeader.value)) + } } } diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt index a0bb7a2e154..d92cefe9772 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt @@ -26,6 +26,7 @@ import io.sentry.SpanStatus import io.sentry.TraceContext import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext +import io.sentry.W3CTraceparentHeader import io.sentry.apollo4.SentryApollo4HttpInterceptor.BeforeSpanCallback import io.sentry.apollo4.generated.LaunchDetailsQuery import io.sentry.mockServerRequestTimeoutMillis @@ -363,6 +364,26 @@ abstract class SentryApollo4HttpInterceptorTest( verify(fixture.scopes).span } + @Test + fun `adds W3C traceparent header when propagateTraceparent is enabled`() { + fixture.options.isPropagateTraceparent = true + executeQuery() + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[W3CTraceparentHeader.TRACEPARENT_HEADER]) + } + + @Test + fun `does not add W3C traceparent header when propagateTraceparent is disabled`() { + fixture.options.isPropagateTraceparent = false + executeQuery() + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recorderRequest.headers[W3CTraceparentHeader.TRACEPARENT_HEADER]) + } + private fun assertTransactionDetails( it: SentryTransaction, httpStatusCode: Int? = 200, diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index ce6ceb08bb6..909d52aa127 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + alias(libs.plugins.animalsniffer) } tasks.withType().configureEach { @@ -38,6 +39,9 @@ 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") } } @@ -58,6 +62,7 @@ tasks { check { dependsOn(jacocoTestCoverageVerification) dependsOn(jacocoTestReport) + dependsOn(animalsnifferMain) } } diff --git a/sentry-async-profiler/api/sentry-async-profiler.api b/sentry-async-profiler/api/sentry-async-profiler.api new file mode 100644 index 00000000000..6366fccc24b --- /dev/null +++ b/sentry-async-profiler/api/sentry-async-profiler.api @@ -0,0 +1,336 @@ +public final class io/sentry/asyncprofiler/BuildConfig { + public static final field SENTRY_ASYNC_PROFILER_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter : io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter { + public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments;Lio/sentry/SentryStackTraceFactory;Lio/sentry/ILogger;)V + public static fun convertFromFileStatic (Ljava/lang/String;)Lio/sentry/protocol/profiling/SentryProfile; +} + +public final class io/sentry/asyncprofiler/convert/NonAggregatingEventCollector : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector { + public fun ()V + public fun afterChunk ()V + public fun beforeChunk ()V + public fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)V + public fun finish ()Z + public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor;)V +} + +public final class io/sentry/asyncprofiler/profiling/JavaContinuousProfiler : io/sentry/IContinuousProfiler, io/sentry/transport/RateLimiter$IRateLimitObserver { + public fun (Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/ISentryExecutorService;)V + public fun close (Z)V + public fun getChunkId ()Lio/sentry/protocol/SentryId; + public fun getProfilerId ()Lio/sentry/protocol/SentryId; + public fun getRootSpanCounter ()I + public fun isRunning ()Z + public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V + public fun reevaluateSampling ()V + public fun startProfiler (Lio/sentry/ProfileLifecycle;Lio/sentry/TracesSampler;)V + public fun stopProfiler (Lio/sentry/ProfileLifecycle;)V +} + +public final class io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider : io/sentry/profiling/JavaContinuousProfilerProvider { + public fun ()V + public fun getContinuousProfiler (Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/ISentryExecutorService;)Lio/sentry/IContinuousProfiler; +} + +public final class io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider : io/sentry/profiling/JavaProfileConverterProvider { + public fun ()V + public fun getProfileConverter ()Lio/sentry/IProfileConverter; +} + +public final class io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider$AsyncProfilerProfileConverter : io/sentry/IProfileConverter { + public fun ()V + public fun convertFromFile (Ljava/lang/String;)Lio/sentry/protocol/profiling/SentryProfile; +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments { + public field alloc Z + public field bci Z + public field classify Z + public field cpu Z + public field dot Z + public field exclude Ljava/util/regex/Pattern; + public final field files Ljava/util/List; + public field from J + public field grain D + public field help Z + public field highlight Ljava/lang/String; + public field include Ljava/util/regex/Pattern; + public field inverted Z + public field leak Z + public field lines Z + public field live Z + public field lock Z + public field minwidth D + public field nativemem Z + public field norm Z + public field output Ljava/lang/String; + public field reverse Z + public field simple Z + public field skip I + public field state Ljava/lang/String; + public field threads Z + public field title Ljava/lang/String; + public field to J + public field total Z + public field wall Z + public fun ([Ljava/lang/String;)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Frame : java/util/HashMap { + public static final field TYPE_C1_COMPILED B + public static final field TYPE_CPP B + public static final field TYPE_INLINED B + public static final field TYPE_INTERPRETED B + public static final field TYPE_JIT_COMPILED B + public static final field TYPE_KERNEL B + public static final field TYPE_NATIVE B +} + +public abstract class io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter { + protected final field args Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments; + protected final field collector Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector; + protected final field jfr Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader; + protected field methodNames Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments;)V + protected fun collectEvents ()V + public fun convert ()V + protected fun convertChunk ()V + protected fun createCollector (Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments;)Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector; + public synthetic fun getCategory (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace;)Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Classifier$Category; + public fun getClassName (J)Ljava/lang/String; + public fun getMethodName (JB)Ljava/lang/String; + public fun getPlainThreadName (I)Ljava/lang/String; + public fun getStackTraceElement (JBI)Ljava/lang/StackTraceElement; + public fun getThreadName (I)Ljava/lang/String; + protected fun getThreadStates (Z)Ljava/util/BitSet; + protected fun isNativeFrame (B)Z + protected fun toThreadState (Ljava/lang/String;)I + protected fun toTicks (J)J +} + +protected abstract class io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter$AggregatedEventVisitor : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor { + protected fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter;)V + protected abstract fun visit (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;J)V + public final fun visit (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;JJ)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/ClassRef { + public final field name J + public fun (J)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary { + public fun ()V + public fun (I)V + public fun clear ()V + public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary$Visitor;)V + public fun get (J)Ljava/lang/Object; + public fun preallocate (I)I + public fun put (JLjava/lang/Object;)V + public fun size ()I +} + +public abstract interface class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary$Visitor { + public abstract fun visit (JLjava/lang/Object;)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt { + public fun ()V + public fun (I)V + public fun clear ()V + public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt$Visitor;)V + public fun get (J)I + public fun get (JI)I + public fun preallocate (I)I + public fun put (JI)V +} + +public abstract interface class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt$Visitor { + public abstract fun visit (JI)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrClass { + public fun field (Ljava/lang/String;)Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField; +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField { +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader : java/io/Closeable { + public field chunkEndNanos J + public field chunkStartNanos J + public field chunkStartTicks J + public final field classes Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public field endNanos J + public final field enums Ljava/util/Map; + public final field javaThreads Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public final field methods Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public final field settings Ljava/util/Map; + public final field stackTraces Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public field startNanos J + public field startTicks J + public field stopAtNewChunk Z + public final field strings Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public final field symbols Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public final field threads Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public field ticksPerSec J + public final field types Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public final field typesByName Ljava/util/Map; + public fun (Ljava/lang/String;)V + public fun (Ljava/nio/ByteBuffer;)V + public fun close ()V + public fun durationNanos ()J + public fun eof ()Z + public fun getBytes ()[B + public fun getDouble ()D + public fun getEnumKey (Ljava/lang/String;Ljava/lang/String;)I + public fun getEnumValue (Ljava/lang/String;I)Ljava/lang/String; + public fun getFloat ()F + public fun getString ()Ljava/lang/String; + public fun getVarint ()I + public fun getVarlong ()J + public fun hasMoreChunks ()Z + public fun incomplete ()Z + public fun readAllEvents ()Ljava/util/List; + public fun readAllEvents (Ljava/lang/Class;)Ljava/util/List; + public fun readEvent ()Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event; + public fun readEvent (Ljava/lang/Class;)Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event; + public fun registerEvent (Ljava/lang/String;Ljava/lang/Class;)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/MethodRef { + public final field cls J + public final field name J + public final field sig J + public fun (JJJ)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace { + public final field locations [I + public final field methods [J + public final field types [B + public fun ([J[B[I)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/AllocationSample : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field allocationSize J + public final field classId I + public final field tlabSize J + public fun (JIIIJJ)V + public fun classId ()J + public fun hashCode ()I + public fun sameGroup (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)Z + public fun value ()J +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/CPULoad : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field jvmSystem F + public final field jvmUser F + public final field machineTotal F + public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ContendedLock : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field classId I + public final field duration J + public fun (JIIJI)V + public fun classId ()J + public fun hashCode ()I + public fun sameGroup (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)Z + public fun value ()J +} + +public abstract class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event : java/lang/Comparable { + public final field stackTraceId I + public final field tid I + public final field time J + protected fun (JII)V + public fun classId ()J + public fun compareTo (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)I + public synthetic fun compareTo (Ljava/lang/Object;)I + public fun hashCode ()I + public fun sameGroup (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)Z + public fun samples ()J + public fun toString ()Ljava/lang/String; + public fun value ()J +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventAggregator : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector { + public fun (ZD)V + public fun afterChunk ()V + public fun beforeChunk ()V + public fun coarsen (D)V + public fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)V + public fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;JJ)V + public fun finish ()Z + public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor;)V + public fun size ()I +} + +public abstract interface class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector { + public abstract fun afterChunk ()V + public abstract fun beforeChunk ()V + public abstract fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)V + public abstract fun finish ()Z + public abstract fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor;)V +} + +public abstract interface class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor { + public abstract fun visit (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;JJ)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ExecutionSample : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field samples I + public final field threadState I + public fun (JIIII)V + public fun samples ()J + public fun value ()J +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/GCHeapSummary : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field afterGC Z + public final field committed J + public final field gcId I + public final field reserved J + public final field used J + public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/LiveObject : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field allocationSize J + public final field allocationTime J + public final field classId I + public fun (JIIIJJ)V + public fun classId ()J + public fun hashCode ()I + public fun sameGroup (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)Z + public fun value ()J +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocEvent : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field address J + public final field size J + public fun (JIIJJ)V + public fun value ()J +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocLeakAggregator : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector { + public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector;)V + public fun afterChunk ()V + public fun beforeChunk ()V + public fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)V + public fun finish ()Z + public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor;)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ObjectCount : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field classId I + public final field count J + public final field gcId I + public final field totalSize J + public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;)V +} + diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts new file mode 100644 index 00000000000..5b78d5b99e4 --- /dev/null +++ b/sentry-async-profiler/build.gradle.kts @@ -0,0 +1,89 @@ +import net.ltgt.gradle.errorprone.errorprone +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) +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + +kotlin { explicitApi() } + +dependencies { + api(projects.sentry) + + implementation("tools.profiler:async-profiler:3.0") + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(projects.sentryTestSupport) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + 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) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.asyncprofiler") + buildConfigField( + "String", + "SENTRY_ASYNC_PROFILER_SDK_NAME", + "\"${Config.Sentry.SENTRY_ASYNC_PROFILER_SDK_NAME}\"", + ) + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_ASYNC_PROFILER_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-async-profiler", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} 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 new file mode 100644 index 00000000000..f6db9a86ab3 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -0,0 +1,266 @@ +package io.sentry.asyncprofiler.convert; + +import io.sentry.DateUtils; +import io.sentry.ILogger; +import io.sentry.Sentry; +import io.sentry.SentryLevel; +import io.sentry.SentryStackTraceFactory; +import io.sentry.asyncprofiler.vendor.asyncprofiler.convert.Arguments; +import io.sentry.asyncprofiler.vendor.asyncprofiler.convert.JfrConverter; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.StackTrace; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.Event; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.EventCollector; +import io.sentry.protocol.SentryStackFrame; +import io.sentry.protocol.profiling.SentryProfile; +import io.sentry.protocol.profiling.SentrySample; +import io.sentry.protocol.profiling.SentryThreadMetadata; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class JfrAsyncProfilerToSentryProfileConverter extends JfrConverter { + private static final double NANOS_PER_SECOND = 1_000_000_000.0; + private static final long UNKNOWN_THREAD_ID = -1; + + private final @NotNull SentryProfile sentryProfile = new SentryProfile(); + private final @NotNull SentryStackTraceFactory stackTraceFactory; + private final @NotNull ILogger logger; + private final @NotNull Map frameDeduplicationMap = new HashMap<>(); + private final @NotNull Map, Integer> stackDeduplicationMap = new HashMap<>(); + + public JfrAsyncProfilerToSentryProfileConverter( + JfrReader jfr, + Arguments args, + @NotNull SentryStackTraceFactory stackTraceFactory, + @NotNull ILogger logger) { + super(jfr, args); + this.stackTraceFactory = stackTraceFactory; + this.logger = logger; + } + + @Override + protected void convertChunk() { + collector.forEach(new ProfileEventVisitor(sentryProfile, stackTraceFactory, jfr, args)); + } + + @Override + protected EventCollector createCollector(Arguments args) { + return new NonAggregatingEventCollector(); + } + + public static @NotNull SentryProfile convertFromFileStatic(@NotNull String jfrFilePath) + throws IOException { + JfrAsyncProfilerToSentryProfileConverter converter; + try (JfrReader jfrReader = new JfrReader(jfrFilePath)) { + Arguments args = new Arguments(); + args.cpu = false; + args.wall = true; + args.alloc = false; + args.threads = true; + args.lines = true; + args.dot = true; + + SentryStackTraceFactory stackTraceFactory = + new SentryStackTraceFactory(Sentry.getGlobalScope().getOptions()); + ILogger logger = Sentry.getGlobalScope().getOptions().getLogger(); + converter = + new JfrAsyncProfilerToSentryProfileConverter(jfrReader, args, stackTraceFactory, logger); + converter.convert(); + } + + return converter.sentryProfile; + } + + private class ProfileEventVisitor implements EventCollector.Visitor { + private final @NotNull SentryProfile sentryProfile; + private final @NotNull SentryStackTraceFactory stackTraceFactory; + private final @NotNull JfrReader jfr; + private final @NotNull Arguments args; + private final double ticksPerNanosecond; + + public ProfileEventVisitor( + @NotNull SentryProfile sentryProfile, + @NotNull SentryStackTraceFactory stackTraceFactory, + @NotNull JfrReader jfr, + @NotNull Arguments args) { + this.sentryProfile = sentryProfile; + this.stackTraceFactory = stackTraceFactory; + this.jfr = jfr; + this.args = args; + ticksPerNanosecond = jfr.ticksPerSec / NANOS_PER_SECOND; + } + + @Override + public void visit(Event event, long samples, long value) { + try { + StackTrace stackTrace = jfr.stackTraces.get(event.stackTraceId); + long threadId = resolveThreadId(event.tid); + + if (stackTrace != null) { + if (args.threads) { + processThreadMetadata(event, threadId); + } + + processSampleWithStack(event, threadId, stackTrace); + } + } catch (Exception e) { + logger.log(SentryLevel.WARNING, "Failed to process JFR event " + event, e); + } + } + + private long resolveThreadId(int eventId) { + Long javaThreadId = jfr.javaThreads.get(eventId); + return javaThreadId != null ? javaThreadId : UNKNOWN_THREAD_ID; + } + + private void processThreadMetadata(Event event, long threadId) { + if (threadId == UNKNOWN_THREAD_ID) { + return; + } + + final String threadName = getPlainThreadName(event.tid); + sentryProfile + .getThreadMetadata() + .computeIfAbsent( + String.valueOf(threadId), + k -> { + SentryThreadMetadata metadata = new SentryThreadMetadata(); + metadata.setName(threadName); + metadata.setPriority(0); // Default priority + return metadata; + }); + } + + private void processSampleWithStack(Event event, long threadId, StackTrace stackTrace) { + int stackIndex = addStackTrace(stackTrace); + + SentrySample sample = new SentrySample(); + sample.setTimestamp(calculateTimestamp(event)); + sample.setThreadId(String.valueOf(threadId)); + sample.setStackId(stackIndex); + + sentryProfile.getSamples().add(sample); + } + + private double calculateTimestamp(Event event) { + long nanosFromStart = (long) ((event.time - jfr.chunkStartTicks) / ticksPerNanosecond); + + long timeNs = jfr.chunkStartNanos + nanosFromStart; + + return DateUtils.nanosToSeconds(timeNs); + } + + private int addStackTrace(StackTrace stackTrace) { + List callStack = createFramesAndCallStack(stackTrace); + + Integer existingIndex = stackDeduplicationMap.get(callStack); + if (existingIndex != null) { + return existingIndex; + } + + int stackIndex = sentryProfile.getStacks().size(); + sentryProfile.getStacks().add(callStack); + stackDeduplicationMap.put(callStack, stackIndex); + return stackIndex; + } + + private List createFramesAndCallStack(StackTrace stackTrace) { + List callStack = new ArrayList<>(); + + long[] methods = stackTrace.methods; + byte[] types = stackTrace.types; + int[] locations = stackTrace.locations; + + for (int i = 0; i < methods.length; i++) { + StackTraceElement element = getStackTraceElement(methods[i], types[i], locations[i]); + if (element.isNativeMethod() || isNativeFrame(types[i])) { + continue; + } + + SentryStackFrame frame = createStackFrame(element); + int frameIndex = getOrAddFrame(frame); + callStack.add(frameIndex); + } + + return callStack; + } + + // Get existing frame index or add new frame and return its index + private int getOrAddFrame(SentryStackFrame frame) { + Integer existingIndex = frameDeduplicationMap.get(frame); + + if (existingIndex != null) { + return existingIndex; + } + + int newIndex = sentryProfile.getFrames().size(); + sentryProfile.getFrames().add(frame); + frameDeduplicationMap.put(frame, newIndex); + return newIndex; + } + + private SentryStackFrame createStackFrame(StackTraceElement element) { + SentryStackFrame frame = new SentryStackFrame(); + final String classNameWithLambdas = element.getClassName().replace("/", "."); + frame.setFunction(element.getMethodName()); + + String sanitizedClassName = extractSanitizedClassName(classNameWithLambdas); + frame.setModule(extractModuleName(sanitizedClassName, classNameWithLambdas)); + + if (shouldMarkAsSystemFrame(element, classNameWithLambdas)) { + frame.setInApp(false); + } else { + frame.setInApp(stackTraceFactory.isInApp(sanitizedClassName)); + } + + frame.setLineno(extractLineNumber(element)); + frame.setFilename(classNameWithLambdas); + + return frame; + } + + // Remove lambda suffix from class name + private String extractSanitizedClassName(String classNameWithLambdas) { + int firstDollar = classNameWithLambdas.indexOf('$'); + if (firstDollar != -1) { + return classNameWithLambdas.substring(0, firstDollar); + } + return classNameWithLambdas; + } + + // TODO: test difference between null and empty string for module + private @Nullable String extractModuleName( + String sanitizedClassName, String classNameWithLambdas) { + if (hasPackageStructure(sanitizedClassName)) { + return sanitizedClassName; + } else if (isRegularClassWithoutPackage(classNameWithLambdas)) { + return ""; + } else { + return null; + } + } + + private boolean hasPackageStructure(String className) { + return className.lastIndexOf('.') > 0; + } + + private boolean isRegularClassWithoutPackage(String className) { + return !className.startsWith("["); + } + + private boolean shouldMarkAsSystemFrame(StackTraceElement element, String className) { + return element.isNativeMethod() || className.isEmpty(); + } + + private @Nullable Integer extractLineNumber(StackTraceElement element) { + return element.getLineNumber() != 0 ? element.getLineNumber() : null; + } + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/NonAggregatingEventCollector.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/NonAggregatingEventCollector.java new file mode 100644 index 00000000000..c39259b7799 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/NonAggregatingEventCollector.java @@ -0,0 +1,39 @@ +package io.sentry.asyncprofiler.convert; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.Event; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.EventCollector; +import java.util.ArrayList; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class NonAggregatingEventCollector implements EventCollector { + final List events = new ArrayList<>(); + + @Override + public void collect(Event e) { + events.add(e); + } + + @Override + public void beforeChunk() { + // No-op + } + + @Override + public void afterChunk() { + // No-op + } + + @Override + public boolean finish() { + return false; + } + + @Override + public void forEach(Visitor visitor) { + for (Event event : events) { + visitor.visit(event, event.samples(), event.value()); + } + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java new file mode 100644 index 00000000000..7af3032f9ae --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java @@ -0,0 +1,451 @@ +package io.sentry.asyncprofiler.profiling; + +import static io.sentry.DataCategory.All; +import static java.util.concurrent.TimeUnit.SECONDS; + +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.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.SentryUUID; +import io.sentry.TracesSampler; +import io.sentry.protocol.SentryId; +import io.sentry.transport.RateLimiter; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.SentryRandom; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import one.profiler.AsyncProfiler; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; + +@ApiStatus.Internal +public final class JavaContinuousProfiler + implements IContinuousProfiler, RateLimiter.IRateLimitObserver { + private static final long MAX_CHUNK_DURATION_MILLIS = 10000; + private final List invalidFilenameChars = + Arrays.asList( + ".", ",", ":", ";", "|", "\\", "=", "%", "&", "[", "]", "(", ")", "<", ">", "{", "}", "!", + "*", "?", "~", "\"", "'", "$", "`", "^"); + + private final @NotNull ILogger logger; + private final @Nullable String profilingTracesDirPath; + private final int profilingTracesHz; + private final @NotNull ISentryExecutorService executorService; + private boolean isInitialized = false; + private boolean isRunning = false; + private @Nullable IScopes scopes; + private @Nullable Future stopFuture; + private final @NotNull List payloadBuilders = new ArrayList<>(); + private @NotNull SentryId profilerId = SentryId.EMPTY_ID; + private final @NotNull AtomicBoolean isClosed = new AtomicBoolean(false); + private @NotNull SentryDate startProfileChunkTimestamp = new SentryNanotimeDate(); + + private volatile @NotNull String filename = ""; + + private @NotNull AsyncProfiler profiler; + private volatile boolean shouldSample = true; + private boolean isSampled = false; + private int rootSpanCounter = 0; + + private final AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private final AutoClosableReentrantLock payloadLock = new AutoClosableReentrantLock(); + + public JavaContinuousProfiler( + final @NotNull ILogger logger, + final @Nullable String profilingTracesDirPath, + final int profilingTracesHz, + final @NotNull ISentryExecutorService executorService) + throws Exception { + this.logger = logger; + this.profilingTracesDirPath = profilingTracesDirPath; + this.profilingTracesHz = profilingTracesHz; + this.executorService = executorService; + initializeProfiler(); + } + + private void initializeProfiler() throws Exception { + this.profiler = AsyncProfiler.getInstance(); + // Check version to verify profiler is working + String version = profiler.execute("version"); + logger.log(SentryLevel.DEBUG, "AsyncProfiler initialized successfully. Version: " + version); + } + + private boolean init() { + if (isInitialized) { + return true; + } + isInitialized = true; + + if (isInvalidDirectory()) { + return false; + } + + if (profilingTracesHz <= 0) { + logger.log( + SentryLevel.WARNING, + "Disabling profiling because trace rate is set to %d", + profilingTracesHz); + return false; + } + return true; + } + + private boolean isInvalidDirectory() { + if (profilingTracesDirPath == null) { + logger.log( + SentryLevel.WARNING, + "Disabling profiling because no profiling traces dir path is defined in options."); + return true; + } + + for (String invalidChar : invalidFilenameChars) { + if (profilingTracesDirPath.contains(invalidChar)) { + logger.log( + SentryLevel.WARNING, + "Disabling profiling because traces directory path contains invalid character: %s", + invalidChar); + return true; + } + } + + File profileDir = new File(profilingTracesDirPath); + + if (!profileDir.canWrite() || !profileDir.exists()) { + logger.log( + SentryLevel.WARNING, + "Disabling profiling because traces directory is not writable or does not exist: %s (writable=%b, exists=%b)", + profilingTracesDirPath, + profileDir.canWrite(), + profileDir.exists()); + return true; + } + return false; + } + + @Override + public void startProfiler( + final @NotNull ProfileLifecycle profileLifecycle, + final @NotNull TracesSampler tracesSampler) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (shouldSample) { + isSampled = tracesSampler.sampleSessionProfile(SentryRandom.current().nextDouble()); + shouldSample = false; + } + if (!isSampled) { + logger.log(SentryLevel.DEBUG, "Profiler was not started due to sampling decision."); + return; + } + + switch (profileLifecycle) { + case TRACE: + // rootSpanCounter should never be negative, unless the user changed profile lifecycle + // while + // the profiler is running or close() is called. This is just a safety check. + if (rootSpanCounter < 0) { + rootSpanCounter = 0; + } + rootSpanCounter++; + break; + case MANUAL: + // We check if the profiler is already running and log a message only in manual mode, + // since + // in trace mode we can have multiple concurrent traces + if (isRunning()) { + logger.log(SentryLevel.DEBUG, "Profiler is already running."); + return; + } + break; + } + + if (!isRunning()) { + logger.log(SentryLevel.DEBUG, "Started Profiler."); + start(); + } + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Error starting profiler: ", e); + } + } + + private void initScopes() { + if ((scopes == null || scopes == NoOpScopes.getInstance()) + && Sentry.getCurrentScopes() != NoOpScopes.getInstance()) { + this.scopes = Sentry.getCurrentScopes(); + final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter(); + if (rateLimiter != null) { + rateLimiter.addRateLimitObserver(this); + } + } + } + + @SuppressWarnings("ReferenceEquality") + private void start() { + initScopes(); + + if (!init()) { + return; + } + + if (scopes != null) { + final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter(); + if (rateLimiter != null + && (rateLimiter.isActiveForCategory(All) + || rateLimiter.isActiveForCategory(DataCategory.ProfileChunk))) { + logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler."); + // Let's stop and reset profiler id, as the profile is now broken anyway + stop(false); + return; + } + + startProfileChunkTimestamp = scopes.getOptions().getDateProvider().now(); + } else { + startProfileChunkTimestamp = new SentryNanotimeDate(); + } + + filename = profilingTracesDirPath + File.separator + SentryUUID.generateSentryId() + ".jfr"; + + File jfrFile = new File(filename); + + try { + final String profilingIntervalMicros = + String.format("%dus", (int) SECONDS.toMicros(1) / profilingTracesHz); + // Example command: start,jfr,event=wall,interval=9900us,file=/path/to/trace.jfr + final String command = + String.format( + "start,jfr,event=wall,interval=%s,file=%s", profilingIntervalMicros, filename); + + profiler.execute(command); + + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Failed to start profiling: ", e); + filename = ""; + // Try to clean up the file if it was created + safelyRemoveFile(jfrFile); + return; + } + + isRunning = true; + + if (profilerId == SentryId.EMPTY_ID) { + profilerId = new SentryId(); + } + + try { + stopFuture = executorService.schedule(() -> stop(true), MAX_CHUNK_DURATION_MILLIS); + } catch (RejectedExecutionException e) { + logger.log( + SentryLevel.ERROR, + "Failed to schedule profiling chunk finish. Did you call Sentry.close()?", + e); + // If we can't schedule the auto-stop, stop immediately without restart + stop(false); + } + } + + @Override + public void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + switch (profileLifecycle) { + case TRACE: + rootSpanCounter--; + // If there are active spans, and profile lifecycle is trace, we don't stop the profiler + if (rootSpanCounter > 0) { + return; + } + // rootSpanCounter should never be negative, unless the user changed profile lifecycle + // while the profiler is running or close() is called. This is just a safety check. + if (rootSpanCounter < 0) { + rootSpanCounter = 0; + } + // Stop immediately without restart + stop(false); + break; + case MANUAL: + // Stop immediately without restart + stop(false); + break; + } + } + } + + private void stop(final boolean restartProfiler) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (stopFuture != null) { + stopFuture.cancel(true); + } + // check if profiler was created and it's running + if (!isRunning) { + // When the profiler is stopped due to an error (e.g. offline or rate limited), reset the + // id + profilerId = SentryId.EMPTY_ID; + return; + } + + File jfrFile = new File(filename); + + try { + profiler.execute("stop,jfr"); + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Error stopping profiler, attempting cleanup: ", e); + // Clean up file if it exists + safelyRemoveFile(jfrFile); + } + + // The scopes can be null if the profiler is started before the SDK is initialized (app + // start profiling), meaning there's no scopes to send the chunks. In that case, we store + // the data in a list and send it when the next chunk is finished. + if (jfrFile.exists() && jfrFile.canRead() && jfrFile.length() > 0) { + try (final @NotNull ISentryLifecycleToken ignored2 = payloadLock.acquire()) { + jfrFile.deleteOnExit(); + payloadBuilders.add( + new ProfileChunk.Builder( + profilerId, + new SentryId(), + new HashMap<>(), + jfrFile, + startProfileChunkTimestamp, + ProfileChunk.PLATFORM_JAVA)); + } + } else { + logger.log( + SentryLevel.WARNING, + "JFR file is invalid or empty: exists=%b, readable=%b, size=%d", + jfrFile.exists(), + jfrFile.canRead(), + jfrFile.length()); + safelyRemoveFile(jfrFile); + } + + // Always clean up state, even if stop failed + isRunning = false; + filename = ""; + + if (scopes != null) { + sendChunks(scopes, scopes.getOptions()); + } + + if (restartProfiler) { + logger.log(SentryLevel.DEBUG, "Profile chunk finished. Starting a new one."); + start(); + } else { + // When the profiler is stopped manually, we have to reset its id + profilerId = SentryId.EMPTY_ID; + logger.log(SentryLevel.DEBUG, "Profile chunk finished."); + } + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Error stopping profiler: ", e); + } + } + + @Override + public void reevaluateSampling() { + shouldSample = true; + } + + @Override + public void close(final boolean isTerminating) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + rootSpanCounter = 0; + stop(false); + if (isTerminating) { + isClosed.set(true); + } + } + } + + @Override + public @NotNull SentryId getProfilerId() { + return profilerId; + } + + // This is currently not used in the JVM profiler, thus we return an empty id for now + @Override + public @NotNull SentryId getChunkId() { + return SentryId.EMPTY_ID; + } + + @SuppressWarnings("FutureReturnValueIgnored") + private void sendChunks(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + try { + options + .getExecutorService() + .submit( + () -> { + // SDK is closed, we don't send the chunks + if (isClosed.get()) { + return; + } + final ArrayList payloads = new ArrayList<>(payloadBuilders.size()); + try (final @NotNull ISentryLifecycleToken ignored = payloadLock.acquire()) { + for (ProfileChunk.Builder builder : payloadBuilders) { + payloads.add(builder.build(options)); + } + payloadBuilders.clear(); + } + for (ProfileChunk payload : payloads) { + scopes.captureProfileChunk(payload); + } + }); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to send profile chunks.", e); + } + } + + private void safelyRemoveFile(File file) { + try { + if (file.exists()) { + file.delete(); + } + } catch (Exception e) { + logger.log(SentryLevel.INFO, "Failed to remove jfr file %s.", file.getAbsolutePath(), e); + } + } + + @Override + public boolean isRunning() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return isRunning && !filename.isEmpty(); + } + } + + @VisibleForTesting + @Nullable + Future getStopFuture() { + return stopFuture; + } + + @VisibleForTesting + public int getRootSpanCounter() { + return rootSpanCounter; + } + + @Override + public void onRateLimitChanged(@NotNull RateLimiter rateLimiter) { + // We stop the profiler as soon as we are rate limited, to avoid the performance overhead + if (rateLimiter.isActiveForCategory(All) + || rateLimiter.isActiveForCategory(DataCategory.ProfileChunk)) { + logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler."); + stop(false); + } + // If we are not rate limited anymore, we don't do anything: the profile is broken, so it's + // useless to restart it automatically + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java new file mode 100644 index 00000000000..de2ba455bc8 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java @@ -0,0 +1,40 @@ +package io.sentry.asyncprofiler.provider; + +import io.sentry.IContinuousProfiler; +import io.sentry.ILogger; +import io.sentry.ISentryExecutorService; +import io.sentry.NoOpContinuousProfiler; +import io.sentry.SentryLevel; +import io.sentry.asyncprofiler.profiling.JavaContinuousProfiler; +import io.sentry.profiling.JavaContinuousProfilerProvider; +import io.sentry.profiling.JavaProfileConverterProvider; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * AsyncProfiler implementation of {@link JavaContinuousProfilerProvider} and {@link + * JavaProfileConverterProvider}. This provider integrates AsyncProfiler with Sentry's continuous + * profiling system and provides profile conversion functionality. + */ +@ApiStatus.Internal +public final class AsyncProfilerContinuousProfilerProvider + implements JavaContinuousProfilerProvider { + + @Override + public @NotNull IContinuousProfiler getContinuousProfiler( + ILogger logger, + String profilingTracesDirPath, + int profilingTracesHz, + ISentryExecutorService executorService) { + try { + return new JavaContinuousProfiler( + logger, profilingTracesDirPath, profilingTracesHz, executorService); + } catch (Exception e) { + logger.log( + SentryLevel.WARNING, + "Failed to initialize AsyncProfiler. Profiling will be disabled.", + e); + return NoOpContinuousProfiler.getInstance(); + } + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider.java new file mode 100644 index 00000000000..b8aa9111fae --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider.java @@ -0,0 +1,34 @@ +package io.sentry.asyncprofiler.provider; + +import io.sentry.IProfileConverter; +import io.sentry.asyncprofiler.convert.JfrAsyncProfilerToSentryProfileConverter; +import io.sentry.profiling.JavaProfileConverterProvider; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * AsyncProfiler implementation of {@link JavaProfileConverterProvider}. This provider integrates + * AsyncProfiler's JFR converter with Sentry's profiling system. + */ +@ApiStatus.Internal +public final class AsyncProfilerProfileConverterProvider implements JavaProfileConverterProvider { + + @Override + public @Nullable IProfileConverter getProfileConverter() { + return new AsyncProfilerProfileConverter(); + } + + /** + * Internal implementation of IProfileConverter that delegates to + * JfrAsyncProfilerToSentryProfileConverter. + */ + public static final class AsyncProfilerProfileConverter implements IProfileConverter { + + @Override + public @NotNull io.sentry.protocol.profiling.SentryProfile convertFromFile( + @NotNull String jfrFilePath) throws java.io.IOException { + return JfrAsyncProfilerToSentryProfileConverter.convertFromFileStatic(jfrFilePath); + } + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/LICENSE b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/LICENSE new file mode 100644 index 00000000000..8dada3edaf5 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/README.md b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/README.md new file mode 100644 index 00000000000..733a69f1c3b --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/README.md @@ -0,0 +1,4 @@ +# Vendored AsyncProfiler code for converting JFR Files +- Vendored-in from commit https://github.com/async-profiler/async-profiler/tree/fe1bc66d4b6181413847f6bbe5c0db805f3e9194 +- Only the code related to JFR conversion is included. +- The `AsyncProfiler` itself is included as a dependency in the Maven project. diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments.java new file mode 100644 index 00000000000..f3b44fccc09 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments.java @@ -0,0 +1,132 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.convert; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.regex.Pattern; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class Arguments { + public @NotNull String title = "Flame Graph"; + public @Nullable String highlight; + public @Nullable String output; + public @Nullable String state; + public @Nullable Pattern include; + public @Nullable Pattern exclude; + public double minwidth; + public double grain; + public int skip; + public boolean help; + public boolean reverse; + public boolean inverted; + public boolean cpu; + public boolean wall; + public boolean alloc; + public boolean nativemem; + public boolean leak; + public boolean live; + public boolean lock; + public boolean threads; + public boolean classify; + public boolean total; + public boolean lines; + public boolean bci; + public boolean simple; + public boolean norm; + public boolean dot; + public long from; + public long to; + public final List files = new ArrayList<>(); + + public Arguments(String... args) { + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + String fieldName; + if (arg.startsWith("--")) { + fieldName = arg.substring(2); + } else if (arg.startsWith("-") && arg.length() == 2) { + fieldName = alias(arg.charAt(1)); + } else { + files.add(arg); + continue; + } + + try { + Field f = Arguments.class.getDeclaredField(fieldName); + if ((f.getModifiers() & (Modifier.PRIVATE | Modifier.STATIC | Modifier.FINAL)) != 0) { + throw new IllegalArgumentException(arg); + } + + Class type = f.getType(); + if (type == String.class) { + f.set(this, args[++i]); + } else if (type == boolean.class) { + f.setBoolean(this, true); + } else if (type == int.class) { + f.setInt(this, Integer.parseInt(args[++i])); + } else if (type == double.class) { + f.setDouble(this, Double.parseDouble(args[++i])); + } else if (type == long.class) { + f.setLong(this, parseTimestamp(args[++i])); + } else if (type == Pattern.class) { + f.set(this, Pattern.compile(args[++i])); + } + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new IllegalArgumentException(arg); + } + } + } + + private static String alias(char c) { + switch (c) { + case 'h': + return "help"; + case 'o': + return "output"; + case 'r': + return "reverse"; + case 'i': + return "inverted"; + case 'I': + return "include"; + case 'X': + return "exclude"; + case 't': + return "threads"; + case 's': + return "state"; + default: + return String.valueOf(c); + } + } + + // Milliseconds or HH:mm:ss.S or yyyy-MM-dd'T'HH:mm:ss.S + private static long parseTimestamp(String time) { + if (time.indexOf(':') < 0) { + return Long.parseLong(time); + } + + GregorianCalendar cal = new GregorianCalendar(); + StringTokenizer st = new StringTokenizer(time, "-:.T"); + + if (time.indexOf('T') > 0) { + cal.set(Calendar.YEAR, Integer.parseInt(st.nextToken())); + cal.set(Calendar.MONTH, Integer.parseInt(st.nextToken()) - 1); + cal.set(Calendar.DAY_OF_MONTH, Integer.parseInt(st.nextToken())); + } + cal.set(Calendar.HOUR_OF_DAY, st.hasMoreTokens() ? Integer.parseInt(st.nextToken()) : 0); + cal.set(Calendar.MINUTE, st.hasMoreTokens() ? Integer.parseInt(st.nextToken()) : 0); + cal.set(Calendar.SECOND, st.hasMoreTokens() ? Integer.parseInt(st.nextToken()) : 0); + cal.set(Calendar.MILLISECOND, st.hasMoreTokens() ? Integer.parseInt(st.nextToken()) : 0); + + return cal.getTimeInMillis(); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Classifier.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Classifier.java new file mode 100644 index 00000000000..ec955870930 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Classifier.java @@ -0,0 +1,156 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.convert; + +import static io.sentry.asyncprofiler.vendor.asyncprofiler.convert.Frame.*; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.StackTrace; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +abstract class Classifier { + + enum Category { + GC("[gc]", TYPE_CPP), + JIT("[jit]", TYPE_CPP), + VM("[vm]", TYPE_CPP), + VTABLE_STUBS("[vtable_stubs]", TYPE_NATIVE), + NATIVE("[native]", TYPE_NATIVE), + INTERPRETER("[Interpreter]", TYPE_NATIVE), + C1_COMP("[c1_comp]", TYPE_C1_COMPILED), + C2_COMP("[c2_comp]", TYPE_INLINED), + ADAPTER("[c2i_adapter]", TYPE_INLINED), + CLASS_INIT("[class_init]", TYPE_CPP), + CLASS_LOAD("[class_load]", TYPE_CPP), + CLASS_RESOLVE("[class_resolve]", TYPE_CPP), + CLASS_VERIFY("[class_verify]", TYPE_CPP), + LAMBDA_INIT("[lambda_init]", TYPE_CPP); + + final String title; + final byte type; + + Category(String title, byte type) { + this.title = title; + this.type = type; + } + } + + public @Nullable Category getCategory(@NotNull StackTrace stackTrace) { + long[] methods = stackTrace.methods; + byte[] types = stackTrace.types; + + Category category; + if ((category = detectGcJit(methods, types)) == null + && (category = detectClassLoading(methods, types)) == null) { + category = detectOther(methods, types); + } + return category; + } + + private @Nullable Category detectGcJit(long[] methods, byte[] types) { + boolean vmThread = false; + for (int i = types.length; --i >= 0; ) { + if (types[i] == TYPE_CPP) { + switch (getMethodName(methods[i], types[i])) { + case "CompileBroker::compiler_thread_loop": + return Category.JIT; + case "GCTaskThread::run": + case "WorkerThread::run": + return Category.GC; + case "java_start": + case "thread_native_entry": + vmThread = true; + break; + } + } else if (types[i] != TYPE_NATIVE) { + break; + } + } + return vmThread ? Category.VM : null; + } + + private @Nullable Category detectClassLoading(long[] methods, byte[] types) { + for (int i = 0; i < methods.length; i++) { + String methodName = getMethodName(methods[i], types[i]); + if (methodName.equals("Verifier::verify")) { + return Category.CLASS_VERIFY; + } else if (methodName.startsWith("InstanceKlass::initialize")) { + return Category.CLASS_INIT; + } else if (methodName.startsWith("LinkResolver::") + || methodName.startsWith("InterpreterRuntime::resolve") + || methodName.startsWith("SystemDictionary::resolve")) { + return Category.CLASS_RESOLVE; + } else if (methodName.endsWith("ClassLoader.loadClass")) { + return Category.CLASS_LOAD; + } else if (methodName.endsWith("LambdaMetafactory.metafactory") + || methodName.endsWith("LambdaMetafactory.altMetafactory")) { + return Category.LAMBDA_INIT; + } else if (methodName.endsWith("table stub")) { + return Category.VTABLE_STUBS; + } else if (methodName.equals("Interpreter")) { + return Category.INTERPRETER; + } else if (methodName.startsWith("I2C/C2I")) { + return i + 1 < types.length && types[i + 1] == TYPE_INTERPRETED + ? Category.INTERPRETER + : Category.ADAPTER; + } + } + return null; + } + + private @NotNull Category detectOther(long[] methods, byte[] types) { + boolean inJava = true; + for (int i = 0; i < types.length; i++) { + switch (types[i]) { + case TYPE_INTERPRETED: + return inJava ? Category.INTERPRETER : Category.NATIVE; + case TYPE_JIT_COMPILED: + return inJava ? Category.C2_COMP : Category.NATIVE; + case TYPE_INLINED: + inJava = true; + break; + case TYPE_NATIVE: + { + String methodName = getMethodName(methods[i], types[i]); + if (methodName.startsWith("JVM_") + || methodName.startsWith("Unsafe_") + || methodName.startsWith("MHN_") + || methodName.startsWith("jni_")) { + return Category.VM; + } + switch (methodName) { + case "call_stub": + case "deoptimization": + case "unknown_Java": + case "not_walkable_Java": + case "InlineCacheBuffer": + return Category.VM; + } + if (methodName.endsWith("_arraycopy") || methodName.contains("pthread_cond")) { + break; + } + inJava = false; + break; + } + case TYPE_CPP: + { + String methodName = getMethodName(methods[i], types[i]); + if (methodName.startsWith("Runtime1::")) { + return Category.C1_COMP; + } + break; + } + case TYPE_C1_COMPILED: + return inJava ? Category.C1_COMP : Category.NATIVE; + } + } + return Category.NATIVE; + } + + protected abstract @NotNull String getMethodName(long method, byte type); +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Frame.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Frame.java new file mode 100644 index 00000000000..bb9768442c4 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Frame.java @@ -0,0 +1,68 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.convert; + +import java.util.HashMap; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class Frame extends HashMap { + private static final long serialVersionUID = 1L; + public static final byte TYPE_INTERPRETED = 0; + public static final byte TYPE_JIT_COMPILED = 1; + public static final byte TYPE_INLINED = 2; + public static final byte TYPE_NATIVE = 3; + public static final byte TYPE_CPP = 4; + public static final byte TYPE_KERNEL = 5; + public static final byte TYPE_C1_COMPILED = 6; + + private static final int TYPE_SHIFT = 28; + + final int key; + long total; + long self; + long inlined, c1, interpreted; + + private Frame(int key) { + this.key = key; + } + + Frame(int titleIndex, byte type) { + this(titleIndex | type << TYPE_SHIFT); + } + + Frame getChild(int titleIndex, byte type) { + return super.computeIfAbsent(titleIndex | type << TYPE_SHIFT, Frame::new); + } + + int getTitleIndex() { + return key & ((1 << TYPE_SHIFT) - 1); + } + + byte getType() { + if (inlined * 3 >= total) { + return TYPE_INLINED; + } else if (c1 * 2 >= total) { + return TYPE_C1_COMPILED; + } else if (interpreted * 2 >= total) { + return TYPE_INTERPRETED; + } else { + return (byte) (key >>> TYPE_SHIFT); + } + } + + int depth(long cutoff) { + int depth = 0; + if (size() > 0) { + for (Frame child : values()) { + if (child.total >= cutoff) { + depth = Math.max(depth, child.depth(cutoff)); + } + } + } + return depth + 1; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter.java new file mode 100644 index 00000000000..006bce7e03c --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter.java @@ -0,0 +1,300 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.convert; + +import static io.sentry.asyncprofiler.vendor.asyncprofiler.convert.Frame.*; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.ClassRef; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.Dictionary; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.MethodRef; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.AllocationSample; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ContendedLock; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.Event; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.EventAggregator; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.EventCollector; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ExecutionSample; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.LiveObject; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.MallocEvent; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.MallocLeakAggregator; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.BitSet; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public abstract class JfrConverter extends Classifier { + protected final @NotNull JfrReader jfr; + protected final @NotNull Arguments args; + protected final @NotNull EventCollector collector; + protected @NotNull Dictionary methodNames; + + public JfrConverter(@NotNull JfrReader jfr, @NotNull Arguments args) { + this.jfr = jfr; + this.args = args; + this.methodNames = new Dictionary<>(); + + EventCollector collector = createCollector(args); + this.collector = args.nativemem && args.leak ? new MallocLeakAggregator(collector) : collector; + } + + public void convert() throws IOException { + jfr.stopAtNewChunk = true; + + while (jfr.hasMoreChunks()) { + // Reset method dictionary, since new chunk may have different IDs + methodNames = new Dictionary<>(); + + collector.beforeChunk(); + collectEvents(); + collector.afterChunk(); + + convertChunk(); + } + + if (collector.finish()) { + convertChunk(); + } + } + + protected EventCollector createCollector(Arguments args) { + return new EventAggregator(args.threads, args.grain); + } + + protected void collectEvents() throws IOException { + Class eventClass = + args.nativemem + ? MallocEvent.class + : args.live + ? LiveObject.class + : args.alloc + ? AllocationSample.class + : args.lock ? ContendedLock.class : ExecutionSample.class; + + BitSet threadStates = null; + if (args.state != null) { + threadStates = new BitSet(); + for (String state : args.state.toUpperCase().split(",", -1)) { + threadStates.set(toThreadState(state)); + } + } else if (args.cpu) { + threadStates = getThreadStates(true); + } else if (args.wall) { + threadStates = getThreadStates(false); + } + + long startTicks = args.from != 0 ? toTicks(args.from) : Long.MIN_VALUE; + long endTicks = args.to != 0 ? toTicks(args.to) : Long.MAX_VALUE; + + for (Event event; (event = jfr.readEvent(eventClass)) != null; ) { + if (event.time >= startTicks && event.time <= endTicks) { + if (threadStates == null || threadStates.get(((ExecutionSample) event).threadState)) { + collector.collect(event); + } + } + } + } + + protected void convertChunk() { + // To be overridden in subclasses + } + + protected int toThreadState(String name) { + Map threadStates = jfr.enums.get("jdk.types.ThreadState"); + if (threadStates != null) { + for (Map.Entry entry : threadStates.entrySet()) { + if (entry.getValue().startsWith(name, 6)) { + return entry.getKey(); + } + } + } + throw new IllegalArgumentException("Unknown thread state: " + name); + } + + protected BitSet getThreadStates(boolean cpu) { + BitSet set = new BitSet(); + Map threadStates = jfr.enums.get("jdk.types.ThreadState"); + if (threadStates != null) { + for (Map.Entry entry : threadStates.entrySet()) { + set.set(entry.getKey(), "STATE_DEFAULT".equals(entry.getValue()) == cpu); + } + } + return set; + } + + // millis can be an absolute timestamp or an offset from the beginning/end of the recording + protected long toTicks(long millis) { + long nanos = millis * 1_000_000; + if (millis < 0) { + nanos += jfr.endNanos; + } else if (millis < 1500000000000L) { + nanos += jfr.startNanos; + } + return (long) ((nanos - jfr.chunkStartNanos) * (jfr.ticksPerSec / 1e9)) + jfr.chunkStartTicks; + } + + @Override + public String getMethodName(long methodId, byte methodType) { + String result = methodNames.get(methodId); + if (result == null) { + methodNames.put(methodId, result = resolveMethodName(methodId, methodType)); + } + return result; + } + + private String resolveMethodName(long methodId, byte methodType) { + MethodRef method = jfr.methods.get(methodId); + if (method == null) { + return "unknown"; + } + + ClassRef cls = jfr.classes.get(method.cls); + byte[] className = jfr.symbols.get(cls.name); + byte[] methodName = jfr.symbols.get(method.name); + + if (className == null || className.length == 0 || isNativeFrame(methodType)) { + return new String(methodName, StandardCharsets.UTF_8); + } else { + String classStr = toJavaClassName(className, 0, args.dot); + if (methodName == null || methodName.length == 0) { + return classStr; + } + String methodStr = new String(methodName, StandardCharsets.UTF_8); + return classStr + '.' + methodStr; + } + } + + public String getClassName(long classId) { + ClassRef cls = jfr.classes.get(classId); + if (cls == null) { + return "null"; + } + byte[] className = jfr.symbols.get(cls.name); + + int arrayDepth = 0; + while (className[arrayDepth] == '[') { + arrayDepth++; + } + + String name = toJavaClassName(className, arrayDepth, true); + while (arrayDepth-- > 0) { + name = name.concat("[]"); + } + return name; + } + + private String toJavaClassName(byte[] symbol, int start, boolean dotted) { + int end = symbol.length; + if (start > 0) { + switch (symbol[start]) { + case 'B': + return "byte"; + case 'C': + return "char"; + case 'S': + return "short"; + case 'I': + return "int"; + case 'J': + return "long"; + case 'Z': + return "boolean"; + case 'F': + return "float"; + case 'D': + return "double"; + case 'L': + start++; + end--; + } + } + + if (args.norm) { + for (int i = end - 2; i > start; i--) { + if (symbol[i] == '/' || symbol[i] == '.') { + if (symbol[i + 1] >= '0' && symbol[i + 1] <= '9') { + end = i; + if (i > start + 19 && symbol[i - 19] == '+' && symbol[i - 18] == '0') { + // Original JFR transforms lambda names to something like + // pkg.ClassName$$Lambda+0x00007f8177090218/543846639 + end = i - 19; + } + } + break; + } + } + } + + if (args.simple) { + for (int i = end - 2; i >= start; i--) { + if (symbol[i] == '/' && (symbol[i + 1] < '0' || symbol[i + 1] > '9')) { + start = i + 1; + break; + } + } + } + + String s = new String(symbol, start, end - start, StandardCharsets.UTF_8); + return dotted ? s.replace('/', '.') : s; + } + + public StackTraceElement getStackTraceElement(long methodId, byte methodType, int location) { + MethodRef method = jfr.methods.get(methodId); + if (method == null) { + return new StackTraceElement("", "unknown", null, 0); + } + + ClassRef cls = jfr.classes.get(method.cls); + byte[] className = jfr.symbols.get(cls.name); + byte[] methodName = jfr.symbols.get(method.name); + + String classStr = + className == null || className.length == 0 || isNativeFrame(methodType) + ? "" + : toJavaClassName(className, 0, args.dot); + String methodStr = + methodName == null || methodName.length == 0 + ? "" + : new String(methodName, StandardCharsets.UTF_8); + return new StackTraceElement(classStr, methodStr, null, location >>> 16); + } + + public String getThreadName(int tid) { + String threadName = jfr.threads.get(tid); + return threadName == null + ? "[tid=" + tid + ']' + : threadName.startsWith("[tid=") ? threadName : '[' + threadName + " tid=" + tid + ']'; + } + + public String getPlainThreadName(int tid) { + String threadName = jfr.threads.get(tid); + return threadName == null ? "[tid=" + tid + ']' : threadName; + } + + protected boolean isNativeFrame(byte methodType) { + // In JDK Flight Recorder, TYPE_NATIVE denotes Java native methods, + // while in async-profiler, TYPE_NATIVE is for C methods + return (methodType == TYPE_NATIVE + && jfr.getEnumValue("jdk.types.FrameType", TYPE_KERNEL) != null) + || methodType == TYPE_CPP + || methodType == TYPE_KERNEL; + } + + // Select sum(samples) or sum(value) depending on the --total option. + // For lock events, convert lock duration from ticks to nanoseconds. + protected abstract class AggregatedEventVisitor implements EventCollector.Visitor { + final double factor = !args.total ? 0.0 : args.lock ? 1e9 / jfr.ticksPerSec : 1.0; + + @Override + public final void visit(Event event, long samples, long value) { + visit(event, factor == 0.0 ? samples : factor == 1.0 ? value : (long) (value * factor)); + } + + protected abstract void visit(Event event, long value); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/ClassRef.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/ClassRef.java new file mode 100644 index 00000000000..1727dc2156c --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/ClassRef.java @@ -0,0 +1,17 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class ClassRef { + public final long name; + + public ClassRef(long name) { + this.name = name; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary.java new file mode 100644 index 00000000000..5f3191a37e6 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary.java @@ -0,0 +1,116 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import java.util.Arrays; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +/** Fast and compact long->Object map. */ +public final class Dictionary { + private static final int INITIAL_CAPACITY = 16; + + private long[] keys; + private Object[] values; + private int size; + + public Dictionary() { + this(INITIAL_CAPACITY); + } + + public Dictionary(int initialCapacity) { + this.keys = new long[initialCapacity]; + this.values = new Object[initialCapacity]; + } + + public void clear() { + Arrays.fill(keys, 0); + Arrays.fill(values, null); + size = 0; + } + + public int size() { + return size; + } + + public void put(long key, T value) { + if (key == 0) { + throw new IllegalArgumentException("Zero key not allowed"); + } + + int mask = keys.length - 1; + int i = hashCode(key) & mask; + while (keys[i] != 0) { + if (keys[i] == key) { + values[i] = value; + return; + } + i = (i + 1) & mask; + } + keys[i] = key; + values[i] = value; + + if (++size * 2 > keys.length) { + resize(keys.length * 2); + } + } + + @SuppressWarnings("unchecked") + public T get(long key) { + int mask = keys.length - 1; + int i = hashCode(key) & mask; + while (keys[i] != key && keys[i] != 0) { + i = (i + 1) & mask; + } + return (T) values[i]; + } + + @SuppressWarnings("unchecked") + public void forEach(Visitor visitor) { + for (int i = 0; i < keys.length; i++) { + if (keys[i] != 0) { + visitor.visit(keys[i], (T) values[i]); + } + } + } + + public int preallocate(int count) { + if (count * 2 > keys.length) { + resize(Integer.highestOneBit(count * 4 - 1)); + } + return count; + } + + private void resize(int newCapacity) { + long[] newKeys = new long[newCapacity]; + Object[] newValues = new Object[newCapacity]; + int mask = newKeys.length - 1; + + for (int i = 0; i < keys.length; i++) { + if (keys[i] != 0) { + for (int j = hashCode(keys[i]) & mask; ; j = (j + 1) & mask) { + if (newKeys[j] == 0) { + newKeys[j] = keys[i]; + newValues[j] = values[i]; + break; + } + } + } + } + + keys = newKeys; + values = newValues; + } + + private static int hashCode(long key) { + key *= 0xc6a4a7935bd1e995L; + return (int) (key ^ (key >>> 32)); + } + + public interface Visitor { + void visit(long key, T value); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt.java new file mode 100644 index 00000000000..83d3a2772e1 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt.java @@ -0,0 +1,125 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import java.util.Arrays; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +/** Fast and compact long->int map. */ +public final class DictionaryInt { + private static final int INITIAL_CAPACITY = 16; + + private long[] keys; + private int[] values; + private int size; + + public DictionaryInt() { + this(INITIAL_CAPACITY); + } + + public DictionaryInt(int initialCapacity) { + this.keys = new long[initialCapacity]; + this.values = new int[initialCapacity]; + } + + public void clear() { + Arrays.fill(keys, 0); + Arrays.fill(values, 0); + size = 0; + } + + public void put(long key, int value) { + if (key == 0) { + throw new IllegalArgumentException("Zero key not allowed"); + } + + int mask = keys.length - 1; + int i = hashCode(key) & mask; + while (keys[i] != 0) { + if (keys[i] == key) { + values[i] = value; + return; + } + i = (i + 1) & mask; + } + keys[i] = key; + values[i] = value; + + if (++size * 2 > keys.length) { + resize(keys.length * 2); + } + } + + public int get(long key) { + int mask = keys.length - 1; + int i = hashCode(key) & mask; + while (keys[i] != key) { + if (keys[i] == 0) { + throw new IllegalArgumentException("No such key: " + key); + } + i = (i + 1) & mask; + } + return values[i]; + } + + public int get(long key, int notFound) { + int mask = keys.length - 1; + int i = hashCode(key) & mask; + while (keys[i] != key) { + if (keys[i] == 0) { + return notFound; + } + i = (i + 1) & mask; + } + return values[i]; + } + + public void forEach(Visitor visitor) { + for (int i = 0; i < keys.length; i++) { + if (keys[i] != 0) { + visitor.visit(keys[i], values[i]); + } + } + } + + public int preallocate(int count) { + if (count * 2 > keys.length) { + resize(Integer.highestOneBit(count * 4 - 1)); + } + return count; + } + + private void resize(int newCapacity) { + long[] newKeys = new long[newCapacity]; + int[] newValues = new int[newCapacity]; + int mask = newKeys.length - 1; + + for (int i = 0; i < keys.length; i++) { + if (keys[i] != 0) { + for (int j = hashCode(keys[i]) & mask; ; j = (j + 1) & mask) { + if (newKeys[j] == 0) { + newKeys[j] = keys[i]; + newValues[j] = values[i]; + break; + } + } + } + } + + keys = newKeys; + values = newValues; + } + + private static int hashCode(long key) { + key *= 0xc6a4a7935bd1e995L; + return (int) (key ^ (key >>> 32)); + } + + public interface Visitor { + void visit(long key, int value); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Element.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Element.java new file mode 100644 index 00000000000..0c12292106a --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Element.java @@ -0,0 +1,18 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +abstract class Element { + + void addChild(Element e) {} + + static final class NoOpElement extends Element { + // Empty implementation for unhandled element types + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrClass.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrClass.java new file mode 100644 index 00000000000..eb85be46eb3 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrClass.java @@ -0,0 +1,44 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class JfrClass extends Element { + final int id; + final boolean simpleType; + final @Nullable String name; + final List fields; + + JfrClass(@NotNull Map attributes) { + this.id = Integer.parseInt(attributes.get("id")); + this.simpleType = "true".equals(attributes.get("simpleType")); + this.name = attributes.get("name"); + this.fields = new ArrayList<>(2); + } + + @Override + void addChild(Element e) { + if (e instanceof JfrField) { + fields.add((JfrField) e); + } + } + + public @Nullable JfrField field(@NotNull String name) { + for (JfrField field : fields) { + if (field.name != null && field.name.equals(name)) { + return field; + } + } + return null; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField.java new file mode 100644 index 00000000000..635b87fd0f3 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField.java @@ -0,0 +1,24 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class JfrField extends Element { + final @Nullable String name; + final int type; + final boolean constantPool; + + JfrField(@NotNull Map attributes) { + this.name = attributes.get("name"); + this.type = Integer.parseInt(attributes.get("class")); + this.constantPool = "true".equals(attributes.get("constantPool")); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader.java new file mode 100644 index 00000000000..a0297d7afc1 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader.java @@ -0,0 +1,714 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.AllocationSample; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.CPULoad; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ContendedLock; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.Event; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ExecutionSample; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.GCHeapSummary; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.LiveObject; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.MallocEvent; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ObjectCount; +import java.io.Closeable; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Parses JFR output produced by async-profiler. */ +public final class JfrReader implements Closeable { + private static final int BUFFER_SIZE = 2 * 1024 * 1024; + private static final int CHUNK_HEADER_SIZE = 68; + private static final int CHUNK_SIGNATURE = 0x464c5200; + + private static final byte STATE_NEW_CHUNK = 0; + private static final byte STATE_READING = 1; + private static final byte STATE_EOF = 2; + private static final byte STATE_INCOMPLETE = 3; + + private final @Nullable FileChannel ch; + private @NotNull ByteBuffer buf; + private final long fileSize; + private long filePosition; + private byte state; + + public long startNanos = Long.MAX_VALUE; + public long endNanos = Long.MIN_VALUE; + public long startTicks = Long.MAX_VALUE; + public long chunkStartNanos; + public long chunkEndNanos; + public long chunkStartTicks; + public long ticksPerSec; + public boolean stopAtNewChunk; + + public final Dictionary types = new Dictionary<>(); + public final Map typesByName = new HashMap<>(); + public final Dictionary threads = new Dictionary<>(); + // Maps thread IDs to Java thread IDs + // Change compared to original async-profiler JFR reader + public final Dictionary javaThreads = new Dictionary<>(); + public final Dictionary classes = new Dictionary<>(); + public final Dictionary strings = new Dictionary<>(); + public final Dictionary symbols = new Dictionary<>(); + public final Dictionary methods = new Dictionary<>(); + public final Dictionary stackTraces = new Dictionary<>(); + public final Map settings = new HashMap<>(); + public final Map> enums = new HashMap<>(); + + private final Dictionary> customEvents = new Dictionary<>(); + + private int executionSample; + private int nativeMethodSample; + private int wallClockSample; + private int allocationInNewTLAB; + private int allocationOutsideTLAB; + private int allocationSample; + private int liveObject; + private int monitorEnter; + private int threadPark; + private int activeSetting; + private int malloc; + private int free; + + @ApiStatus.Internal + public JfrReader(String fileName) throws IOException { + this.ch = FileChannel.open(Paths.get(fileName), StandardOpenOption.READ); + this.buf = ByteBuffer.allocateDirect(BUFFER_SIZE); + this.fileSize = ch.size(); + + buf.flip(); + ensureBytes(CHUNK_HEADER_SIZE); + if (!readChunk(0)) { + throw new IOException("Incomplete JFR file"); + } + } + + public JfrReader(@NotNull ByteBuffer buf) throws IOException { + this.ch = null; + this.buf = buf; + this.fileSize = buf.limit(); + + buf.order(ByteOrder.BIG_ENDIAN); + if (!readChunk(0)) { + throw new IOException("Incomplete JFR file"); + } + } + + @Override + public void close() throws IOException { + if (ch != null) { + ch.close(); + } + } + + public boolean eof() { + return state >= STATE_EOF; + } + + public boolean incomplete() { + return state == STATE_INCOMPLETE; + } + + public long durationNanos() { + return endNanos - startNanos; + } + + public void registerEvent(String name, Class eventClass) { + JfrClass type = typesByName.get(name); + if (type != null) { + try { + customEvents.put(type.id, eventClass.getConstructor(JfrReader.class)); + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException("No suitable constructor found"); + } + } + } + + // Similar to eof(), but parses the next chunk header + public boolean hasMoreChunks() throws IOException { + return state == STATE_NEW_CHUNK ? readChunk(buf.position()) : state == STATE_READING; + } + + public List readAllEvents() throws IOException { + return readAllEvents(null); + } + + public List readAllEvents(@Nullable Class cls) throws IOException { + ArrayList events = new ArrayList<>(); + for (E event; (event = readEvent(cls)) != null; ) { + events.add(event); + } + Collections.sort(events); + return events; + } + + public @Nullable Event readEvent() throws IOException { + return readEvent(null); + } + + @SuppressWarnings("unchecked") + public @Nullable E readEvent(@Nullable Class cls) throws IOException { + while (ensureBytes(CHUNK_HEADER_SIZE)) { + int pos = buf.position(); + int size = getVarint(); + int type = getVarint(); + + if (type == 'L' && buf.getInt(pos) == CHUNK_SIGNATURE) { + if (state != STATE_NEW_CHUNK && stopAtNewChunk) { + buf.position(pos); + state = STATE_NEW_CHUNK; + } else if (readChunk(pos)) { + continue; + } + return null; + } + + if (type == executionSample || type == nativeMethodSample) { + if (cls == null || cls == ExecutionSample.class) return (E) readExecutionSample(false); + } else if (type == wallClockSample) { + if (cls == null || cls == ExecutionSample.class) return (E) readExecutionSample(true); + } else if (type == allocationInNewTLAB) { + if (cls == null || cls == AllocationSample.class) return (E) readAllocationSample(true); + } else if (type == allocationOutsideTLAB || type == allocationSample) { + if (cls == null || cls == AllocationSample.class) return (E) readAllocationSample(false); + } else if (type == malloc) { + if (cls == null || cls == MallocEvent.class) return (E) readMallocEvent(true); + } else if (type == free) { + if (cls == null || cls == MallocEvent.class) return (E) readMallocEvent(false); + } else if (type == liveObject) { + if (cls == null || cls == LiveObject.class) return (E) readLiveObject(); + } else if (type == monitorEnter) { + if (cls == null || cls == ContendedLock.class) return (E) readContendedLock(false); + } else if (type == threadPark) { + if (cls == null || cls == ContendedLock.class) return (E) readContendedLock(true); + } else if (type == activeSetting) { + readActiveSetting(); + } else { + Constructor customEvent = customEvents.get(type); + if (customEvent != null && (cls == null || cls == customEvent.getDeclaringClass())) { + try { + return (E) customEvent.newInstance(this); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } finally { + seek(filePosition + pos + size); + } + } + } + + seek(filePosition + pos + size); + } + + state = STATE_EOF; + return null; + } + + private ExecutionSample readExecutionSample(boolean hasSamples) { + long time = getVarlong(); + int tid = getVarint(); + int stackTraceId = getVarint(); + int threadState = getVarint(); + int samples = hasSamples ? getVarint() : 1; + return new ExecutionSample(time, tid, stackTraceId, threadState, samples); + } + + private AllocationSample readAllocationSample(boolean tlab) { + long time = getVarlong(); + int tid = getVarint(); + int stackTraceId = getVarint(); + int classId = getVarint(); + long allocationSize = getVarlong(); + long tlabSize = tlab ? getVarlong() : 0; + return new AllocationSample(time, tid, stackTraceId, classId, allocationSize, tlabSize); + } + + private MallocEvent readMallocEvent(boolean hasSize) { + long time = getVarlong(); + int tid = getVarint(); + int stackTraceId = getVarint(); + long address = getVarlong(); + long size = hasSize ? getVarlong() : 0; + return new MallocEvent(time, tid, stackTraceId, address, size); + } + + private LiveObject readLiveObject() { + long time = getVarlong(); + int tid = getVarint(); + int stackTraceId = getVarint(); + int classId = getVarint(); + long allocationSize = getVarlong(); + long allocatimeTime = getVarlong(); + return new LiveObject(time, tid, stackTraceId, classId, allocationSize, allocatimeTime); + } + + private ContendedLock readContendedLock(boolean hasTimeout) { + long time = getVarlong(); + long duration = getVarlong(); + int tid = getVarint(); + int stackTraceId = getVarint(); + int classId = getVarint(); + if (hasTimeout) getVarlong(); + getVarlong(); + getVarlong(); + return new ContendedLock(time, tid, stackTraceId, duration, classId); + } + + private void readActiveSetting() { + JfrClass activeSetting = typesByName.get("jdk.ActiveSetting"); + if (activeSetting == null) return; + for (JfrField field : activeSetting.fields) { + getVarlong(); + if ("id".equals(field.name)) { + break; + } + } + String name = getString(); + String value = getString(); + settings.put(name, value); + } + + private boolean readChunk(int pos) throws IOException { + if (pos + CHUNK_HEADER_SIZE > buf.limit() || buf.getInt(pos) != CHUNK_SIGNATURE) { + throw new IOException("Not a valid JFR file"); + } + + int version = buf.getInt(pos + 4); + if (version < 0x20000 || version > 0x2ffff) { + throw new IOException( + "Unsupported JFR version: " + (version >>> 16) + "." + (version & 0xffff)); + } + + long chunkStart = filePosition + pos; + long chunkSize = buf.getLong(pos + 8); + if (chunkStart + chunkSize > fileSize) { + state = STATE_INCOMPLETE; + return false; + } + + long cpOffset = buf.getLong(pos + 16); + long metaOffset = buf.getLong(pos + 24); + if (cpOffset == 0 || metaOffset == 0) { + state = STATE_INCOMPLETE; + return false; + } + + chunkStartNanos = buf.getLong(pos + 32); + chunkEndNanos = buf.getLong(pos + 32) + buf.getLong(pos + 40); + chunkStartTicks = buf.getLong(pos + 48); + ticksPerSec = buf.getLong(pos + 56); + + startNanos = Math.min(startNanos, chunkStartNanos); + endNanos = Math.max(endNanos, chunkEndNanos); + startTicks = Math.min(startTicks, chunkStartTicks); + + types.clear(); + typesByName.clear(); + + readMeta(chunkStart + metaOffset); + readConstantPool(chunkStart + cpOffset); + cacheEventTypes(); + + seek(chunkStart + CHUNK_HEADER_SIZE); + state = STATE_READING; + return true; + } + + private void readMeta(long metaOffset) throws IOException { + seek(metaOffset); + ensureBytes(5); + + int posBeforeSize = buf.position(); + ensureBytes(getVarint() - (buf.position() - posBeforeSize)); + getVarint(); + getVarlong(); + getVarlong(); + getVarlong(); + + String[] strings = new String[getVarint()]; + for (int i = 0; i < strings.length; i++) { + strings[i] = getString(); + } + readElement(strings); + } + + private Element readElement(String[] strings) { + String name = strings[getVarint()]; + + int attributeCount = getVarint(); + Map attributes = new HashMap<>(attributeCount); + for (int i = 0; i < attributeCount; i++) { + attributes.put(strings[getVarint()], strings[getVarint()]); + } + + Element e = createElement(name, attributes); + int childCount = getVarint(); + for (int i = 0; i < childCount; i++) { + e.addChild(readElement(strings)); + } + return e; + } + + private Element createElement(String name, Map attributes) { + switch (name) { + case "class": + { + JfrClass type = new JfrClass(attributes); + if (!attributes.containsKey("superType")) { + types.put(type.id, type); + } + typesByName.put(type.name, type); + return type; + } + case "field": + return new JfrField(attributes); + default: + return new Element.NoOpElement(); + } + } + + private void readConstantPool(long cpOffset) throws IOException { + long delta; + do { + seek(cpOffset); + ensureBytes(5); + + int posBeforeSize = buf.position(); + ensureBytes(getVarint() - (buf.position() - posBeforeSize)); + getVarint(); + getVarlong(); + getVarlong(); + delta = getVarlong(); + getVarint(); + + int poolCount = getVarint(); + for (int i = 0; i < poolCount; i++) { + int type = getVarint(); + readConstants(types.get(type)); + } + } while (delta != 0 && (cpOffset += delta) > 0); + } + + private void readConstants(JfrClass type) { + String typeName = type.name; + if (typeName == null) { + readOtherConstants(type.fields); + return; + } + switch (typeName) { + case "jdk.types.ChunkHeader": + buf.position(buf.position() + (CHUNK_HEADER_SIZE + 3)); + break; + case "java.lang.Thread": + readThreads(type.fields.size()); + break; + case "java.lang.Class": + readClasses(type.fields.size()); + break; + case "java.lang.String": + readStrings(); + break; + case "jdk.types.Symbol": + readSymbols(); + break; + case "jdk.types.Method": + readMethods(); + break; + case "jdk.types.StackTrace": + readStackTraces(); + break; + default: + if (type.simpleType && type.fields.size() == 1) { + readEnumValues(typeName); + } else { + readOtherConstants(type.fields); + } + } + } + + private void readThreads(int fieldCount) { + int count = threads.preallocate(getVarint()); + for (int i = 0; i < count; i++) { + long id = getVarlong(); + String osName = getString(); + getVarint(); // osThreadId + String javaName = getString(); + long javaThreadId = getVarlong(); + readFields(fieldCount - 4); + javaThreads.put(id, javaThreadId); + String threadName = javaName != null ? javaName : (osName != null ? osName : "Thread-" + id); + threads.put(id, threadName); + } + } + + private void readClasses(int fieldCount) { + int count = classes.preallocate(getVarint()); + for (int i = 0; i < count; i++) { + long id = getVarlong(); + getVarlong(); + long name = getVarlong(); + getVarlong(); + getVarint(); + readFields(fieldCount - 4); + classes.put(id, new ClassRef(name)); + } + } + + private void readMethods() { + int count = methods.preallocate(getVarint()); + for (int i = 0; i < count; i++) { + long id = getVarlong(); + long cls = getVarlong(); + long name = getVarlong(); + long sig = getVarlong(); + getVarint(); + getVarint(); + methods.put(id, new MethodRef(cls, name, sig)); + } + } + + private void readStackTraces() { + int count = stackTraces.preallocate(getVarint()); + for (int i = 0; i < count; i++) { + long id = getVarlong(); + getVarint(); // int truncated + StackTrace stackTrace = readStackTrace(); + stackTraces.put(id, stackTrace); + } + } + + private StackTrace readStackTrace() { + int depth = getVarint(); + long[] methods = new long[depth]; + byte[] types = new byte[depth]; + int[] locations = new int[depth]; + for (int i = 0; i < depth; i++) { + methods[i] = getVarlong(); + int line = getVarint(); + int bci = getVarint(); + locations[i] = line << 16 | (bci & 0xffff); + types[i] = buf.get(); + } + return new StackTrace(methods, types, locations); + } + + private void readStrings() { + int count = strings.preallocate(getVarint()); + for (int i = 0; i < count; i++) { + String str = getString(); + if (str == null) str = ""; + strings.put(getVarlong(), str); + } + } + + private void readSymbols() { + int count = symbols.preallocate(getVarint()); + for (int i = 0; i < count; i++) { + long id = getVarlong(); + if (buf.get() != 3) { + throw new IllegalArgumentException("Invalid symbol encoding"); + } + symbols.put(id, getBytes()); + } + } + + private void readEnumValues(@NotNull String typeName) { + HashMap map = new HashMap<>(); + int count = getVarint(); + for (int i = 0; i < count; i++) { + map.put((int) getVarlong(), getString()); + } + enums.put(typeName, map); + } + + private void readOtherConstants(List fields) { + int stringType = getTypeId("java.lang.String"); + + boolean[] numeric = new boolean[fields.size()]; + for (int i = 0; i < numeric.length; i++) { + JfrField f = fields.get(i); + numeric[i] = f.constantPool || f.type != stringType; + } + + int count = getVarint(); + for (int i = 0; i < count; i++) { + getVarlong(); + readFields(numeric); + } + } + + private void readFields(boolean[] numeric) { + for (boolean n : numeric) { + if (n) { + getVarlong(); + } else { + getString(); + } + } + } + + private void readFields(int count) { + while (count-- > 0) { + getVarlong(); + } + } + + private void cacheEventTypes() { + executionSample = getTypeId("jdk.ExecutionSample"); + nativeMethodSample = getTypeId("jdk.NativeMethodSample"); + wallClockSample = getTypeId("profiler.WallClockSample"); + allocationInNewTLAB = getTypeId("jdk.ObjectAllocationInNewTLAB"); + allocationOutsideTLAB = getTypeId("jdk.ObjectAllocationOutsideTLAB"); + allocationSample = getTypeId("jdk.ObjectAllocationSample"); + liveObject = getTypeId("profiler.LiveObject"); + monitorEnter = getTypeId("jdk.JavaMonitorEnter"); + threadPark = getTypeId("jdk.ThreadPark"); + activeSetting = getTypeId("jdk.ActiveSetting"); + malloc = getTypeId("profiler.Malloc"); + free = getTypeId("profiler.Free"); + + registerEvent("jdk.CPULoad", CPULoad.class); + registerEvent("jdk.GCHeapSummary", GCHeapSummary.class); + registerEvent("jdk.ObjectCount", ObjectCount.class); + registerEvent("jdk.ObjectCountAfterGC", ObjectCount.class); + } + + private int getTypeId(String typeName) { + JfrClass type = typesByName.get(typeName); + return type != null ? type.id : -1; + } + + public int getEnumKey(String typeName, String value) { + Map enumValues = enums.get(typeName); + if (enumValues != null) { + for (Map.Entry entry : enumValues.entrySet()) { + if (value.equals(entry.getValue())) { + return entry.getKey(); + } + } + } + return -1; + } + + public @Nullable String getEnumValue(String typeName, int key) { + Map enumMap = enums.get(typeName); + return enumMap != null ? enumMap.get(key) : null; + } + + public int getVarint() { + int result = 0; + for (int shift = 0; ; shift += 7) { + byte b = buf.get(); + result |= (b & 0x7f) << shift; + if (b >= 0) { + return result; + } + } + } + + public long getVarlong() { + long result = 0; + for (int shift = 0; shift < 56; shift += 7) { + byte b = buf.get(); + result |= (b & 0x7fL) << shift; + if (b >= 0) { + return result; + } + } + return result | (buf.get() & 0xffL) << 56; + } + + public float getFloat() { + return buf.getFloat(); + } + + public double getDouble() { + return buf.getDouble(); + } + + public @Nullable String getString() { + switch (buf.get()) { + case 0: + return null; + case 1: + return ""; + case 2: + return strings.get(getVarlong()); + case 3: + return new String(getBytes(), StandardCharsets.UTF_8); + case 4: + { + char[] chars = new char[getVarint()]; + for (int i = 0; i < chars.length; i++) { + chars[i] = (char) getVarint(); + } + return new String(chars); + } + case 5: + return new String(getBytes(), StandardCharsets.ISO_8859_1); + default: + throw new IllegalArgumentException("Invalid string encoding"); + } + } + + public byte[] getBytes() { + byte[] bytes = new byte[getVarint()]; + buf.get(bytes); + return bytes; + } + + private void seek(long pos) throws IOException { + long bufPosition = pos - filePosition; + if (bufPosition >= 0 && bufPosition <= buf.limit()) { + buf.position((int) bufPosition); + } else { + filePosition = pos; + if (ch != null) { + ch.position(pos); + } + buf.rewind().flip(); + } + } + + private boolean ensureBytes(int needed) throws IOException { + if (buf.remaining() >= needed) { + return true; + } + + if (ch == null) { + return false; + } + + filePosition += buf.position(); + + if (buf.capacity() < needed) { + ByteBuffer newBuf = ByteBuffer.allocateDirect(needed); + newBuf.put(buf); + buf = newBuf; + } else { + buf.compact(); + } + + while (ch.read(buf) > 0 && buf.position() < needed) { + // keep reading + } + buf.flip(); + return buf.limit() > 0; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/MethodRef.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/MethodRef.java new file mode 100644 index 00000000000..7790a492375 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/MethodRef.java @@ -0,0 +1,21 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class MethodRef { + public final long cls; + public final long name; + public final long sig; + + public MethodRef(long cls, long name, long sig) { + this.cls = cls; + this.name = name; + this.sig = sig; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace.java new file mode 100644 index 00000000000..01e292f96f4 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace.java @@ -0,0 +1,21 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class StackTrace { + public final long[] methods; + public final byte[] types; + public final int[] locations; + + public StackTrace(long[] methods, byte[] types, int[] locations) { + this.methods = methods; + this.types = types; + this.locations = locations; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/AllocationSample.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/AllocationSample.java new file mode 100644 index 00000000000..0f60086527d --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/AllocationSample.java @@ -0,0 +1,47 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class AllocationSample extends Event { + public final int classId; + public final long allocationSize; + public final long tlabSize; + + public AllocationSample( + long time, int tid, int stackTraceId, int classId, long allocationSize, long tlabSize) { + super(time, tid, stackTraceId); + this.classId = classId; + this.allocationSize = allocationSize; + this.tlabSize = tlabSize; + } + + @Override + public int hashCode() { + return classId * 127 + stackTraceId + (tlabSize == 0 ? 17 : 0); + } + + @Override + public boolean sameGroup(Event o) { + if (o instanceof AllocationSample) { + AllocationSample a = (AllocationSample) o; + return classId == a.classId && (tlabSize == 0) == (a.tlabSize == 0); + } + return false; + } + + @Override + public long classId() { + return classId; + } + + @Override + public long value() { + return tlabSize != 0 ? tlabSize : allocationSize; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/CPULoad.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/CPULoad.java new file mode 100644 index 00000000000..9134fe190ec --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/CPULoad.java @@ -0,0 +1,23 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class CPULoad extends Event { + public final float jvmUser; + public final float jvmSystem; + public final float machineTotal; + + public CPULoad(JfrReader jfr) { + super(jfr.getVarlong(), 0, 0); + this.jvmUser = jfr.getFloat(); + this.jvmSystem = jfr.getFloat(); + this.machineTotal = jfr.getFloat(); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ContendedLock.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ContendedLock.java new file mode 100644 index 00000000000..e85595af4ce --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ContendedLock.java @@ -0,0 +1,44 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class ContendedLock extends Event { + public final long duration; + public final int classId; + + public ContendedLock(long time, int tid, int stackTraceId, long duration, int classId) { + super(time, tid, stackTraceId); + this.duration = duration; + this.classId = classId; + } + + @Override + public int hashCode() { + return classId * 127 + stackTraceId; + } + + @Override + public boolean sameGroup(Event o) { + if (o instanceof ContendedLock) { + ContendedLock c = (ContendedLock) o; + return classId == c.classId; + } + return false; + } + + @Override + public long classId() { + return classId; + } + + @Override + public long value() { + return duration; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event.java new file mode 100644 index 00000000000..5612904e404 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event.java @@ -0,0 +1,68 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import java.lang.reflect.Field; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public abstract class Event implements Comparable { + public final long time; + public final int tid; + public final int stackTraceId; + + protected Event(long time, int tid, int stackTraceId) { + this.time = time; + this.tid = tid; + this.stackTraceId = stackTraceId; + } + + @Override + public int compareTo(Event o) { + return Long.compare(time, o.time); + } + + @Override + public int hashCode() { + return stackTraceId; + } + + @Override + public String toString() { + StringBuilder sb = + new StringBuilder(getClass().getSimpleName()) + .append("{time=") + .append(time) + .append(",tid=") + .append(tid) + .append(",stackTraceId=") + .append(stackTraceId); + for (Field f : getClass().getDeclaredFields()) { + try { + sb.append(',').append(f.getName()).append('=').append(f.get(this)); + } catch (ReflectiveOperationException e) { + break; + } + } + return sb.append('}').toString(); + } + + public boolean sameGroup(Event o) { + return getClass() == o.getClass(); + } + + public long classId() { + return 0; + } + + public long samples() { + return 1; + } + + public long value() { + return 1; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventAggregator.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventAggregator.java new file mode 100644 index 00000000000..a3b9c7dd17b --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventAggregator.java @@ -0,0 +1,157 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public final class EventAggregator implements EventCollector { + private static final int INITIAL_CAPACITY = 1024; + + private final boolean threads; + private final double grain; + private @NotNull Event[] keys; + private @NotNull long[] samples; + private @NotNull long[] values; + private int size; + private double fraction; + + public EventAggregator(boolean threads, double grain) { + this.threads = threads; + this.grain = grain; + this.keys = new Event[INITIAL_CAPACITY]; + this.samples = new long[INITIAL_CAPACITY]; + this.values = new long[INITIAL_CAPACITY]; + + beforeChunk(); + } + + public int size() { + return size; + } + + @Override + public void collect(Event e) { + collect(e, e.samples(), e.value()); + } + + public void collect(Event e, long samples, long value) { + int mask = keys.length - 1; + int i = hashCode(e) & mask; + while (keys[i] != null) { + if (sameGroup(keys[i], e)) { + this.samples[i] += samples; + this.values[i] += value; + return; + } + i = (i + 1) & mask; + } + + this.keys[i] = e; + this.samples[i] = samples; + this.values[i] = value; + + if (++size * 2 > keys.length) { + resize(keys.length * 2); + } + } + + @Override + public void beforeChunk() { + if (keys == null || size > 0) { + keys = new Event[INITIAL_CAPACITY]; + samples = new long[INITIAL_CAPACITY]; + values = new long[INITIAL_CAPACITY]; + size = 0; + } + } + + @Override + public void afterChunk() { + if (grain > 0) { + coarsen(grain); + } + } + + @Override + public boolean finish() { + // Don't set to null as it would break nullability contract + keys = new Event[0]; + samples = new long[0]; + values = new long[0]; + return false; + } + + @Override + public void forEach(Visitor visitor) { + if (size > 0) { + for (int i = 0; i < keys.length; i++) { + if (keys[i] != null) { + visitor.visit(keys[i], samples[i], values[i]); + } + } + } + } + + public void coarsen(double grain) { + fraction = 0; + + for (int i = 0; i < keys.length; i++) { + if (keys[i] != null) { + long s0 = samples[i]; + long s1 = round(s0 / grain); + if (s1 == 0) { + keys[i] = null; + size--; + } + samples[i] = s1; + values[i] = (long) (values[i] * ((double) s1 / s0)); + } + } + } + + private long round(double d) { + long r = (long) d; + if ((fraction += d - r) >= 1.0) { + fraction -= 1.0; + r++; + } + return r; + } + + private int hashCode(Event e) { + return e.hashCode() + (threads ? e.tid * 31 : 0); + } + + private boolean sameGroup(Event e1, Event e2) { + return e1.stackTraceId == e2.stackTraceId && (!threads || e1.tid == e2.tid) && e1.sameGroup(e2); + } + + private void resize(int newCapacity) { + Event[] newKeys = new Event[newCapacity]; + long[] newSamples = new long[newCapacity]; + long[] newValues = new long[newCapacity]; + int mask = newKeys.length - 1; + + for (int i = 0; i < keys.length; i++) { + if (keys[i] != null) { + for (int j = hashCode(keys[i]) & mask; ; j = (j + 1) & mask) { + if (newKeys[j] == null) { + newKeys[j] = keys[i]; + newSamples[j] = samples[i]; + newValues[j] = values[i]; + break; + } + } + } + } + + keys = newKeys; + samples = newSamples; + values = newValues; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector.java new file mode 100644 index 00000000000..639faa88778 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector.java @@ -0,0 +1,27 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public interface EventCollector { + + void collect(Event e); + + void beforeChunk(); + + void afterChunk(); + + // Returns true if this collector has remaining data to process + boolean finish(); + + void forEach(Visitor visitor); + + interface Visitor { + void visit(Event event, long samples, long value); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ExecutionSample.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ExecutionSample.java new file mode 100644 index 00000000000..d4db5c5e585 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ExecutionSample.java @@ -0,0 +1,30 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class ExecutionSample extends Event { + public final int threadState; + public final int samples; + + public ExecutionSample(long time, int tid, int stackTraceId, int threadState, int samples) { + super(time, tid, stackTraceId); + this.threadState = threadState; + this.samples = samples; + } + + @Override + public long samples() { + return samples; + } + + @Override + public long value() { + return samples; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/GCHeapSummary.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/GCHeapSummary.java new file mode 100644 index 00000000000..68a8be94cf5 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/GCHeapSummary.java @@ -0,0 +1,30 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class GCHeapSummary extends Event { + public final int gcId; + public final boolean afterGC; + public final long committed; + public final long reserved; + public final long used; + + public GCHeapSummary(JfrReader jfr) { + super(jfr.getVarlong(), 0, 0); + this.gcId = jfr.getVarint(); + this.afterGC = jfr.getVarint() > 0; + jfr.getVarlong(); // long start + jfr.getVarlong(); // long committedEnd + this.committed = jfr.getVarlong(); + jfr.getVarlong(); // long reservedEnd + this.reserved = jfr.getVarlong(); + this.used = jfr.getVarlong(); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/LiveObject.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/LiveObject.java new file mode 100644 index 00000000000..6423d3b7f67 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/LiveObject.java @@ -0,0 +1,47 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class LiveObject extends Event { + public final int classId; + public final long allocationSize; + public final long allocationTime; + + public LiveObject( + long time, int tid, int stackTraceId, int classId, long allocationSize, long allocationTime) { + super(time, tid, stackTraceId); + this.classId = classId; + this.allocationSize = allocationSize; + this.allocationTime = allocationTime; + } + + @Override + public int hashCode() { + return classId * 127 + stackTraceId; + } + + @Override + public boolean sameGroup(Event o) { + if (o instanceof LiveObject) { + LiveObject a = (LiveObject) o; + return classId == a.classId; + } + return false; + } + + @Override + public long classId() { + return classId; + } + + @Override + public long value() { + return allocationSize; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocEvent.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocEvent.java new file mode 100644 index 00000000000..04aff9c71fe --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocEvent.java @@ -0,0 +1,25 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class MallocEvent extends Event { + public final long address; + public final long size; + + public MallocEvent(long time, int tid, int stackTraceId, long address, long size) { + super(time, tid, stackTraceId); + this.address = address; + this.size = size; + } + + @Override + public long value() { + return size; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocLeakAggregator.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocLeakAggregator.java new file mode 100644 index 00000000000..6fc81957342 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocLeakAggregator.java @@ -0,0 +1,69 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public final class MallocLeakAggregator implements EventCollector { + private final EventCollector wrapped; + private final Map addresses; + private @NotNull List events; + + public MallocLeakAggregator(@NotNull EventCollector wrapped) { + this.wrapped = wrapped; + this.addresses = new HashMap<>(); + this.events = new ArrayList<>(); + } + + @Override + public void collect(Event e) { + events.add((MallocEvent) e); + } + + @Override + public void beforeChunk() { + events = new ArrayList<>(); + } + + @Override + public void afterChunk() { + events.sort(null); + + for (MallocEvent e : events) { + if (e.size > 0) { + addresses.put(e.address, e); + } else { + addresses.remove(e.address); + } + } + + events = new ArrayList<>(); + } + + @Override + public boolean finish() { + wrapped.beforeChunk(); + for (Event e : addresses.values()) { + wrapped.collect(e); + } + wrapped.afterChunk(); + + // Free memory before the final conversion + addresses.clear(); + return true; + } + + @Override + public void forEach(Visitor visitor) { + wrapped.forEach(visitor); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ObjectCount.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ObjectCount.java new file mode 100644 index 00000000000..202619272eb --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ObjectCount.java @@ -0,0 +1,25 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class ObjectCount extends Event { + public final int gcId; + public final int classId; + public final long count; + public final long totalSize; + + public ObjectCount(JfrReader jfr) { + super(jfr.getVarlong(), 0, 0); + this.gcId = jfr.getVarint(); + this.classId = jfr.getVarint(); + this.count = jfr.getVarlong(); + this.totalSize = jfr.getVarlong(); + } +} diff --git a/sentry-async-profiler/src/main/resources/META-INF/services/io.sentry.profiling.JavaContinuousProfilerProvider b/sentry-async-profiler/src/main/resources/META-INF/services/io.sentry.profiling.JavaContinuousProfilerProvider new file mode 100644 index 00000000000..a59cb70f73c --- /dev/null +++ b/sentry-async-profiler/src/main/resources/META-INF/services/io.sentry.profiling.JavaContinuousProfilerProvider @@ -0,0 +1 @@ +io.sentry.asyncprofiler.provider.AsyncProfilerContinuousProfilerProvider diff --git a/sentry-async-profiler/src/main/resources/META-INF/services/io.sentry.profiling.JavaProfileConverterProvider b/sentry-async-profiler/src/main/resources/META-INF/services/io.sentry.profiling.JavaProfileConverterProvider new file mode 100644 index 00000000000..5f39755545d --- /dev/null +++ b/sentry-async-profiler/src/main/resources/META-INF/services/io.sentry.profiling.JavaProfileConverterProvider @@ -0,0 +1 @@ +io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/JavaContinuousProfilingServiceLoaderTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/JavaContinuousProfilingServiceLoaderTest.kt new file mode 100644 index 00000000000..5bac6d3ddbb --- /dev/null +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/JavaContinuousProfilingServiceLoaderTest.kt @@ -0,0 +1,24 @@ +package io.sentry.asyncprofiler + +import io.sentry.ILogger +import io.sentry.asyncprofiler.profiling.JavaContinuousProfiler +import io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider +import io.sentry.profiling.ProfilingServiceLoader +import kotlin.test.Test +import org.mockito.kotlin.mock + +class JavaContinuousProfilingServiceLoaderTest { + @Test + fun loadsAsyncProfilerProfileConverter() { + val service = ProfilingServiceLoader.loadProfileConverter() + assert(service is AsyncProfilerProfileConverterProvider.AsyncProfilerProfileConverter) + } + + @Test + fun loadsJavaAsyncProfiler() { + val logger = mock() + + val service = ProfilingServiceLoader.loadContinuousProfiler(logger, "", 10, null) + assert(service is JavaContinuousProfiler) + } +} 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 new file mode 100644 index 00000000000..d565fd9d51d --- /dev/null +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt @@ -0,0 +1,301 @@ +package io.sentry.asyncprofiler.convert + +import io.sentry.DateUtils +import io.sentry.ILogger +import io.sentry.IProfileConverter +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryStackTraceFactory +import io.sentry.TracesSampler +import io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider +import io.sentry.protocol.profiling.SentryProfile +import io.sentry.test.DeferredExecutorService +import java.io.IOException +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.temporal.ChronoUnit +import java.util.* +import kotlin.math.absoluteValue +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +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 +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever + +class JfrAsyncProfilerToSentryProfileConverterTest { + + private val fixture = Fixture() + + private class Fixture { + private val mockDsn = "http://key@localhost/proj" + val executor = DeferredExecutorService() + val mockedSentry = Mockito.mockStatic(Sentry::class.java) + val mockLogger = mock() + val mockTracesSampler = mock() + val mockStackTraceFactory = mock() + + val scopes: IScopes = mock() + val scope: IScope = mock() + + val options = + spy(SentryOptions()).apply { + dsn = mockDsn + profilesSampleRate = 1.0 + isDebug = true + setLogger(mockLogger) + // Set in-app packages for testing + addInAppInclude("io.sentry") + addInAppInclude("com.example") + } + + init { + whenever(mockTracesSampler.sampleSessionProfile(any())).thenReturn(true) + // Setup default in-app behavior for stack trace factory + whenever(mockStackTraceFactory.isInApp(any())).thenAnswer { invocation -> + val className = invocation.getArgument(0) + className.startsWith("io.sentry") || className.startsWith("com.example") + } + } + + fun getSut(optionConfig: ((options: SentryOptions) -> Unit) = {}): IProfileConverter? { + options.executorService = executor + optionConfig(options) + whenever(scopes.options).thenReturn(options) + whenever(scope.options).thenReturn(options) + return AsyncProfilerProfileConverterProvider().profileConverter + } + } + + @BeforeTest + fun `set up`() { + Sentry.setCurrentScopes(fixture.scopes) + + fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + fixture.mockedSentry.`when` { Sentry.getGlobalScope() }.thenReturn(fixture.scope) + + // Ensure the global scope returns proper options for the static converter method + whenever(fixture.scope.options).thenReturn(fixture.options) + } + + @AfterTest + fun clear() { + Sentry.close() + fixture.mockedSentry.close() + } + + @Test + fun `check number of samples for specific frame`() { + val file = loadFile("async_profiler_test_sample.jfr") + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + val tracingFilterFrame = + sentryProfile.frames.filter { + it.function == "slowFunction" && it.module == "io.sentry.samples.console.Main" + } + + val tracingFilterFrameIndexes = tracingFilterFrame.map { sentryProfile.frames.indexOf(it) } + val tracingFilterStacks = + sentryProfile.stacks.filter { it.any { inner -> tracingFilterFrameIndexes.contains(inner) } } + val tracingFilterStackIds = tracingFilterStacks.map { sentryProfile.stacks.indexOf(it) } + val tracingFilterSamples = + sentryProfile.samples.filter { tracingFilterStackIds.contains(it.stackId) } + + // Sample size base on 101 samples/sec and 5 sec of profiling + // So expected around 500 samples (with some margin) + assertTrue( + tracingFilterSamples.count() >= 500 && tracingFilterSamples.count() <= 600, + "Expected sample count between 500 and 600, but was ${tracingFilterSamples.count()}", + ) + } + + @Test + fun `check number of samples for specific thread`() { + val file = loadFile("async_profiler_test_sample.jfr") + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + val mainThread = + sentryProfile.threadMetadata.entries.firstOrNull { it.value.name == "main" }?.key + + val samples = sentryProfile.samples.filter { it.threadId == mainThread } + + // Sample size base on 101 samples/sec and 5 sec of profiling + // So expected around 500 samples (with some margin) + assertTrue( + samples.count() >= 500 && samples.count() <= 600, + "Expected sample count between 500 and 600, but was ${samples.count()}", + ) + } + + @Test + fun `check no duplicate frames`() { + val file = loadFile("async_profiler_test_sample.jfr") + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + val frameSet = sentryProfile.frames.toSet() + + assertEquals(frameSet.size, sentryProfile.frames.size) + } + + @Test + fun `convertFromFile with valid JFR returns populated SentryProfile`() { + val file = loadFile("async_profiler_test_sample.jfr") + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + assertNotNull(sentryProfile) + assertValidSentryProfile(sentryProfile) + } + + @Test + fun `convertFromFile parses timestamps correctly`() { + val file = loadFile("async_profiler_test_sample.jfr") + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + val samples = sentryProfile.samples + assertTrue(samples.isNotEmpty()) + + val minTimestamp = samples.minOf { it.timestamp } + val maxTimestamp = samples.maxOf { it.timestamp } + val sampleTimeStamp = + DateUtils.nanosToDate((maxTimestamp * 1000 * 1000 * 1000).toLong()).toInstant() + + // The sample was recorded around "2025-09-05T08:14:50" in UTC timezone + val referenceTimestamp = LocalDateTime.parse("2025-09-05T08:14:50").toInstant(ZoneOffset.UTC) + val between = ChronoUnit.MILLIS.between(sampleTimeStamp, referenceTimestamp).absoluteValue + + assertTrue(between < 5000, "Sample timestamp should be within 5s of reference timestamp") + assertTrue(maxTimestamp >= minTimestamp, "Max timestamp should be >= min timestamp") + assertTrue( + maxTimestamp - minTimestamp <= 10, + "There should be a max difference of <10s between min and max timestamp", + ) + } + + @Test + fun `convertFromFile extracts thread metadata correctly`() { + val file = loadFile("async_profiler_test_sample.jfr") + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + val threadMetadata = sentryProfile.threadMetadata + val samples = sentryProfile.samples + + assertTrue(threadMetadata.isNotEmpty()) + + // Verify thread IDs in samples match thread metadata keys + val threadIdsFromSamples = samples.map { it.threadId }.toSet() + threadIdsFromSamples.forEach { threadId -> + assertTrue( + threadMetadata.containsKey(threadId), + "Thread metadata should contain thread ID: $threadId", + ) + } + + // Verify thread metadata has proper values + threadMetadata.forEach { (_, metadata) -> + assertNotNull(metadata.name, "Thread name should not be null") + assertEquals(0, metadata.priority, "Thread priority should be default (0)") + } + } + + @Test + fun `converter processes frames with complete information`() { + val file = loadFile("async_profiler_test_sample.jfr") + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + val frames = sentryProfile.frames + 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 + } + + assertTrue(completeFrames.isNotEmpty(), "Should have frames with complete information") + } + + @Test + fun `converter marks in-app frames correctly`() { + val file = loadFile("async_profiler_test_sample.jfr") + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + 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 inappSentryFrames = + frames.filter { frame -> frame.module?.startsWith("io.sentry.") ?: false } + + val emptyModuleFrames = frames.filter { it.module.isNullOrEmpty() } + + // Verify system classes are not marked as in-app + systemFrames.forEach { frame -> + assertFalse(frame.isInApp ?: false, "System classes should not be marked as in app") + } + + // Verify sentry classes are marked as in-app + inappSentryFrames.forEach { frame -> + assertTrue(frame.isInApp ?: false, "Sentry classes should be marked as in app") + } + + // Verify empty class names are marked as not in-app + emptyModuleFrames.forEach { frame -> + assertFalse(frame.isInApp ?: true, "Empty module frame should not be in-app") + } + } + + @Test + fun `converter filters native methods`() { + val file = loadFile("async_profiler_test_sample.jfr") + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + // Native methods should be filtered out during frame creation + // Verify no frames have characteristics of native methods + sentryProfile.frames.forEach { frame -> + // Native methods would have been skipped, so no frame should indicate native + assertTrue( + frame.filename?.isNotEmpty() == true || frame?.module?.isNotEmpty() == true, + "Frame should have some non-native information", + ) + } + } + + @Test(expected = IOException::class) + fun `convertFromFile with non-existent file throws IOException`() { + val nonExistentFile = "/non/existent/file.jfr" + + JfrAsyncProfilerToSentryProfileConverter.convertFromFileStatic(nonExistentFile) + } + + private fun loadFile(path: String): String = javaClass.classLoader!!.getResource(path)!!.file + + private fun assertValidSentryProfile(profile: SentryProfile) { + assertNotNull(profile.samples, "Samples should not be null") + assertNotNull(profile.frames, "Frames should not be null") + assertNotNull(profile.stacks, "Stacks should not be null") + assertNotNull(profile.threadMetadata, "Thread metadata should not be null") + } +} diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfilerTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfilerTest.kt new file mode 100644 index 00000000000..7764249e3eb --- /dev/null +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfilerTest.kt @@ -0,0 +1,429 @@ +package io.sentry.asyncprofiler.profiling + +import io.sentry.DataCategory +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ProfileLifecycle +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.TracesSampler +import io.sentry.TransactionContext +import io.sentry.protocol.SentryId +import io.sentry.test.DeferredExecutorService +import io.sentry.transport.RateLimiter +import java.io.File +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.mockito.ArgumentMatchers.startsWith +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class JavaContinuousProfilerTest { + + private val fixture = Fixture() + + private class Fixture { + private val mockDsn = "http://key@localhost/proj" + val executor = DeferredExecutorService() + val mockedSentry = Mockito.mockStatic(Sentry::class.java) + val mockLogger = mock() + val mockTracesSampler = mock() + + val scopes: IScopes = mock() + + lateinit var transaction1: SentryTracer + lateinit var transaction2: SentryTracer + lateinit var transaction3: SentryTracer + + val options = + spy(SentryOptions()).apply { + dsn = mockDsn + profilesSampleRate = 1.0 + isDebug = true + setLogger(mockLogger) + } + + init { + whenever(mockTracesSampler.sampleSessionProfile(any())).thenReturn(true) + } + + fun getSut(optionConfig: ((options: SentryOptions) -> Unit) = {}): JavaContinuousProfiler { + options.executorService = executor + optionConfig(options) + whenever(scopes.options).thenReturn(options) + transaction1 = SentryTracer(TransactionContext("", ""), scopes) + transaction2 = SentryTracer(TransactionContext("", ""), scopes) + transaction3 = SentryTracer(TransactionContext("", ""), scopes) + return JavaContinuousProfiler( + options.logger, + options.profilingTracesDirPath, + options.profilingTracesHz, + options.executorService, + ) + } + } + + @BeforeTest + fun `set up`() { + // Profiler doesn't start if the folder doesn't exists. + // Usually it's generated when calling Sentry.init, but for tests we can create it manually. + + fixture.options.cacheDirPath = "tmp" + File(fixture.options.profilingTracesDirPath!!).mkdirs() + + Sentry.setCurrentScopes(fixture.scopes) + + fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + fixture.mockedSentry.`when` { Sentry.close() }.then { fixture.executor.runAll() } + } + + @AfterTest + fun clear() { + fixture.options.profilingTracesDirPath?.let { File(it).deleteRecursively() } + fixture.options.cacheDirPath?.let { File(it).deleteRecursively() } + + Sentry.stopProfiler() + Sentry.close() + fixture.mockedSentry.close() + } + + @Test + fun `isRunning reflects profiler status`() { + val profiler = fixture.getSut() + 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`() { + val profiler = fixture.getSut() + 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 + profiler.stopProfiler(ProfileLifecycle.MANUAL) + assertFalse(profiler.isRunning) + } + + @Test + fun `profiler multiple starts are ignored in manual mode`() { + val profiler = fixture.getSut() + 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) + } + + @Test + fun `profiler multiple starts are accepted in trace mode`() { + val profiler = fixture.getSut() + + // 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) + + // rootSpanCounter is decremented when the profiler stops in trace mode, and keeps running until + // rootSpanCounter is 0 + profiler.stopProfiler(ProfileLifecycle.TRACE) + 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) + } + + @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.")) + } + + @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()) + } + + @Test + fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() { + 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 + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + verify(fixture.mockTracesSampler, times(2)).sampleSessionProfile(any()) + } + + @Test + fun `profiler ignores profilesSampleRate`() { + val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + profiler.close(true) + } + + @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)) + .log( + SentryLevel.WARNING, + "Disabling profiling because no profiling traces dir path is defined in options.", + ) + } + + @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)) + .log(SentryLevel.WARNING, "Disabling profiling because trace rate is set to %d", 0) + } + + @Test + fun `profiler on tracesDirPath null`() { + val profiler = fixture.getSut { it.cacheDirPath = null } + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertFalse(profiler.isRunning) + } + + @Test + fun `profiler on tracesDirPath empty`() { + val profiler = fixture.getSut { it.cacheDirPath = "" } + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertFalse(profiler.isRunning) + } + + @Test + fun `profiler on profilingTracesHz 0`() { + val profiler = fixture.getSut { it.profilingTracesHz = 0 } + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertFalse(profiler.isRunning) + } + + @Test + fun `profiler does not throw if traces cannot be written to disk`() { + val profiler = fixture.getSut { File(it.profilingTracesDirPath!!).setWritable(false) } + 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()) + val expectedPath = fixture.options.profilingTracesDirPath + verify(fixture.mockLogger) + .log( + eq(SentryLevel.WARNING), + eq( + "Disabling profiling because traces directory is not writable or does not exist: %s (writable=%b, exists=%b)" + ), + eq(expectedPath), + eq(false), + eq(true), + ) + } + + @Test + fun `profiler stops and restart for each chunk`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + + 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) + } + + @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 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() + // At this point the chunk has been submitted to the executor, but yet to be sent + verify(fixture.scopes, never()).captureProfileChunk(any()) + profiler.stopProfiler(ProfileLifecycle.MANUAL) + // We stop the profiler, which should send both the first and last chunk + fixture.executor.runAll() + verify(fixture.scopes, times(2)).captureProfileChunk(any()) + } + + @Test + fun `close without terminating stops all profiles after chunk is finished`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + // We are closing the profiler, which should stop all profiles after the chunk is finished + profiler.close(false) + assertFalse(profiler.isRunning) + // However, close() already resets the rootSpanCounter + assertEquals(0, profiler.rootSpanCounter) + } + + @Test + fun `profiler can be stopped and restarted`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + + profiler.stopProfiler(ProfileLifecycle.MANUAL) + fixture.executor.runAll() + assertFalse(profiler.isRunning) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + fixture.executor.runAll() + + assertTrue(profiler.isRunning) + verify(fixture.mockLogger, never()) + .log(eq(SentryLevel.WARNING), startsWith("JFR file is invalid or empty"), any(), any(), any()) + } + + @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.ProfileChunk)).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) + 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.ProfileChunk)).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) + verify(fixture.mockLogger) + .log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) + } + + @Test + fun `profiler does not start when filename contains invalid characters`() { + val profiler = fixture.getSut { it.profilingTracesDirPath = "," } + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertFalse(profiler.isRunning) + assertEquals(SentryId.EMPTY_ID, profiler.profilerId) + verify(fixture.mockLogger) + .log( + eq(SentryLevel.WARNING), + eq("Disabling profiling because traces directory path contains invalid character: %s"), + eq(","), + ) + } + + fun withMockScopes(closure: () -> Unit) = + Mockito.mockStatic(Sentry::class.java).use { + it.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + closure.invoke() + } +} diff --git a/sentry-async-profiler/src/test/resources/async_profiler_test_sample.jfr b/sentry-async-profiler/src/test/resources/async_profiler_test_sample.jfr new file mode 100644 index 00000000000..35b4c768eac Binary files /dev/null and b/sentry-async-profiler/src/test/resources/async_profiler_test_sample.jfr differ diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt index aa1b2279b83..ca923e5d2c9 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -78,7 +78,7 @@ public fun SentryTraced( } val firstRendered = remember { ImmutableHolder(false) } - val baseModifier = if (enableUserInteractionTracing) Modifier.sentryTag(tag) else modifier + val baseModifier = if (enableUserInteractionTracing) modifier.sentryTag(tag) else modifier Box( modifier = diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index e5e6c89a9d0..55aca007130 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) + alias(libs.plugins.animalsniffer) } tasks.withType().configureEach { @@ -32,6 +33,9 @@ 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") } } @@ -52,6 +56,7 @@ tasks { check { dependsOn(jacocoTestCoverageVerification) dependsOn(jacocoTestReport) + dependsOn(animalsnifferMain) } } diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index 1fb3e98f126..2965e81ebd3 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + alias(libs.plugins.animalsniffer) } tasks.withType().configureEach { @@ -39,6 +40,9 @@ 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") } } @@ -59,6 +63,7 @@ tasks { check { dependsOn(jacocoTestCoverageVerification) dependsOn(jacocoTestReport) + dependsOn(animalsnifferMain) } } diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt index a653304e992..2f559f804fc 100644 --- a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt +++ b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt @@ -137,6 +137,7 @@ public val SentryKtorClientPlugin: ClientPlugin = request.headers.remove(BaggageHeader.BAGGAGE_HEADER) request.headers[it.name] = it.value } + tracingHeaders.w3cTraceparentHeader?.let { request.headers[it.name] = it.value } } } } diff --git a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt index b15ae23d03d..976d3200e11 100644 --- a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt +++ b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt @@ -24,6 +24,7 @@ import io.sentry.SentryTracer import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext +import io.sentry.W3CTraceparentHeader import io.sentry.exception.SentryHttpClientException import io.sentry.mockServerRequestTimeoutMillis import java.util.concurrent.TimeUnit @@ -426,4 +427,29 @@ class SentryKtorClientPluginTest { assertTrue(baggageHeaderValues[0].contains("sentry-transaction=name")) assertTrue(baggageHeaderValues[0].contains("sentry-trace_id")) } + + @Test + fun `adds W3C traceparent header when propagateTraceparent is enabled`(): Unit = runBlocking { + val sut = + fixture.getSut(optionsConfiguration = { options -> options.isPropagateTraceparent = true }) + sut.get(fixture.server.url("/hello").toString()) + + val recordedRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recordedRequest.headers[W3CTraceparentHeader.TRACEPARENT_HEADER]) + } + + @Test + fun `does not add W3C traceparent header when propagateTraceparent is disabled`(): Unit = + runBlocking { + val sut = + fixture.getSut(optionsConfiguration = { options -> options.isPropagateTraceparent = false }) + sut.get(fixture.server.url("/hello").toString()) + + val recordedRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recordedRequest.headers[W3CTraceparentHeader.TRACEPARENT_HEADER]) + } } diff --git a/sentry-okhttp/build.gradle.kts b/sentry-okhttp/build.gradle.kts index e2c10ffc5a1..f7178cf1dfe 100644 --- a/sentry-okhttp/build.gradle.kts +++ b/sentry-okhttp/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + alias(libs.plugins.animalsniffer) } tasks.withType().configureEach { @@ -38,6 +39,9 @@ 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") } } @@ -58,6 +62,7 @@ tasks { check { dependsOn(jacocoTestCoverageVerification) dependsOn(jacocoTestReport) + dependsOn(animalsnifferMain) } } 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 e36d3772bf3..be1ee1caf37 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -116,6 +116,7 @@ public open class SentryOkHttpInterceptor( requestBuilder.removeHeader(BaggageHeader.BAGGAGE_HEADER) requestBuilder.addHeader(it.name, it.value) } + tracingHeaders.w3cTraceparentHeader?.let { requestBuilder.addHeader(it.name, it.value) } } } diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index 5f3198c097c..bbdb3a86516 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -19,6 +19,7 @@ import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.TypeCheckHint +import io.sentry.W3CTraceparentHeader import io.sentry.exception.SentryHttpClientException import io.sentry.mockServerRequestTimeoutMillis import java.io.IOException @@ -645,4 +646,38 @@ class SentryOkHttpInterceptorTest { val okHttpEvent = SentryOkHttpEventListener.eventMap[call]!! assertTrue(okHttpEvent.isEventFinished.get()) } + + @Test + fun `adds W3C traceparent header when propagateTraceparent is enabled`() { + val client = + fixture.getSut( + optionsConfiguration = Sentry.OptionsConfiguration { it.isPropagateTraceparent = true } + ) + + fixture.server.enqueue(MockResponse().setResponseCode(200)) + + val request = getRequest("/test") + client.newCall(request).execute() + + val recordedRequest = fixture.server.takeRequest() + assertNotNull(recordedRequest.getHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) + assertNotNull(recordedRequest.getHeader(W3CTraceparentHeader.TRACEPARENT_HEADER)) + } + + @Test + fun `does not add W3C traceparent header when propagateTraceparent is disabled`() { + val client = + fixture.getSut( + optionsConfiguration = Sentry.OptionsConfiguration { it.isPropagateTraceparent = false } + ) + + fixture.server.enqueue(MockResponse().setResponseCode(200)) + + val request = getRequest("/test") + client.newCall(request).execute() + + val recordedRequest = fixture.server.takeRequest() + assertNotNull(recordedRequest.getHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) + assertNull(recordedRequest.getHeader(W3CTraceparentHeader.TRACEPARENT_HEADER)) + } } diff --git a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java index 35b2b5c8fdb..acd73bbec7c 100644 --- a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java +++ b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java @@ -17,6 +17,7 @@ import io.sentry.SpanDataConvention; import io.sentry.SpanOptions; import io.sentry.SpanStatus; +import io.sentry.W3CTraceparentHeader; import io.sentry.util.Objects; import io.sentry.util.SpanUtils; import io.sentry.util.TracingUtils; @@ -137,6 +138,12 @@ public Response execute(final @NotNull Request request, final @NotNull Request.O requestWrapper.removeHeader(BaggageHeader.BAGGAGE_HEADER); requestWrapper.header(baggageHeader.getName(), baggageHeader.getValue()); } + + final @Nullable W3CTraceparentHeader w3cTraceparentHeader = + tracingHeaders.getW3cTraceparentHeader(); + if (w3cTraceparentHeader != null) { + requestWrapper.header(w3cTraceparentHeader.getName(), w3cTraceparentHeader.getValue()); + } } return requestWrapper.build(); 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 66fbd86a91a..571a2339326 100644 --- a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt +++ b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt @@ -16,6 +16,7 @@ import io.sentry.SentryTracer import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext +import io.sentry.W3CTraceparentHeader import io.sentry.mockServerRequestTimeoutMillis import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest @@ -316,6 +317,32 @@ class SentryFeignClientTest { assertNotNull(httpClientSpan.spanContext.sampled) { assertFalse(it) } } + @Test + fun `adds W3C traceparent header when propagateTraceparent is enabled`() { + fixture.sentryOptions.isTraceSampling = true + fixture.sentryOptions.isPropagateTraceparent = true + fixture.sentryOptions.dsn = "https://key@sentry.io/proj" + val sut = fixture.getSut() + sut.getOk() + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[W3CTraceparentHeader.TRACEPARENT_HEADER]) + } + + @Test + fun `does not add W3C traceparent header when propagateTraceparent is disabled`() { + fixture.sentryOptions.isTraceSampling = true + fixture.sentryOptions.isPropagateTraceparent = false + fixture.sentryOptions.dsn = "https://key@sentry.io/proj" + val sut = fixture.getSut() + sut.getOk() + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recorderRequest.headers[W3CTraceparentHeader.TRACEPARENT_HEADER]) + } + interface MockApi { @RequestLine("GET /status/200") fun getOk(): String diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java index ae32ced33e6..bb374f4a517 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java @@ -14,6 +14,7 @@ import io.sentry.Baggage; import io.sentry.DateUtils; import io.sentry.IScopes; +import io.sentry.ProfileLifecycle; import io.sentry.PropagationContext; import io.sentry.ScopesAdapter; import io.sentry.Sentry; @@ -82,6 +83,17 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri final @Nullable Boolean sampled = isSampled(otelSpan, samplingDecision); + if (Boolean.TRUE.equals(sampled) && isRootSpan(otelSpan.toSpanData())) { + if (scopes.getOptions().isContinuousProfilingEnabled() + && scopes.getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE) { + scopes + .getOptions() + .getContinuousProfiler() + .startProfiler( + ProfileLifecycle.TRACE, scopes.getOptions().getInternalTracesSampler()); + } + } + final @NotNull PropagationContext propagationContext = new PropagationContext( new SentryId(traceId), sentrySpanId, sentryParentSpanId, baggage, sampled); @@ -105,6 +117,9 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri sentryParentSpanId, baggage); sentrySpan.getSpanContext().setOrigin(SentrySpanExporter.TRACE_ORIGIN); + sentrySpan + .getSpanContext() + .setProfilerId(scopes.getOptions().getContinuousProfiler().getProfilerId()); spanStorage.storeSentrySpan(spanContext, sentrySpan); } @@ -159,6 +174,13 @@ public boolean isStartRequired() { public void onEnd(final @NotNull ReadableSpan spanBeingEnded) { final @Nullable IOtelSpanWrapper sentrySpan = spanStorage.getSentrySpan(spanBeingEnded.getSpanContext()); + + if (isRootSpan(spanBeingEnded.toSpanData()) + && scopes.getOptions().isContinuousProfilingEnabled() + && scopes.getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE) { + scopes.getOptions().getContinuousProfiler().stopProfiler(ProfileLifecycle.TRACE); + } + if (sentrySpan != null) { final @NotNull SentryDate finishDate = new SentryLongDate(spanBeingEnded.toSpanData().getEndEpochNanos()); diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 56f270d235b..acec6ac809c 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -150,6 +150,7 @@ dependencies { implementation(libs.sentry.native.ndk) implementation(libs.timber) + debugImplementation(projects.sentryAndroidDistribution) debugImplementation(libs.leakcanary) } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 9d084ed97e1..65445d85706 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -76,7 +76,7 @@ android:exported="false"/> - + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 1b9acc3c267..25907655f7f 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -5,12 +5,15 @@ import android.content.res.Configuration; import android.os.Bundle; import android.os.Handler; +import android.widget.Toast; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import io.sentry.Attachment; import io.sentry.ISpan; import io.sentry.MeasurementUnit; import io.sentry.Sentry; +import io.sentry.SentryLogLevel; +import io.sentry.UpdateStatus; import io.sentry.instrumentation.file.SentryFileOutputStream; import io.sentry.protocol.Feedback; import io.sentry.protocol.User; @@ -304,7 +307,44 @@ public void run() { Sentry.replay().enableDebugMaskingOverlay(); }); + binding.checkForUpdate.setOnClickListener( + view -> { + Toast.makeText(this, "Checking for updates...", Toast.LENGTH_SHORT).show(); + Sentry.distribution() + .checkForUpdate( + result -> { + runOnUiThread( + () -> { + String message; + if (result instanceof UpdateStatus.NewRelease) { + UpdateStatus.NewRelease newRelease = (UpdateStatus.NewRelease) result; + message = + "Update available: " + + newRelease.getInfo().getBuildVersion() + + " (Build " + + newRelease.getInfo().getBuildNumber() + + ")\nDownload URL: " + + newRelease.getInfo().getDownloadUrl(); + } else if (result instanceof UpdateStatus.UpToDate) { + message = "App is up to date!"; + } else if (result instanceof UpdateStatus.NoNetwork) { + UpdateStatus.NoNetwork noNetwork = (UpdateStatus.NoNetwork) result; + message = "No network connection: " + noNetwork.getMessage(); + } else if (result instanceof UpdateStatus.UpdateError) { + UpdateStatus.UpdateError error = (UpdateStatus.UpdateError) result; + message = "Error checking for updates: " + error.getMessage(); + } else { + message = "Unknown status"; + } + Toast.makeText(this, message, Toast.LENGTH_LONG).show(); + }); + }); + }); + + Sentry.logger().log(SentryLogLevel.INFO, "Creating content view"); setContentView(binding.getRoot()); + + Sentry.logger().log(SentryLogLevel.INFO, "MainActivity created"); } private void stackOverflow() { diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index d2eda41a387..0083fae8f93 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -170,6 +170,12 @@ android:layout_height="wrap_content" android:text="@string/enable_replay_debug_mode"/> +