diff --git a/.claude/skills/create-java-pr/SKILL.md b/.claude/skills/create-java-pr/SKILL.md index 70bc4453a58..ef813f4b1c8 100644 --- a/.claude/skills/create-java-pr/SKILL.md +++ b/.claude/skills/create-java-pr/SKILL.md @@ -74,10 +74,10 @@ Derive the branch name from the changes being made. Use `feat/`, `fix/`, `ref/`, ## Step 2: Format Code and Regenerate API Files ```bash -./gradlew spotlessApply updateKotlinAbi +make format api ``` -This is **required** before every PR in this repo. It formats all Java/Kotlin code via Spotless and regenerates the `.api` binary compatibility files. +This is **required** before every PR in this repo. It formats all Java/Kotlin code via Spotless, including the separate builds the root `spotlessApply` doesn't reach, and regenerates the `.api` binary compatibility files. If the command fails, diagnose and fix the issue before continuing. diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 9400b46c783..9e063579e90 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -13,15 +13,26 @@ rule file in [`.cursor/rules/`](rules) for the area the diff touches (`api`, `op - While we don't want to crash or hang the host application, we also don't want to leave the host application in a bad or unrecoverable state. Therefore catch the narrowest type the guarded code can throw. -- Existing broad catches like `catch (Throwable)` are legacy, not precedent. Where a broad catch is - genuinely unavoidable (an entry point running user code or third-party callbacks), it must call - `ExceptionUtils.rethrowIfFatal(t)` first and a code comment must say why the broad catch is - needed. +- Flag any new broad catch (`Throwable`, `Error`, `Exception`, `RuntimeException`) outside a + boundary where we can't know what the other side throws, e.g. public API entry points like + `captureException`, or Android framework calls that cross into another process + (`ContentResolver`, system services). Existing broad catches are legacy, not precedent. See the + Exception Handling section in `AGENTS.md`. +- A broad catch at a boundary must call `ExceptionUtils.rethrowIfFatal(t)` first and requires a + code comment saying why the catch is broad and what happens on failure (event dropped, feature + disabled, ...). +- A broad catch that wraps user code requires documentation, both in code and in sentry-docs, + explaining how the user is expected to discover that the code they wrote isn't working. + sentry-docs is a separate repository, so ask the author to link the docs PR. - Code probing for an optional `compileOnly` dependency must catch the specific `LinkageError` - subclass (`NoClassDefFoundError`, `NoSuchMethodError`, ...) only. + subclass (`NoClassDefFoundError`, `NoSuchMethodError`, ...). Flag a probe guarded only by a broad + catch with `rethrowIfFatal`: it rethrows every `LinkageError`, crashing the host app when the + dependency is missing. +- Logging is not handling: `options.getLogger()` is silent unless `debug` is enabled. When a catch + only logs, ask the author what the customer sees when it fires. - The SDK must never `captureException`/`captureMessage` for its own failures or for exceptions - thrown inside user callbacks (`beforeSend`, `beforeBreadcrumb`, `tracesSampler`, ...). Log via - `options.getLogger()` instead — capturing here loops. See + thrown inside user callbacks (`beforeSend`, `beforeBreadcrumb`, `tracesSampler`, ...) — capturing + here loops. See [Never capture your own exceptions](https://develop.sentry.dev/sdk/getting-started/principles/#never-capture-your-own-exceptions). - Flag `System.out`/`System.err`, `printStackTrace()`, and `android.util.Log` in SDK source; use `options.getLogger().log(...)`. diff --git a/.cursor/rules/e2e_tests.mdc b/.cursor/rules/e2e_tests.mdc index 3603794cd5c..3de11557f87 100644 --- a/.cursor/rules/e2e_tests.mdc +++ b/.cursor/rules/e2e_tests.mdc @@ -83,8 +83,9 @@ it. Gradle runs the two in parallel. customizes, and that the OpenTelemetry starter instruments, so it adds or replaces trace headers on the requests a test sends. Build one outside the context. - Sentry exports OpenTelemetry spans in batches every 5 seconds. In-process, flush the tracer - provider from the server's hook. With the agent this isn't possible, so each test waits for a - batch and negative checks wait at least 6 seconds. + provider from the server's hook. With the agent this isn't possible, so set + `OTEL_BSP_SCHEDULE_DELAY` on the app to shorten the batch delay, and make negative checks wait + well past it. - Sentry is global, so a second live Spring context in an OpenTelemetry suite takes it over. Keep one live context per JVM (`@DirtiesContext` when a test needs its own). - The async profiler rejects a traces path with a `.` anywhere in its absolute form. Put the path diff --git a/.cursor/rules/new_module.mdc b/.cursor/rules/new_module.mdc index b9fdde3df6a..77f8e7367fa 100644 --- a/.cursor/rules/new_module.mdc +++ b/.cursor/rules/new_module.mdc @@ -31,16 +31,11 @@ the release that first ships it. All of the following have to be merged **before 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}" - ) -) -``` +If adding a sample, add an `include("…")` line for it to `sentry-samples/settings.gradle.kts` +instead, and apply `id("io.sentry.spotless")` and `id("io.sentry.jdk-target")` in its `plugins` +block. The samples are a separate Gradle build that includes the SDK build, so a sample depends on +`"io.sentry:sentry-{module-name}:$sentryVersion"` (with `val sentryVersion = +libs.versions.sentry.get()`), and Gradle substitutes the SDK's project for it. 3. Add a `SENTRY_{MODULE}_SDK_NAME` constant to the `Config.Sentry` block in `buildSrc/src/main/java/Config.kt`: diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2824699563c..d1593330dd3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,7 +7,12 @@ registries: password: dummy # Required by dependabot updates: - package-ecosystem: "gradle" - directory: "/" + # The root build doesn't include the separate sample and test builds. + directories: + - "/" + - "/sentry-samples/sentry-samples-android" + - "/sentry-android-integration-tests" + - "/sentry-samples" registries: - gradle-plugin-portal schedule: diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 81127cb468f..7b1980b114d 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -24,13 +24,15 @@ jobs: env: VERSION_AGP: ${{ matrix.agp }} APPLY_SENTRY_INTEGRATIONS: ${{ matrix.integrations }} + # Upload replay snapshots from one cell only. Forks don't have access to the upload secret. + UPLOAD_REPLAY_SNAPSHOTS: ${{ matrix.agp == '9.4.0' && matrix.integrations && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Java Version - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '21' @@ -51,20 +53,21 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-api-30-x86_64-aosp_atd + key: avd-api-30-x86_64-google_apis - name: Create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 with: api-level: 30 - target: aosp_atd - channel: canary # Necessary for ATDs + # Not an ATD image: PixelCopy returns no data on ATDs, which the replay tests need + target: google_apis + channel: canary arch: x86_64 force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + emulator-options: -memory 4096 -no-window -gpu auto -noaudio -no-boot-anim -camera-back none disk-size: 4096M script: echo "Generated AVD snapshot for caching." @@ -77,15 +80,28 @@ jobs: uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 with: api-level: 30 - target: aosp_atd - channel: canary # Necessary for ATDs + # Not an ATD image: PixelCopy returns no data on ATDs, which the replay tests need + target: google_apis + channel: canary arch: x86_64 force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + emulator-options: -memory 4096 -no-snapshot-save -no-window -gpu auto -noaudio -no-boot-anim -camera-back none disk-size: 4096M - script: ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest -Denvironment=github --daemon + script: ./scripts/test-ui-android.sh replay-snapshots + + - name: Install Sentry CLI + if: ${{ !cancelled() && env.UPLOAD_REPLAY_SNAPSHOTS == 'true' }} + uses: getsentry/action-setup-cli@70d7e587b84c2e78cf4d37cd33d7b74fb3729c1b # v1 + + - name: Upload Replay Snapshots to Sentry + if: ${{ !cancelled() && env.UPLOAD_REPLAY_SNAPSHOTS == 'true' && hashFiles('replay-snapshots/*.png') != '' }} + run: sentry-cli snapshots upload ./replay-snapshots --app-id sentry-android-replay + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: sentry-sdks + SENTRY_PROJECT: sentry-android - name: Upload test results if: always() @@ -96,6 +112,7 @@ jobs: **/build/reports/* **/build/outputs/*/connected/* **/build/outputs/mapping/release/* + replay-snapshots/ - name: Test Report uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 36c62fbe188..351dd6c3da1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Setup Java Version - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '21' diff --git a/.github/workflows/changes-in-high-risk-code.yml b/.github/workflows/changes-in-high-risk-code.yml index 61ee69334a7..a800284fd0a 100644 --- a/.github/workflows/changes-in-high-risk-code.yml +++ b/.github/workflows/changes-in-high-risk-code.yml @@ -39,11 +39,79 @@ jobs: high_risk_code: ${{ needs.files-changed.outputs.high_risk_code_files }} with: script: | - const highRiskFiles = process.env.high_risk_code; - const fileList = highRiskFiles.split(',').map(file => `- [ ] ${file}`).join('\n'); - github.rest.issues.createComment({ + const highRiskFiles = process.env.high_risk_code + .split(',') + .map(file => file.trim()) + .filter(Boolean); + + const marker = '🚨 Detected changes in high risk code 🚨'; + const intro = `### ${marker} \n High-risk code has higher potential to break the SDK and may be hard to test. To prevent severe bugs, apply the rollout process for releasing such changes and be extra careful when changing and reviewing these files:`; + + const buildBody = (files, checkedFiles = new Set()) => { + const fileList = files + .map(file => `- [${checkedFiles.has(file) ? 'x' : ' '}] ${file}`) + .join('\n'); + return `${intro}\n${fileList}`; + }; + + const parseCheckedFiles = (commentBody = '') => { + const checked = new Set(); + for (const match of commentBody.matchAll(/^[ \t]*- \[x\] (.+)$/gim)) { + checked.add(match[1].trim()); + } + return checked; + }; + + const parseListedFiles = (commentBody = '') => { + const listed = []; + for (const match of commentBody.matchAll(/^[ \t]*- \[[ xX]\] (.+)$/gim)) { + listed.push(match[1].trim()); + } + return listed; + }; + + const sameFileSet = (a, b) => { + if (a.length !== b.length) return false; + const left = [...a].sort().join('\n'); + const right = [...b].sort().join('\n'); + return left === right; + }; + + // Get existing comments (paginate so older comments are not missed) + const comments = await github.paginate(github.rest.issues.listComments, { issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: `### 🚨 Detected changes in high risk code 🚨 \n High-risk code has higher potential to break the SDK and may be hard to test. To prevent severe bugs, apply the rollout process for releasing such changes and be extra careful when changing and reviewing these files:\n ${fileList}` - }) + per_page: 100 + }); + + const existingComment = comments.find(comment => + comment.body?.includes(marker) + ); + + // Keep one warning comment. Only rewrite when the file set changes, + // and preserve reviewer checkbox state for files that remain. + if (existingComment) { + const previousFiles = parseListedFiles(existingComment.body); + if (sameFileSet(previousFiles, highRiskFiles)) { + return; + } + + const body = buildBody( + highRiskFiles, + parseCheckedFiles(existingComment.body) + ); + await github.rest.issues.updateComment({ + comment_id: existingComment.id, + owner: context.repo.owner, + repo: context.repo.repo, + body + }); + } else { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: buildBody(highRiskFiles) + }); + } diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d0d096a77b7..c13eb29178c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Java Version - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '21' @@ -29,7 +29,7 @@ jobs: uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Initialize CodeQL - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # pin@v2 + uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # pin@v2 with: languages: 'java' @@ -38,4 +38,4 @@ jobs: ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # pin@v2 + uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # pin@v2 diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 20f089a42e5..32bbd532f41 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -14,7 +14,7 @@ jobs: uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Set up Java - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '21' diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index fb1a1da7102..69e28271add 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -11,7 +11,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: set up JDK 17 - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '21' @@ -20,7 +20,7 @@ jobs: uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Format with spotlessApply - run: ./gradlew spotlessApply + run: make format # actions/checkout fetches only a single commit in a detached HEAD state. Therefore # we need to pass the current branch, otherwise we can't commit the changes. diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index aaf447372de..c52879b6a27 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -12,7 +12,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: set up JDK 17 - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '21' diff --git a/.github/workflows/integration-tests-macrobenchmark.yml b/.github/workflows/integration-tests-macrobenchmark.yml index 654e8c17f76..f4bf120b7e0 100644 --- a/.github/workflows/integration-tests-macrobenchmark.yml +++ b/.github/workflows/integration-tests-macrobenchmark.yml @@ -1,5 +1,5 @@ name: 'Integration Tests - Macrobenchmark' -# Runs the sentry-uitest-android-macrobenchmark cold-start benchmark on a Sauce Labs real +# Runs the sentry-samples-android macrobenchmark cold-start benchmark on a Sauce Labs real # device and recovers timeToInitialDisplay from the device log. # on: @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: 'Set up Java: 17' - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '21' @@ -33,7 +33,7 @@ jobs: - name: Assemble target app and Macrobenchmark apk if: env.SAUCE_USERNAME != null - run: ./gradlew :sentry-samples:sentry-samples-android:assembleRelease :sentry-android-integration-tests:sentry-uitest-android-macrobenchmark:assembleBenchmark + run: ./gradlew -p sentry-samples/sentry-samples-android :assembleRelease :macrobenchmark:assembleBenchmark - name: Run Macrobenchmark in SauceLab uses: saucelabs/saucectl-run-action@283660aa934c02723c497efa151d582a3acc5801 # pin@v3 diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index 9705499f430..58e35d7ca13 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Java Version - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: "temurin" java-version: "21" @@ -29,6 +29,6 @@ jobs: uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Size Analysis - run: ./gradlew :sentry-android-integration-tests:test-app-size:bundleRelease + run: ./gradlew -p sentry-android-integration-tests :test-app-size:bundleRelease env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index de3613da606..4f5f3535ccb 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java 17 - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '21' @@ -105,6 +105,30 @@ jobs: fi echo "version=$("$SDK/cmdline-tools/latest/bin/sdkmanager" --version 2>/dev/null | grep -Eo '^[0-9][0-9.]*' | head -1)" >> "$GITHUB_OUTPUT" + # Snapshots only load on the emulator version that created them. The canary channel + # moves often, so a cached snapshot from an older emulator is silently rejected and the + # emulator cold boots. Tests then run while the system is still busy with first-boot work. + # Install the emulator up front (the runner reuses it) and key the AVD cache on its version. + - name: Install emulator + id: emulator + run: | + SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" + case "${{ matrix.channel }}" in + stable) CHANNEL=0 ;; + beta) CHANNEL=1 ;; + dev) CHANNEL=2 ;; + canary) CHANNEL=3 ;; + *) echo "Unknown channel: ${{ matrix.channel }}"; exit 1 ;; + esac + yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --install emulator --channel="$CHANNEL" > /dev/null + VERSION=$(grep -E '^Pkg\.Revision=' "$SDK/emulator/source.properties" | cut -d= -f2) + if [ -z "$VERSION" ]; then + echo "Could not read the emulator version from $SDK/emulator/source.properties" + exit 1 + fi + echo "Emulator version: $VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: AVD cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: avd-cache @@ -112,9 +136,9 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - # Keyed on memory and the cmdline-tools version so incompatible snapshots - # and AVDs created by the old, broken avdmanager are invalidated automatically. - key: avd-api-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }}-memory${{ matrix.memory }}-tools${{ steps.cmdline-tools.outputs.version }} + # Keyed on memory, the cmdline-tools version and the emulator version so incompatible + # snapshots and AVDs created by the old, broken avdmanager are invalidated automatically. + key: avd-api-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }}-memory${{ matrix.memory }}-tools${{ steps.cmdline-tools.outputs.version }}-emulator${{ steps.emulator.outputs.version }} - name: Create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' @@ -143,6 +167,9 @@ jobs: - name: Run tests uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2.38.0 + env: + # Default is 15s. In case of an emulator cold-boot due to cache invalidation this isn't always enough. + MAESTRO_DRIVER_STARTUP_TIMEOUT: 60000 with: api-level: ${{ matrix.api-level }} target: ${{ matrix.target }} @@ -155,7 +182,7 @@ jobs: script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" - mkdir "${{env.BASE_PATH}}/maestro-logs/" || true; adb emu screenrecord start --time-limit 360 "${{env.BASE_PATH}}/maestro-logs/recording.webm" || true; maestro test "${{env.BASE_PATH}}/maestro" --test-output-dir="${{env.BASE_PATH}}/maestro-logs/test-output" || MAESTRO_EXIT_CODE=$?; adb emu screenrecord stop || true; adb logcat -d > "${{env.BASE_PATH}}/maestro-logs/logcat.txt" || true; exit ${MAESTRO_EXIT_CODE:-0} + mkdir "${{env.BASE_PATH}}/maestro-logs/" || true; adb emu screenrecord start --time-limit 360 "${{env.BASE_PATH}}/maestro-logs/recording.webm" || true; maestro test "${{env.BASE_PATH}}/maestro" --test-output-dir="${{env.BASE_PATH}}/maestro-logs/test-output" --debug-output="${{env.BASE_PATH}}/maestro-logs/debug-output" --flatten-debug-output || MAESTRO_EXIT_CODE=$?; adb emu screenrecord stop || true; adb logcat -d > "${{env.BASE_PATH}}/maestro-logs/logcat.txt" || true; exit ${MAESTRO_EXIT_CODE:-0} - name: Upload Maestro test results if: ${{ always() }} diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml deleted file mode 100644 index 5704347f497..00000000000 --- a/.github/workflows/integration-tests-ui.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: 'Integration Tests' -on: - push: - branches: - - main - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - name: Ui tests - runs-on: ubuntu-latest - - # we copy the secret to the env variable in order to access it in the workflow - env: - SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} - SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} - - steps: - - name: Git checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: 'Set up Java: 17' - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 - with: - distribution: 'temurin' - java-version: '21' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - - # Clean, build and release a test apk, but only if we will run the benchmark - - name: Make assembleUiTests - if: env.SAUCE_USERNAME != null - run: make assembleUiTests - - - name: Install SauceLabs CLI - uses: saucelabs/saucectl-run-action@283660aa934c02723c497efa151d582a3acc5801 # pin@v4.5.0 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - skip-run: true - if: env.SAUCE_USERNAME != null - - - name: Run Tests - id: saucelabs - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - exec &> >(tee -a "test_logs.txt") - saucectl run -c .sauce/sentry-uitest-android-ui.yml - if: env.SAUCE_USERNAME != null - continue-on-error: true - - - name: Verify Test Results - run: | - processCrashed=$(cat test_logs.txt | grep "Instrumentation run failed due to 'Process crashed.'" | wc -l) - if [[ ${{ steps.saucelabs.outcome }} == 'success' ]]; then - exit 0 - elif [[ "$processCrashed" -ne 0 ]]; then - exit 0 - else - exit 1 - fi - if: env.SAUCE_USERNAME != null - - - - name: Install Sentry CLI - if: ${{ !cancelled() && env.SAUCE_USERNAME != null }} - uses: getsentry/action-setup-cli@70d7e587b84c2e78cf4d37cd33d7b74fb3729c1b # v1 - - - name: Upload Replay Snapshots to Sentry - # Skip on PRs from forks, which don't have access to the upload secret - if: ${{ !cancelled() && env.SAUCE_USERNAME != null && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} - run: | - # find instead of `shopt -s globstar`, which macOS's bash 3.2 doesn't support - mkdir -p replay-snapshots - if [ -d artifacts ]; then - find artifacts -type f -name '*.png' -exec cp {} replay-snapshots/ \; - fi - if [ -n "$(ls -A replay-snapshots)" ]; then - sentry-cli snapshots upload ./replay-snapshots \ - --app-id sentry-android-replay - else - echo "No replay snapshot files found, skipping upload" - fi - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: sentry-sdks - SENTRY_PROJECT: sentry-android diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 46882ef200b..425fb334a75 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Java Version - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '21' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a3db95c974..785e319eab2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: # Needs to be set, otherwise git describe --tags will fail with: No names found, cannot describe anything fetch-depth: 0 - name: Prepare release - uses: getsentry/craft@2e29b0819113d334c15da3d8cac2138cb9dc111a # 3.9.0 + uses: getsentry/craft@9567926a606072678ef367b02445c4f99f374795 # 3.11.2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml new file mode 100644 index 00000000000..2392455970c --- /dev/null +++ b/.github/workflows/samples.yml @@ -0,0 +1,52 @@ +name: 'Samples' +# Runs `check` in each build the SDK build doesn't include, so a broken sample or +# integration test fails its own job instead of the SDK's Build job. +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Check ${{ matrix.name }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + - name: sentry-samples-android + path: sentry-samples/sentry-samples-android + - name: sentry-android-integration-tests + path: sentry-android-integration-tests + - name: sentry-samples + path: sentry-samples + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Java Version + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + + - name: Run Tests and Lint + run: ./gradlew -p ${{ matrix.path }} check + + - name: Upload test results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: test-results-${{ matrix.name }} + path: | + **/build/reports/* diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 3d18ffa28bf..f3a782ebc79 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -47,7 +47,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '21' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index d72fa7c8d9e..e1a77de92ab 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -47,7 +47,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '21' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 43bf788cf22..871e07e74a4 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -47,7 +47,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '21' diff --git a/.sauce/sentry-uitest-android-macrobenchmark.yml b/.sauce/sentry-uitest-android-macrobenchmark.yml index 4e3d5cfbb3d..a6ef85e7c4a 100644 --- a/.sauce/sentry-uitest-android-macrobenchmark.yml +++ b/.sauce/sentry-uitest-android-macrobenchmark.yml @@ -15,7 +15,7 @@ defaults: espresso: app: ./sentry-samples/sentry-samples-android/build/outputs/apk/release/sentry-samples-android-release.apk - testApp: ./sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build/outputs/apk/benchmark/sentry-uitest-android-macrobenchmark-benchmark.apk + testApp: ./sentry-samples/sentry-samples-android/macrobenchmark/build/outputs/apk/benchmark/macrobenchmark-benchmark.apk suites: diff --git a/.sauce/sentry-uitest-android-ui.yml b/.sauce/sentry-uitest-android-ui.yml deleted file mode 100644 index a00ee10614b..00000000000 --- a/.sauce/sentry-uitest-android-ui.yml +++ /dev/null @@ -1,36 +0,0 @@ -apiVersion: v1alpha -kind: espresso -sauce: - region: us-west-1 - # Controls how many suites are executed at the same time (sauce test env only). - concurrency: 4 - metadata: - build: sentry-uitest-android-ui-$GITHUB_REF-$GITHUB_SHA - tags: - - e2e - - android - -defaults: - timeout: 45m - -espresso: - app: ./sentry-android-integration-tests/sentry-uitest-android/build/outputs/apk/release/sentry-uitest-android-release.apk - testApp: ./sentry-android-integration-tests/sentry-uitest-android/build/outputs/apk/androidTest/release/sentry-uitest-android-release-androidTest.apk -suites: - - - name: "Android 15 Ui test (api 35)" - testOptions: - clearPackageData: true - useTestOrchestrator: true - devices: - - name: ".*" - platformVersion: "15" - -# Controls what artifacts to fetch when the suite on Sauce Cloud has finished. -artifacts: - download: - when: always - match: - - junit.xml - - "*.png" - directory: ./artifacts/ diff --git a/AGENTS.md b/AGENTS.md index 8e1917f90d8..ecec67d375b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,8 +41,8 @@ The project uses **Gradle** with Kotlin DSL. Key build files: ## Essential Commands ```bash -# Format code and regenerate .api files (REQUIRED before committing) -./gradlew spotlessApply updateKotlinAbi +# Format code in every build and regenerate .api files (REQUIRED before committing) +make format api # Run all tests and linter ./gradlew check @@ -59,11 +59,17 @@ resolves the per-module test task, including integration test suites, for you. ### Android-Specific Commands ```bash -# Assemble Android test APKs -./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest +# Assemble Android test APKs, in a separate Gradle build that includes this one +./gradlew -p sentry-android-integration-tests :sentry-uitest-android:assembleRelease :sentry-uitest-android:assembleAndroidTest # Run critical UI tests ./scripts/test-ui-critical.sh + +# Build the Android sample, a separate Gradle build that includes this one +./gradlew -p sentry-samples/sentry-samples-android assembleDebug + +# Build the JVM samples, another separate Gradle build that includes this one +./gradlew -p sentry-samples assemble ``` ## Development Workflow Rules @@ -74,7 +80,7 @@ resolves the per-module test task, including integration test suites, for you. 3. **Use todo tracking**: Work through todo items, marking them as complete as you go 4. **High-level communication**: Give high-level explanations of changes made, not step-by-step descriptions 5. **Simplicity first**: Make every task and code change as simple as possible. Avoid massive or complex changes. Impact as little code as possible. -6. **Format and regenerate**: Once done, format code and regenerate .api files: `./gradlew spotlessApply updateKotlinAbi` +6. **Format and regenerate**: Once done, format code and regenerate .api files: `make format api` 7. **Propose commit**: As final step, git stage relevant files and propose (but not execute) a single git commit command. This applies to implementation work; when the task is to open a PR, the `create-java-pr` skill takes over from here and does commit, push, and open it. ## Repository Skills @@ -110,7 +116,7 @@ The repository is organized into multiple modules: ### Utility Modules - **`sentry-test-support`** - Shared test utilities, including `MockSentryServer` and envelope matchers -- **`sentry-samples`** - Example applications +- **`sentry-samples`** - Example applications, in two separate Gradle builds that include this one: `sentry-samples` (JVM) and `sentry-samples/sentry-samples-android`. Root tasks such as `check` and `spotlessApply` don't reach them, and `sentry-android-integration-tests` is a separate build too - **`sentry-bom`** - Bill of Materials for dependency management ### Key Architectural Patterns @@ -129,30 +135,35 @@ The repository is organized into multiple modules: ### Exception Handling -**Never introduce a new `catch (Throwable)`.** Catch the narrowest type the guarded code can -actually throw. The repository still contains many pre-existing broad catches; they are legacy, -not a precedent to follow. +The following section applies only in cases where the spec (sentry-docs) do not define an exception +handling strategy. -A broad catch swallows `OutOfMemoryError`, `StackOverflowError`, `ThreadDeath` and `LinkageError` — -conditions the JVM/ART cannot recover from and that leave the process in an undefined state — and -it hides real bugs in our own code behind a log line. +Inside the SDK, **never add a broad catch** (`Throwable`, `Error`, `Exception`, +`RuntimeException`). Catch the narrowest type the guarded code can actually throw. The many +existing broad catches are legacy, not precedent: they hide real bugs in our own code and make it +appear that Sentry is broken. -"The SDK must never crash the host application" is not a reason to catch `Throwable`. That goal is -served by `io.sentry.util.ExceptionUtils.rethrowIfFatal`, which lets the non-recoverable throwables -through while leaving everything else for the caller to log or ignore: +A broad catch is allowed only at a boundary where we can't know what the other side throws. Two +examples: -```java -try { - doSomethingRisky(); -} catch (Throwable t) { - ExceptionUtils.rethrowIfFatal(t); - options.getLogger().log(SentryLevel.ERROR, "Failed to do something risky", t); -} -``` +- public API entry points like `captureException` +- Android framework calls that cross into another process (`ContentResolver`, system services) +- user callbacks like `beforeSendLogs` defined in the spec (sentry-docs) + +At a boundary follow the spec (sentry-docs) otherwise if no spec is defined for this case: -Apply that pattern only where a broad catch is genuinely unavoidable — an entry point that runs -arbitrary user code or third-party callbacks. Everywhere else, name the exception types. Say in the -PR description why the broad catch is necessary. +- Call `ExceptionUtils.rethrowIfFatal(t)` first so `OutOfMemoryError`, `StackOverflowError`, etc. + are never swallowed. It also rethrows `LinkageError`, so code probing an optional `compileOnly` + dependency must catch the specific subclass (`NoClassDefFoundError`, ...) before it. +- A code comment is required saying why the catch is broad and what happens on failure (event + dropped, feature disabled, ...). +- If wrapping user code with a broad catch, documentation is **required** explaining how the user + is expected to discover that the code they wrote isn't working. Both in the Javadoc and in sentry-docs. +- Logging is not handling: `options.getLogger()` is silent unless `debug` is enabled, so a failure + that is only logged is invisible in production. + +Instrumentation that wraps user code is not a boundary for the user's own exceptions: let them +propagate unchanged. ### Concurrency @@ -202,6 +213,29 @@ Two more rules that catch most of the rest: Prefer `AutoClosableReentrantLock` over `synchronized`, matching the rest of the codebase. +### Measuring Time + +**New code that reads a clock uses `io.sentry.time`**, not `System.currentTimeMillis()`, +`System.nanoTime()`, `SystemClock`, `DateUtils.getCurrentDateTime()`, a `SentryDateProvider`, or an +`ICurrentDateProvider`. Those are legacy, not a precedent to follow. + +Pick the type by what the number is for: + +| You need | Use | +|---|---| +| An instant that leaves the process (event, breadcrumb, session) | `EpochClock` → `Timestamp`. Never subtract two of them | +| How long something took | `Stopwatch` | +| Whether a window has elapsed (TTL, backoff, timeout) | `Deadline` | +| Several instants compared with each other (spans of a transaction) | `AnchoredClock` | +| A raw tick, when none of the above fits | `MonotonicTicker` | + +Get the clock from `options.getEpochClock()` or `options.getMonotonicTicker()`, not a +`getInstance()`: on Android the ticker keeps counting through device suspend, and `System.nanoTime()` +does not. Take it as a constructor parameter; tests use `TestMonotonicTicker` instead of sleeping. + +If none of these fits, propose a new type, and the clock bug it makes impossible, before writing +call sites against it. Don't inline raw tick arithmetic as a stopgap. + ### Testing Requirements - Write comprehensive unit tests for new features - Android modules require both unit tests and instrumented tests where applicable diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dbe9e60bee..a3d0cec6fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +> [!WARNING] +> SDK 9.0.0 will raise the minimum supported Java and Android versions: +> - Java 8 support will be dropped. SDK 9.0.0 requires Java 11+. See [#6469](https://github.com/getsentry/sentry-java/issues/6469). +> - The Android `minSdk` will be raised from 21 to 24. See [#5508](https://github.com/getsentry/sentry-java/issues/5508). + ## Unreleased ### Breaking Changes @@ -60,8 +65,28 @@ - `ActivityFramesTracker` is removed, and the `ActivityLifecycleIntegration` constructor no longer takes it - Screenshots attached to errors and user feedback now mask all text and images by default. Turn it off with `options.screenshot.setMaskAllText(false)` and `setMaskAllImages(false)`, or the `io.sentry.screenshot.mask-all-text` and `io.sentry.screenshot.mask-all-images` manifest options. Masking needs `sentry-android-replay`, so apps that depend on `sentry-android-core` alone get no screenshot until they add it or turn masking off ([#6437](https://github.com/getsentry/sentry-java/pull/6437)) +### Features + +- Monitor every Spring `@Scheduled` method with Sentry Crons by setting `sentry.monitor-scheduled-tasks=true`, without adding `@SentryCheckIn` to each method. Sentry creates each monitor from the method's `@Scheduled` schedule. Exclude methods with `sentry.exclude-scheduled-tasks` ([#6275](https://github.com/getsentry/sentry-java/pull/6275), [#6276](https://github.com/getsentry/sentry-java/pull/6276), [#6277](https://github.com/getsentry/sentry-java/pull/6277)) +- Quartz `SentryJobListener` can send a monitor config derived from the trigger with check-ins, so Sentry creates or updates the monitor from code. Set the `sentry-upsert-monitor-config` job or trigger data key to `"true"` to send the schedule ([#6216](https://github.com/getsentry/sentry-java/pull/6216)) +- Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = true)` to send the schedule ([#6215](https://github.com/getsentry/sentry-java/pull/6215)) +- Sentry OpenTelemetry integrations now honor `OTEL_BSP_SCHEDULE_DELAY` (`otel.bsp.schedule.delay`) for how often spans are sent to Sentry, instead of always waiting 5 seconds ([#6388](https://github.com/getsentry/sentry-java/pull/6388)) + +### Behavioral Changes + +- Gradle builds now resolve all Sentry SDK modules to the same version, the highest one present, because every module depends on `sentry-bom` as a platform ([#6466](https://github.com/getsentry/sentry-java/pull/6466)) + - Before, a build could mix module versions, for example a newer `sentry-android-core` with an older `sentry-android-fragment`, which failed at runtime. Maven builds resolve as before. + - `sentry-bom` now also includes `sentry-android-distribution`. + ### Fixes +- Add opt-in support for matching GraphQL ignored error types against classification specifications via `sentry.graphql.enable-specification-error-type-filtering` ([#6092](https://github.com/getsentry/sentry-java/pull/6092)) +- Session Replay: Fix `SentryOkHttpInterceptor` hanging forever on responses whose body has no known length, such as Server-Sent Events or a gzipped response, when network details are enabled for the URL ([#6231](https://github.com/getsentry/sentry-java/pull/6231)) +- Session Replay: `SentryOkHttpInterceptor` now captures network request and response details only while Session Replay is recording or buffering ([#6486](https://github.com/getsentry/sentry-java/pull/6486)) +- Stop marking an OkHttp call span as failed when a connection attempt to one of the host's addresses fails but OkHttp connects on another ([#6472](https://github.com/getsentry/sentry-java/pull/6472)) +- Stop blocking the main thread while the frame metrics thread starts when the first activity transaction begins ([#6500](https://github.com/getsentry/sentry-java/pull/6500)) + - On a busy device during cold start this could stall the launcher activity's `onCreate` for hundreds of milliseconds. +- Do not inspect the response body of streaming Apollo 3 and 4 responses (`multipart/mixed`, `text/event-stream`) for failed requests, which blocked `@defer` and `@stream` ([#6432](https://github.com/getsentry/sentry-java/pull/6432)) - Set Apollo 3, 4, and 5 HTTP spans to `CANCELLED` instead of `INTERNAL_ERROR` when the request is cancelled ([#6284](https://github.com/getsentry/sentry-java/pull/6284)) ### Improvements @@ -80,6 +105,13 @@ - Promote the metrics capture API from experimental to stable ([#6425](https://github.com/getsentry/sentry-java/pull/6425)) +### Dependencies + +- Bump OkHttp from 4.9.2 to 5.5.1 ([#6472](https://github.com/getsentry/sentry-java/pull/6472)) + - `sentry-okhttp` and the Spring Boot integrations keep OkHttp as a `compileOnly` dependency, so your app keeps the OkHttp version it declares. OkHttp 4 is still supported. + - [changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) + - [diff](https://github.com/square/okhttp/compare/parent-4.9.2...parent-5.5.1) + ### Internal - Measure the 30 second performance-collection budget on a monotonic ticker, so that a device time change no longer ends collection early or extends it past the budget ([#6101](https://github.com/getsentry/sentry-java/pull/6101)) @@ -112,6 +144,7 @@ - Refuse to send data to servers whose certificate was issued by one of the leaked eMudhra intermediates (CVE-2026-111943) ([#6336](https://github.com/getsentry/sentry-java/pull/6336)) - Stop sending the whole release as `dist` on ANR and app-exit events when the release has no `+versionCode` ([#6450](https://github.com/getsentry/sentry-java/pull/6450)) - Relay rejected these values (e.g. `invalid character '@'`). `dist` is now left empty unless `options.dist` is set or the release contains a version code. +- Accept profiling traces directories whose path contains a `.` on the JVM, and keep profiling disabled after its first start rejects the options instead of starting on the next one ([#6334](https://github.com/getsentry/sentry-java/pull/6334)) ### Dependencies @@ -121,6 +154,10 @@ - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0176) - [diff](https://github.com/getsentry/sentry-native/compare/0.17.4...0.17.6) +### Internal + +- Measure ANR detection and ANR profiling durations on the internal `MonotonicTicker` ([#6041](https://github.com/getsentry/sentry-java/pull/6041)) + ### Improvements - Promote ignored span origin configuration APIs from experimental to stable ([#6427](https://github.com/getsentry/sentry-java/pull/6427)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77714917518..d0642308ff8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,11 +73,7 @@ To format the changed code and make CI happy you can run: make format ``` -or - -```shell -./gradlew spotlessApply -``` +`./gradlew spotlessApply` alone formats only the SDK build, not the separate builds it doesn't include, such as the Android sample. # Binary compatibility validation diff --git a/Makefile b/Makefile index c5bb6ac2313..562eec68140 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleUiTestRelease assembleUiTestCriticalRelease runUiTestCritical check preMerge publish +.PHONY: all clean compile javadocs dryRelease update format checkFormat api assembleUiTestRelease assembleUiTestCriticalRelease runUiTestCritical check preMerge publish all: stop clean javadocs compile assembleUiTests: assembleUiTestRelease @@ -25,9 +25,19 @@ dryRelease: update: ./gradlew dependencyUpdates -Drevision=release +# Spotless formats the code. The root spotlessApply doesn't reach the separate builds. +format: + ./gradlew spotlessApply + ./gradlew -p sentry-samples spotlessApply + ./gradlew -p sentry-samples/sentry-samples-android spotlessApply + ./gradlew -p sentry-android-integration-tests spotlessApply + # Spotless check's code checkFormat: ./gradlew spotlessJavaCheck spotlessKotlinCheck + ./gradlew -p sentry-samples spotlessJavaCheck spotlessKotlinCheck + ./gradlew -p sentry-samples/sentry-samples-android spotlessJavaCheck spotlessKotlinCheck + ./gradlew -p sentry-android-integration-tests spotlessJavaCheck spotlessKotlinCheck # Binary compatibility validator api: @@ -35,11 +45,11 @@ api: # Assemble release and Android test apk of the uitest-android module assembleUiTestRelease: - ./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest + ./gradlew -p sentry-android-integration-tests :sentry-uitest-android:assembleRelease :sentry-uitest-android:assembleAndroidTest # Assemble release of the uitest-android-critical module assembleUiTestCriticalRelease: - ./gradlew :sentry-android-integration-tests:sentry-uitest-android-critical:assembleRelease + ./gradlew -p sentry-android-integration-tests :sentry-uitest-android-critical:assembleRelease # Run Maestro tests for the uitest-android-critical module runUiTestCritical: diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a0040916145..b4c58711dd3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -390,6 +390,34 @@ limitations under the License. --- +## Android Open Source Project — AndroidX MavenUploadHelper (Apache 2.0) + +**Source:** https://github.com/androidx/androidx/blob/c799cba927a71f01ea6b421a8f83c181682633fb/buildSrc/private/src/main/kotlin/androidx/build/MavenUploadHelper.kt#L524-L549
+**License:** Apache License 2.0
+**Copyright:** Copyright 2018 The Android Open Source Project + +### Scope + +The Sentry Java SDK build includes an adapted version of the AndroidX build's POM rewriting, which adds `aar` to dependencies on Android libraries in published POMs. The code resides in the build logic at `build-logic/src/main/kotlin/io/sentry/gradle/AarTypes.kt` and is not part of the published artifacts. + +``` +Copyright 2018 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +--- + ## OpenTelemetry (Apache 2.0) **Source:** https://github.com/open-telemetry/opentelemetry-java (Commit: 0aacc55d1e3f5cc6dbb4f8fa26bcb657b01a7bc9)
diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 031482f7d4b..262315cd7d1 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -5,6 +5,8 @@ plugins { dependencies { implementation(libs.animalsniffer.gradle.plugin) implementation(libs.spotlessLib) + implementation(libs.detektLib) + implementation(libs.vanniktechMavenPublishLib) implementation(libs.asm) compileOnly(libs.kotlin.gradle.plugin) compileOnly(libs.android.gradle.plugin) @@ -16,6 +18,18 @@ gradlePlugin { id = "io.sentry.jdk-target" implementationClass = "io.sentry.gradle.JdkTargetConvention" } + register("sentryDetekt") { + id = "io.sentry.detekt" + implementationClass = "io.sentry.gradle.SentryDetektPlugin" + } + register("sentryPublish") { + id = "io.sentry.publish" + implementationClass = "io.sentry.gradle.SentryPublishPlugin" + } + register("sentryMultiplatformPublish") { + id = "io.sentry.publish.multiplatform" + implementationClass = "io.sentry.gradle.SentryMultiplatformPublishPlugin" + } register("sentryApiValidation") { id = "io.sentry.api-validation" implementationClass = "io.sentry.gradle.SentryApiValidationPlugin" diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/AarTypes.kt b/build-logic/src/main/kotlin/io/sentry/gradle/AarTypes.kt new file mode 100644 index 00000000000..0dda1a6bcb4 --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/AarTypes.kt @@ -0,0 +1,69 @@ +/* + * Adapted from https://github.com/androidx/androidx/blob/c799cba927a71f01ea6b421a8f83c181682633fb/buildSrc/private/src/main/kotlin/androidx/build/MavenUploadHelper.kt#L524-L549 + * + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.sentry.gradle + +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import groovy.util.Node + +private val androidLibs = + setOf( + "sentry-android-core", + "sentry-android-ndk", + "sentry-android-fragment", + "sentry-android-navigation", + "sentry-android-navigation3", + "sentry-android-timber", + "sentry-compose-android", + "sentry-android-sqlite", + "sentry-android-replay", + ) + +private val androidXLibs = listOf("androidx.core:core") + +// Workaround for https://github.com/gradle/gradle/issues/3170 +fun MavenPublishBaseExtension.assignAarTypes() { + pom { + withXml { + val dependencies = + asNode().children().find { it is Node && it.name().toString().endsWith("dependencies") } + as Node? + + dependencies?.children()?.forEach { dep -> + if (dep !is Node) { + return@forEach + } + val group = + dep.children().firstOrNull { it is Node && it.name().toString().endsWith("groupId") } + as? Node + val groupValue = group?.children()?.firstOrNull() as? String + + val artifactId = + dep.children().firstOrNull { it is Node && it.name().toString().endsWith("artifactId") } + as? Node + val artifactIdValue = artifactId?.children()?.firstOrNull() as? String + + if (artifactIdValue in androidLibs) { + dep.appendNode("type", "aar") + } else if ("$groupValue:$artifactIdValue" in androidXLibs) { + dep.appendNode("type", "aar") + } + } + } + } +} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt b/build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt index a46a2866df2..dd0a8e275a6 100644 --- a/build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt +++ b/build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt @@ -33,9 +33,23 @@ private val PUBLISHED_JAVA_VERSION = JavaVersion.VERSION_11 * archives are checked for it * - published Android modules main code targets Java 11 * - minSdk is set for Android libraries + * - javac warnings fail the build */ class JdkTargetConvention : Plugin { override fun apply(project: Project) { + project.tasks.withType(JavaCompile::class.java).configureEach { + options.compilerArgs.addAll( + listOf( + "-Xlint:all", + "-Werror", + "-Xlint:-classfile", + "-Xlint:-processing", + "-Xlint:-try", + "-Xlint:-options", + ) + ) + } + project.plugins.withType(JavaBasePlugin::class.java) { project.extensions.getByType(JavaPluginExtension::class.java).toolchain { languageVersion.set(BUILD_JDK) diff --git a/buildSrc/src/main/java/MergeSpringMetadataAction.kt b/build-logic/src/main/kotlin/io/sentry/gradle/MergeSpringMetadataAction.kt similarity index 99% rename from buildSrc/src/main/java/MergeSpringMetadataAction.kt rename to build-logic/src/main/kotlin/io/sentry/gradle/MergeSpringMetadataAction.kt index 2df744924cb..ac73e21f588 100644 --- a/buildSrc/src/main/java/MergeSpringMetadataAction.kt +++ b/build-logic/src/main/kotlin/io/sentry/gradle/MergeSpringMetadataAction.kt @@ -1,3 +1,5 @@ +package io.sentry.gradle + import java.net.URI import java.nio.file.FileSystems import java.nio.file.Files diff --git a/buildSrc/src/main/java/Publication.kt b/build-logic/src/main/kotlin/io/sentry/gradle/Publication.kt similarity index 98% rename from buildSrc/src/main/java/Publication.kt rename to build-logic/src/main/kotlin/io/sentry/gradle/Publication.kt index c9251fe25c0..519dc7419f1 100644 --- a/buildSrc/src/main/java/Publication.kt +++ b/build-logic/src/main/kotlin/io/sentry/gradle/Publication.kt @@ -1,3 +1,5 @@ +package io.sentry.gradle + import org.gradle.api.Project import org.gradle.api.distribution.DistributionContainer import org.gradle.api.file.CopySpec diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SentryDetektPlugin.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SentryDetektPlugin.kt new file mode 100644 index 00000000000..1775f672a5a --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/SentryDetektPlugin.kt @@ -0,0 +1,20 @@ +package io.sentry.gradle + +import io.gitlab.arturbosch.detekt.extensions.DetektExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure + +/** Runs detekt on a module against the shared ruleset, with a per-module baseline. */ +class SentryDetektPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply("io.gitlab.arturbosch.detekt") + + project.extensions.configure { + buildUponDefaultConfig = true + allRules = true + config.setFrom(project.isolated.rootProject.projectDirectory.file("detekt.yml")) + baseline = project.file("detekt-baseline.xml") + } + } +} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SentryPublishPlugin.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SentryPublishPlugin.kt new file mode 100644 index 00000000000..de8162032e1 --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/SentryPublishPlugin.kt @@ -0,0 +1,109 @@ +package io.sentry.gradle + +import com.vanniktech.maven.publish.JavaLibrary +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import java.io.File +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.distribution.DistributionContainer +import org.gradle.api.distribution.plugins.DistributionPlugin +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.publish.PublishingExtension +import org.gradle.kotlin.dsl.configure + +/** Publishes a JVM or Android module and packages it as the zip distribution craft consumes. */ +class SentryPublishPlugin : Plugin { + override fun apply(project: Project) { + project.configurePublishing(DistributionContainer::configureForJvm) + // the BOMs are java-platform projects, so they do not depend on themselves + project.plugins.withId("java-library") { project.alignWithBom("api") } + project.plugins.withId("com.android.library") { project.alignWithBom("api") } + } +} + +/** + * Publishes a Kotlin Multiplatform module. Separate from [SentryPublishPlugin] because the + * per-target distributions it lays out have no counterpart in a single-target module. + */ +class SentryMultiplatformPublishPlugin : Plugin { + override fun apply(project: Project) { + project.configurePublishing(DistributionContainer::configureForMultiplatform) + project.plugins.withId("org.jetbrains.kotlin.multiplatform") { + project.alignWithBom("commonMainApi") + } + } +} + +/** + * With sentry-bom as a platform, Gradle resolves all SDK modules of a consumer to one version, the + * highest one present. + */ +private fun Project.alignWithBom(configuration: String) { + val bom = dependencies.project(mapOf("path" to ":sentry-bom")) + dependencies.add(configuration, dependencies.platform(bom)) +} + +private fun Project.configurePublishing( + configureDistributions: DistributionContainer.(Project) -> Unit +) { + pluginManager.apply(DistributionPlugin::class.java) + pluginManager.apply("com.vanniktech.maven.publish") + + extensions.configure { + configureDistributions(project) + // craft only uses zip archives + forEach { dist -> + if (dist.name == DistributionPlugin.MAIN_DISTRIBUTION_NAME) { + tasks.named("distTar").configure { enabled = false } + } else { + tasks.named(dist.name + "DistTar").configure { enabled = false } + } + } + } + + val sep = File.separator + val distributionFile = + layout.buildDirectory.file("distributions$sep$name-$version.zip").get().asFile + + tasks.named("distZip").configure { + dependsOn("publishToMavenLocal") + dependsOn("publishAllPublicationsTo${DIST_MAVEN_REPO.replaceFirstChar { it.uppercase() }}Repository") + val file = distributionFile + doLast { + if (!file.exists()) { + throw IllegalStateException("Distribution file: ${file.absolutePath} does not exist") + } + if (file.length() == 0L) { + throw IllegalStateException("Distribution file: ${file.absolutePath} is empty") + } + } + } + + extensions.configure { assignAarTypes() } + + plugins.withId("java-library") { + extensions.configure { + // we have to disable javadoc publication in maven-publish plugin as it's not + // including it in the .module file + // https://github.com/vanniktech/gradle-maven-publish-plugin/issues/861 + // and do it ourselves + configure(JavaLibrary(JavadocJar.None(), sourcesJar = true)) + } + + extensions.configure { withJavadocJar() } + } + + extensions.configure { + // the dist zips craft publishes are assembled from this, see Publication.kt + repositories.maven { + name = DIST_MAVEN_REPO + url = distMavenDir.get().asFile.toURI() + } + // this is needed for sentry-unity to consume our artifacts locally as proper maven publication + repositories.maven { + name = "unityMaven" + url = isolated.rootProject.projectDirectory.dir("build${sep}unityMaven").asFile.toURI() + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 1f0e87fefcc..824afcca706 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,3 @@ -import com.vanniktech.maven.publish.JavaLibrary -import com.vanniktech.maven.publish.JavadocJar -import com.vanniktech.maven.publish.MavenPublishBaseExtension -import groovy.util.Node -import io.gitlab.arturbosch.detekt.extensions.DetektExtension import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent @@ -11,21 +6,19 @@ plugins { alias(libs.plugins.spotless) apply false alias(libs.plugins.detekt) `maven-publish` - alias(libs.plugins.vanniktech.maven.publish) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.spring) apply false alias(libs.plugins.buildconfig) apply false - // dokka is required by gradle-maven-publish-plugin. + // dokka is applied directly by sentry-compose; pinned here so it is on the classpath. alias(libs.plugins.dokka) apply false alias(libs.plugins.dokka.javadoc) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.errorprone) apply false alias(libs.plugins.gradle.versions) apply false - alias(libs.plugins.spring.dependency.management) apply false id("io.sentry.javadoc.aggregate") - alias(libs.plugins.sentry) apply false + alias(libs.plugins.android.application) apply false } buildscript { @@ -33,13 +26,9 @@ buildscript { google() } dependencies { - // R8 must come before AGP on the classpath to replace the R8 bundled with AGP. - Config.BuildPlugins.r8?.let { classpath(it) } - classpath(Config.BuildPlugins.androidGradle) - - // add classpath of sentry android gradle plugin - // classpath("io.sentry:sentry-android-gradle-plugin:{version}") - + // Replaces the R8 bundled with AGP when the AGP matrix job overrides AGP. See r8 in + // libs.versions.toml. + if (System.getenv("VERSION_AGP") != null) classpath(libs.android.r8) classpath(libs.commons.compress) } } @@ -58,108 +47,13 @@ allprojects { TestLogEvent.FAILED ) } - withType().configureEach { - options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing", "-Xlint:-try", "-Xlint:-options")) - } - } -} - -subprojects { - apply { plugin("io.sentry.spotless") } - apply { plugin("io.sentry.jdk-target") } - - plugins.withId(Config.QualityPlugins.detektPlugin) { - configure { - buildUponDefaultConfig = true - allRules = true - config.setFrom("${rootProject.rootDir}/detekt.yml") - baseline = file("detekt-baseline.xml") - } - } - - if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-test-support") { - apply() - apply() - - val sep = File.separator - - configure { - if (this@subprojects.name.contains("-compose")) { - this.configureForMultiplatform(this@subprojects) - } else { - this.configureForJvm(this@subprojects) - } - // craft only uses zip archives - this.forEach { dist -> - if (dist.name == DistributionPlugin.MAIN_DISTRIBUTION_NAME) { - tasks.named("distTar").configure { enabled = false } - } else { - tasks.named(dist.name + "DistTar").configure { enabled = false } - } - } - } - - tasks.named("distZip").configure { - this.dependsOn("publishToMavenLocal") - this.dependsOn("publishAllPublicationsTo${DIST_MAVEN_REPO.replaceFirstChar { it.uppercase() }}Repository") - val file = this.project.layout.buildDirectory.file("distributions${sep}${this.project.name}-${this.project.version}.zip").get().asFile - this.doLast { - if (!file.exists()) throw IllegalStateException("Distribution file: ${file.absolutePath} does not exist") - if (file.length() == 0L) throw IllegalStateException("Distribution file: ${file.absolutePath} is empty") - } - } - - plugins.withId("java-library") { - configure { - // we have to disable javadoc publication in maven-publish plugin as it's not - // including it in the .module file https://github.com/vanniktech/gradle-maven-publish-plugin/issues/861 - // and do it ourselves - configure(JavaLibrary(JavadocJar.None(), sourcesJar = true)) - } - - configure { - withJavadocJar() - } - } - - apply() - - afterEvaluate { - configure { - assignAarTypes() - } - - // this is needed for sentry-unity to consume our artifacts locally as proper maven publication - configure { - repositories { - // the dist zips craft publishes are assembled from this, see Publication.kt - maven { - name = DIST_MAVEN_REPO - url = distMavenDir.get().asFile.toURI() - } - maven { - name = "unityMaven" - url = rootProject.layout.buildDirectory.file("unityMaven").get().asFile.toURI() - } - } - } - - // maven central info go to: - // ~/.gradle/gradle.properties - - // maven central info: - // mavenCentralUsername=user name - // mavenCentralPassword=password - } } } tasks.register("buildForCodeQL") { subprojects .filter { - !it.displayName.contains("sample") && - !it.displayName.contains("integration-tests") && - !it.displayName.contains("bom") && + !it.displayName.contains("bom") && it.name != "sentry-opentelemetry" } .forEach { proj -> @@ -174,53 +68,3 @@ tasks.register("buildForCodeQL") { } } } - -/* - * Adapted from https://github.com/androidx/androidx/blob/c799cba927a71f01ea6b421a8f83c181682633fb/buildSrc/private/src/main/kotlin/androidx/build/MavenUploadHelper.kt#L524-L549 - * - * Copyright 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// Workaround for https://github.com/gradle/gradle/issues/3170 -fun MavenPublishBaseExtension.assignAarTypes() { - pom { - withXml { - val dependencies = asNode().children().find { - it is Node && it.name().toString().endsWith("dependencies") - } as Node? - - dependencies?.children()?.forEach { dep -> - if (dep !is Node) { - return@forEach - } - val group = dep.children().firstOrNull { - it is Node && it.name().toString().endsWith("groupId") - } as? Node - val groupValue = group?.children()?.firstOrNull() as? String - - val artifactId = dep.children().firstOrNull { - it is Node && it.name().toString().endsWith("artifactId") - } as? Node - val artifactIdValue = artifactId?.children()?.firstOrNull() as? String - - if (artifactIdValue in Config.BuildScript.androidLibs) { - dep.appendNode("type", "aar") - } else if ("$groupValue:$artifactIdValue" in Config.BuildScript.androidXLibs) { - dep.appendNode("type", "aar") - } - } - } - } -} diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 78991110635..bcd9317e473 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -1,19 +1,9 @@ object Config { - val AGP = System.getenv("VERSION_AGP") ?: "9.4.0" val kotlinStdLib = "stdlib-jdk8" val kotlinStdLibVersionAndroid = "1.9.24" val kotlinTestJunit = "test-junit" - object BuildPlugins { - val androidGradle = "com.android.tools.build:gradle:$AGP" - - // The R8 bundled with AGP 9.0 and 9.2 crashes on androidx.core at minSdk 24, so builds that - // override AGP (the AGP matrix) run the R8 that ships with the default AGP instead. - val r8: String? - get() = if (System.getenv("VERSION_AGP") != null) "com.android.tools:r8:9.4.14" else null - } - object Android { val abiFilters = listOf("x86", "armeabi-v7a", "x86_64", "arm64-v8a") @@ -30,9 +20,7 @@ object Config { val springSecurityWeb = "org.springframework.security:spring-security-web" val springSecurityConfig = "org.springframework.security:spring-security-config" val springAop = "org.springframework:spring-aop" - val aspectj = "org.aspectj:aspectjweaver" - val kotlinReflect = "org.jetbrains.kotlin:kotlin-reflect" val kotlinStdLib = "org.jetbrains.kotlin:kotlin-stdlib" } @@ -41,11 +29,6 @@ object Config { val springBootConfiguration = "org.springframework.boot:spring-boot-configuration-processor" } - object QualityPlugins { - // this can be removed when we upgrade to Gradle 8, which allows us to use a getter for the plugin ID - val detektPlugin = "io.gitlab.arturbosch.detekt" - } - object Sentry { val SENTRY_JAVA_SDK_NAME = "sentry.java" val SENTRY_ANDROID_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.android" @@ -97,22 +80,4 @@ object Config { val description = "SDK for sentry.io" val versionNameProp = "versionName" } - - object BuildScript { - val androidLibs = setOf( - "sentry-android-core", - "sentry-android-ndk", - "sentry-android-fragment", - "sentry-android-navigation", - "sentry-android-navigation3", - "sentry-android-timber", - "sentry-compose-android", - "sentry-android-sqlite", - "sentry-android-replay" - ) - - val androidXLibs = listOf( - "androidx.core:core" - ) - } } diff --git a/gradle.properties b/gradle.properties index eeefd38c92d..417334e771c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,6 @@ android.useAndroidX=true # AGP 9+ migration opt-outs until we remove kotlin-android plugin and adopt built-in Kotlin. android.builtInKotlin=false android.newDsl=false -android.experimental.lint.version=9.4.0 android.disableAgpUpgradePrompt=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec1280cc278..1fd2d0f7581 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,21 +10,23 @@ asyncProfiler = "4.4" camerax = "1.4.0" composeCompiler = "1.5.14" coroutines = "1.6.1" +detekt = "1.23.8" +dokka = "2.0.0" espresso = "3.7.0" feign = "11.6" -gummyBears = "0.12.0" +gummyBears = "0.16.0" jackson = "2.18.3" jetbrainsCompose = "1.6.11" kotlin = "2.3.21" kotlin-compatible-version = "1.9" -ksp = "2.3.9" +ksp = "2.3.13" ktorClient = "3.0.0" logback = "1.2.9" log4j2 = "2.26.1" nopen = "1.0.1" # see https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html#kotlin-compatibility # see https://developer.android.com/jetpack/androidx/releases/compose-kotlin -okhttp = "4.9.2" +okhttp = "5.5.1" openfeature = "1.18.2" otel = "1.63.0" otelAlpha = "1.63.0-alpha" @@ -37,11 +39,13 @@ retrofit = "2.9.0" room2 = "2.8.4" room3 = "3.0.0-rc01" sagp = "6.13.0" -# Compile-time only, for the AGP types the build-logic convention plugins use. The AGP that -# actually runs the build comes from the root buildscript (Config.AGP, overridable via VERSION_AGP). +# The AGP matrix job overrides this with the VERSION_AGP env var, through each build's settings. agp = "9.4.0" +# The R8 bundled with AGP 9.0 and 9.2 crashes on androidx.core at minSdk 24, so builds that override +# AGP (the AGP matrix) put the R8 that ships with the default AGP on their buildscript classpath. +r8 = "9.4.14" sqlite = "2.6.2" -sqliteRc = "2.7.0-rc01" # Required by Room3 3.0.0-rc* +sqliteRc = "2.8.0-alpha03" # Required by Room3 3.0.0-rc* slf4j = "1.7.30" spotless = "8.8.0" springboot2 = "2.7.18" @@ -50,6 +54,7 @@ springboot4 = "4.1.0" springKafka2 = "2.8.11" springKafka3 = "3.3.5" sqldelight = "2.3.2" +vanniktechMavenPublish = "0.37.0" # Android targetSdk = "37" @@ -58,6 +63,7 @@ compileSdkMinor = "2" minSdk = "24" [plugins] +android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } @@ -65,13 +71,13 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } buildconfig = { id = "com.github.gmazzo.buildconfig", version = "5.6.5" } -dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } -dokka-javadoc = { id = "org.jetbrains.dokka-javadoc", version = "2.0.0" } +dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } +dokka-javadoc = { id = "org.jetbrains.dokka-javadoc", version.ref = "dokka" } errorprone = { id = "net.ltgt.errorprone", version = "5.1.1" } gradle-versions = { id = "com.github.ben-manes.versions", version = "0.42.0" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } -detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.8" } -vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.37.0" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" } springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" } @@ -91,7 +97,7 @@ apollo4-kotlin = { module = "com.apollographql.apollo:apollo-runtime", version = # sentry-apollo-5 compiles against 5.0.0 (supported floor) and tests against the latest 5.x apollo5-kotlin = { module = "com.apollographql.apollo:apollo-runtime", version = "5.0.0" } apollo5-kotlin-latest = { module = "com.apollographql.apollo:apollo-runtime", version = "5.1.0" } -androidx-appcompat = { module = "androidx.appcompat:appcompat", version = "1.3.0" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } androidx-annotation = { module = "androidx.annotation:annotation", version = "1.9.1" } androidx-activity = { module = "androidx.activity:activity", version = "1.8.2" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.9.3" } @@ -104,9 +110,9 @@ androidx-compose-material-icons-extended = { module = "androidx.compose.material androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidxCompose" } # Note: don't change without testing forwards compatibility androidx-compose-ui-replay = { module = "androidx.compose.ui:ui", version = "1.10.2" } -androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.2.1" } +androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.2.2" } androidx-core = { module = "androidx.core:core", version = "1.3.2" } -androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.7.0" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.19.1" } androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version = "1.3.5" } androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidxLifecycle" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } @@ -126,13 +132,15 @@ androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteRc" } androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteRc" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.4.0" } -androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } +androidx-browser = { module = "androidx.browser:browser", version = "1.10.0" } +aspectjweaver = { module = "org.aspectj:aspectjweaver" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } async-profiler-jfr-converter = { module = "tools.profiler:jfr-converter", version.ref = "asyncProfiler" } caffeine = { module = "com.github.ben-manes.caffeine:caffeine" } -caffeine-jcache = { module = "com.github.ben-manes.caffeine:jcache", version = "3.2.0" } +caffeine-jcache = { module = "com.github.ben-manes.caffeine:jcache", version = "3.3.1" } coil-compose = { module = "io.coil-kt:coil-compose", version = "2.7.0" } commons-compress = {module = "org.apache.commons:commons-compress", version = "1.28.0"} +android-r8 = { module = "com.android.tools:r8", version.ref = "r8" } context-propagation = { module = "io.micrometer:context-propagation", version = "1.1.0" } errorprone-core = { module = "com.google.errorprone:error_prone_core", version = "2.50.0" } feign-core = { module = "io.github.openfeign:feign-core", version.ref = "feign" } @@ -144,13 +152,14 @@ jackson-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jac jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jetbrains-annotations = { module = "org.jetbrains:annotations", version = "23.0.0" } kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClient" } ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktorClient" } launchdarkly-android = { module = "com.launchdarkly:launchdarkly-android-client-sdk", version = "5.9.2" } -launchdarkly-server = { module = "com.launchdarkly:launchdarkly-java-server-sdk", version = "7.13.4" } +launchdarkly-server = { module = "com.launchdarkly:launchdarkly-java-server-sdk", version = "7.17.3" } log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j2" } log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j2" } leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.14" } @@ -193,6 +202,8 @@ slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-jdk14 = { module = "org.slf4j:slf4j-jdk14", version.ref = "slf4j" } slf4j2-api = { module = "org.slf4j:slf4j-api", version = "2.0.5" } spotlessLib = { module = "com.diffplug.spotless:com.diffplug.spotless.gradle.plugin", version.ref = "spotless"} +detektLib = { module = "io.gitlab.arturbosch.detekt:io.gitlab.arturbosch.detekt.gradle.plugin", version.ref = "detekt" } +vanniktechMavenPublishLib = { module = "com.vanniktech.maven.publish:com.vanniktech.maven.publish.gradle.plugin", version.ref = "vanniktechMavenPublish" } springboot2-bom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springboot2" } springboot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springboot2" } spring-graphql = { module = "org.springframework.graphql:spring-graphql", version = "1.0.6" } @@ -288,9 +299,11 @@ mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "6.4. mockito-core = { module = "org.mockito:mockito-core", version = "5.24.0" } msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +# For tests that can't run on okhttp 5: apollo-runtime 2.x passes a null body to +# Response.Builder.body(), and the Android UI tests are pinned to leakcanary's okio 2, while okhttp +# 5 needs okio 3. +okhttp-mockwebserver4 = { module = "com.squareup.okhttp3:mockwebserver", version = "4.9.2" } okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" } -# apollo-runtime 5.x pulls in okhttp 5.x; mockwebserver 4.x is incompatible with that classpath -okhttp-mockwebserver5 = { module = "com.squareup.okhttp3:mockwebserver", version = "5.3.0" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } diff --git a/scripts/test-ui-android.sh b/scripts/test-ui-android.sh new file mode 100755 index 00000000000..d81c683aff3 --- /dev/null +++ b/scripts/test-ui-android.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Runs the sentry-uitest-android instrumentation tests on the connected device, then pulls the +# replay frame snapshots that ReplaySnapshotTest writes, also when the tests fail. +# android-emulator-runner runs each `script:` line in its own shell, so this stays one script to +# keep the tests' exit code. Keep the device directory in sync with ReplaySnapshotTest. +set -eu + +out_dir=$1 +device_dir=/sdcard/Download/sentry_replay_snapshots + +test_status=0 +./gradlew -p sentry-android-integration-tests :sentry-uitest-android:connectedReleaseAndroidTest || test_status=$? + +mkdir -p "$out_dir" +if adb shell test -d "$device_dir"; then + adb pull "$device_dir/." "$out_dir" +else + printf 'No replay snapshots on the device, skipping\n' +fi + +exit "$test_status" diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index d111a71ecc3..64e9a7ac09c 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java b/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java index c8f1bf5e361..6f8fedcecea 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java @@ -29,25 +29,27 @@ import android.app.ActivityManager; import android.content.Context; -import android.os.SystemClock; import io.sentry.IDebugManager; import io.sentry.ILogger; import io.sentry.SentryLevel; -import io.sentry.transport.ICurrentDateProvider; +import io.sentry.time.Deadline; +import io.sentry.time.MonotonicTicker; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; /** A watchdog timer thread that detects when the UI thread has frozen. */ @SuppressWarnings("UnusedReturnValue") final class ANRWatchDog extends Thread { + private static final long DEFAULT_POLLING_INTERVAL_MS = 500; + private final boolean reportInDebug; private final ANRListener anrListener; private final MainLooperHandler uiHandler; - private final ICurrentDateProvider timeProvider; + private final MonotonicTicker monotonicTicker; /** the interval in which we check if there's an ANR, in ms */ private long pollingIntervalMs; @@ -55,7 +57,9 @@ final class ANRWatchDog extends Thread { private final long timeoutIntervalMillis; private final @NotNull ILogger logger; - private volatile long lastKnownActiveUiTimestampMs = 0; + /** How long the main thread has left to run the ticker before we call it an ANR. */ + private volatile @NotNull Deadline uiResponsiveUntil; + private final AtomicBoolean reported = new AtomicBoolean(false); private final @NotNull Context context; @@ -64,30 +68,24 @@ final class ANRWatchDog extends Thread { @SuppressWarnings("UnnecessaryLambda") private final Runnable ticker; - ANRWatchDog( - long timeoutIntervalMillis, - boolean reportInDebug, - @NotNull ANRListener listener, - @NotNull ILogger logger, - final @NotNull Context context, - final @Nullable IDebugManager debugManager) { - // avoid method refs on Android due to some issues with older AGP setups - // noinspection Convert2MethodRef - this( - () -> SystemClock.uptimeMillis(), - timeoutIntervalMillis, - 500, - reportInDebug, + static @NotNull ANRWatchDog create( + final @NotNull SentryAndroidOptions options, + final @NotNull ANRListener listener, + final @NotNull Context context) { + return new ANRWatchDog( + options.getMonotonicTicker(), + options.getAnrTimeoutIntervalMillis(), + DEFAULT_POLLING_INTERVAL_MS, + options.isAnrReportInDebug(), listener, - logger, + options.getLogger(), new MainLooperHandler(), context, - debugManager); + options.getDebugManager()); } - @TestOnly ANRWatchDog( - @NotNull final ICurrentDateProvider timeProvider, + @NotNull final MonotonicTicker monotonicTicker, long timeoutIntervalMillis, long pollingIntervalMillis, boolean reportInDebug, @@ -99,7 +97,7 @@ final class ANRWatchDog extends Thread { super("|ANR-WatchDog|"); - this.timeProvider = timeProvider; + this.monotonicTicker = monotonicTicker; this.timeoutIntervalMillis = timeoutIntervalMillis; this.pollingIntervalMs = pollingIntervalMillis; this.reportInDebug = reportInDebug; @@ -108,9 +106,12 @@ final class ANRWatchDog extends Thread { this.uiHandler = uiHandler; this.context = context; this.debugManager = debugManager; + this.uiResponsiveUntil = + Deadline.after(monotonicTicker, timeoutIntervalMillis, TimeUnit.MILLISECONDS); this.ticker = () -> { - lastKnownActiveUiTimestampMs = timeProvider.getCurrentTimeMillis(); + uiResponsiveUntil = + Deadline.after(monotonicTicker, timeoutIntervalMillis, TimeUnit.MILLISECONDS); reported.set(false); }; @@ -146,11 +147,8 @@ public void run() { return; } - final long unresponsiveDurationMs = - timeProvider.getCurrentTimeMillis() - lastKnownActiveUiTimestampMs; - // If the main thread has not handled ticker, it is blocked. ANR. - if (unresponsiveDurationMs > timeoutIntervalMillis) { + if (uiResponsiveUntil.hasPassed()) { if (!reportInDebug && debugManager != null && debugManager.isDebuggerConnected()) { logger.log( SentryLevel.DEBUG, diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java index b9fd0b781d8..275914e3fe2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java @@ -95,13 +95,7 @@ private void startAnrWatchdog( options.getAnrTimeoutIntervalMillis()); anrWatchDog = - new ANRWatchDog( - options.getAnrTimeoutIntervalMillis(), - options.isAnrReportInDebug(), - error -> reportANR(scopes, options, error), - options.getLogger(), - context, - options.getDebugManager()); + ANRWatchDog.create(options, error -> reportANR(scopes, options, error), context); anrWatchDog.start(); options.getLogger().log(SentryLevel.DEBUG, "AnrIntegration installed."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java index 612e6809ab4..8994fba74d1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java @@ -74,6 +74,10 @@ public SpanFrameMetricsCollector( this.frameMetricsCollector = frameMetricsCollector; enabled = options.isEnableFramesTracking(); + if (enabled && options.isTracingEnabled()) { + // Start the frame metrics thread now, so it's ready before the first activity's first frame. + frameMetricsCollector.prepare(); + } } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index bc41f3faf6a..1a8552c51a8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -4,7 +4,6 @@ import android.os.Handler; import android.os.Looper; -import android.os.SystemClock; import io.sentry.IDebugManager; import io.sentry.ILogger; import io.sentry.IScopes; @@ -15,12 +14,15 @@ import io.sentry.SentryOptions; import io.sentry.android.core.AppState; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.time.MonotonicTicker; +import io.sentry.time.Stopwatch; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import io.sentry.util.SentryRandom; import java.io.Closeable; import java.io.File; import java.io.IOException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.ApiStatus; @@ -44,12 +46,22 @@ public class AnrProfilingIntegration static final int MAX_NUM_STACKS = (int) (10_000 / POLLING_INTERVAL_MS); private final AtomicBoolean enabled = new AtomicBoolean(true); - private final Runnable updater = () -> lastMainThreadExecutionTime = SystemClock.uptimeMillis(); + + private volatile @Nullable MonotonicTicker ticker; + + private final @NotNull Runnable updater = + () -> { + final @Nullable MonotonicTicker currentTicker = ticker; + if (currentTicker != null) { + lastMainThreadExecutionNanos = currentTicker.tickNanos(); + } + }; + private final @NotNull AutoClosableReentrantLock lifecycleLock = new AutoClosableReentrantLock(); private final @NotNull AutoClosableReentrantLock profileManagerLock = new AutoClosableReentrantLock(); - private volatile long lastMainThreadExecutionTime = SystemClock.uptimeMillis(); + private volatile long lastMainThreadExecutionNanos; final AtomicInteger numCollectedStacks = new AtomicInteger(); private volatile MainThreadState mainThreadState = MainThreadState.IDLE; private volatile @Nullable AnrProfileManager profileManager; @@ -69,6 +81,10 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); this.logger = options.getLogger(); + final @NotNull MonotonicTicker ticker = options.getMonotonicTicker(); + // A tick's origin is arbitrary, so the default of 0 would read as a stall of unknown length. + this.lastMainThreadExecutionNanos = ticker.tickNanos(); + this.ticker = ticker; this.debugManager = this.options.getDebugManager(); if (this.options.isAnrProfilingEnabled()) { @@ -223,8 +239,12 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept return; } - final long now = SystemClock.uptimeMillis(); - final long diff = now - lastMainThreadExecutionTime; + final @Nullable MonotonicTicker currentTicker = ticker; + if (currentTicker == null) { + return; + } + final long diff = + TimeUnit.NANOSECONDS.toMillis(currentTicker.tickNanos() - lastMainThreadExecutionNanos); if (diff < THRESHOLD_SUSPICION_MS) { mainThreadState = MainThreadState.IDLE; @@ -253,14 +273,15 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept && (mainThreadState == MainThreadState.SUSPICIOUS || mainThreadState == MainThreadState.ANR_DETECTED)) { if (numCollectedStacks.get() < MAX_NUM_STACKS) { - final long start = SystemClock.uptimeMillis(); + final @NotNull Stopwatch stopwatch = Stopwatch.started(currentTicker); final @NotNull AnrStackTrace trace = new AnrStackTrace(System.currentTimeMillis(), mainThread.getStackTrace()); - final long duration = SystemClock.uptimeMillis() - start; if (logger.isEnabled(SentryLevel.DEBUG)) { logger.log( SentryLevel.DEBUG, - "AnrWatchdog: capturing main thread stacktrace took " + duration + "ms"); + "AnrWatchdog: capturing main thread stacktrace took " + + stopwatch.elapsed(TimeUnit.MILLISECONDS) + + "ms"); } addStackTrace(trace); } else { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicTicker.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicTicker.java index 22973b0d937..06deaa5a48a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicTicker.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicTicker.java @@ -16,6 +16,7 @@ public final class AndroidMonotonicTicker implements MonotonicTicker { private static final AndroidMonotonicTicker instance = new AndroidMonotonicTicker(); + /** Prefer {@link io.sentry.SentryOptions#getMonotonicTicker()}; on Android it returns this. */ public static @NotNull MonotonicTicker getInstance() { return instance; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java index 1af8f97fea2..e618fea6883 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java @@ -34,6 +34,7 @@ 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 SentryFrameMetricsCollector implements Application.ActivityLifecycleCallbacks { @@ -47,8 +48,10 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLi private final @NotNull ILogger logger; private volatile @Nullable Handler handler; + private volatile boolean handlerThreadStarted = false; private final @NotNull AutoClosableReentrantLock handlerLock = new AutoClosableReentrantLock(); - private @Nullable WeakReference currentWindow; + // volatile: read from the HandlerThread in onHandlerReady while set on the main thread + private volatile @Nullable WeakReference currentWindow; private final @NotNull Map listenerMap = new ConcurrentHashMap<>(); private boolean isAvailable = false; @@ -109,8 +112,8 @@ public SentryFrameMetricsCollector( } isAvailable = true; - // The frame metrics HandlerThread is started lazily on the first startCollection() call. - // Starting it here would block the main thread on HandlerThread.getLooper() during SDK init. + // The frame metrics HandlerThread is started by prepare() or the first startCollection() call, + // and never waited on: see ensureHandlerThreadStarted(). // We have to register the lifecycle callback, even if no profile is started, otherwise when we // start a profile, we wouldn't have the current activity and couldn't get the frameMetrics. @@ -286,26 +289,51 @@ public void onActivityDestroyed(@NotNull Activity activity) {} } /** - * Lazily starts the background HandlerThread used to receive frame metrics. Deferred out of the - * constructor because {@link HandlerThread#getLooper()} blocks the caller (the main thread during - * SDK init) until the thread is ready, and the handler is only needed once collection starts. + * Starts the background HandlerThread ahead of the first {@link #startCollection} call, so its + * looper is ready by the time the first frame is drawn. Does not block. + */ + public void prepare() { + if (isAvailable) { + ensureHandlerThreadStarted(); + } + } + + /** + * Starts the background HandlerThread used to receive frame metrics without waiting for it: the + * handler is created on the new thread in {@link HandlerThread#onLooperPrepared()}. Calling + * {@link HandlerThread#getLooper()} here would block the caller (the main thread) until the new + * thread is scheduled, which takes hundreds of ms on a busy device. */ private void ensureHandlerThreadStarted() { - if (handler != null) { + if (handlerThreadStarted) { return; } try (final @NotNull ISentryLifecycleToken ignored = handlerLock.acquire()) { - if (handler == null) { - final HandlerThread handlerThread = - new HandlerThread("io.sentry.android.core.internal.util.SentryFrameMetricsCollector"); - handlerThread.setUncaughtExceptionHandler( - (thread, e) -> logger.log(SentryLevel.ERROR, "Error during frames measurements.", e)); - handlerThread.start(); - handler = new Handler(handlerThread.getLooper()); + if (handlerThreadStarted) { + return; } + handlerThreadStarted = true; + final HandlerThread handlerThread = + new HandlerThread("io.sentry.android.core.internal.util.SentryFrameMetricsCollector") { + @Override + protected void onLooperPrepared() { + onHandlerReady(new Handler(getLooper())); + } + }; + handlerThread.setUncaughtExceptionHandler( + (thread, e) -> logger.log(SentryLevel.ERROR, "Error during frames measurements.", e)); + handlerThread.start(); } } + @VisibleForTesting + void onHandlerReady(final @NotNull Handler handler) { + this.handler = handler; + // startCollection() or an activity start may have happened before the handler existed, in + // which case trackCurrentWindow() skipped attaching the listener + trackCurrentWindow(); + } + public void stopCollection(final @Nullable String listenerId) { if (!isAvailable) { return; @@ -313,7 +341,9 @@ public void stopCollection(final @Nullable String listenerId) { if (listenerId != null) { listenerMap.remove(listenerId); } - Window window = currentWindow != null ? currentWindow.get() : null; + // read the volatile field once: onActivityStopped may null it concurrently + final @Nullable WeakReference windowRef = currentWindow; + final @Nullable Window window = windowRef != null ? windowRef.get() : null; if (window != null && listenerMap.isEmpty()) { stopTrackingWindow(window); } @@ -345,7 +375,9 @@ private void setCurrentWindow(final @NotNull Window window) { } private void trackCurrentWindow() { - @Nullable Window window = currentWindow != null ? currentWindow.get() : null; + // read the volatile field once: onActivityStopped may null it concurrently + final @Nullable WeakReference windowRef = currentWindow; + final @Nullable Window window = windowRef != null ? windowRef.get() : null; if (window == null || !isAvailable) { return; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt index 02522f725f1..71f477b24a1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt @@ -4,10 +4,11 @@ import android.app.ActivityManager import android.app.ActivityManager.ProcessErrorStateInfo.NOT_RESPONDING import android.app.ActivityManager.ProcessErrorStateInfo.NO_ERROR import android.content.Context -import io.sentry.transport.ICurrentDateProvider +import io.sentry.time.TestMonotonicTicker import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -20,12 +21,12 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever class ANRWatchDogTest { - private var currentTimeMs = 0L - private val timeProvider = ICurrentDateProvider { currentTimeMs } + private lateinit var ticker: TestMonotonicTicker @Before fun `setup`() { - currentTimeMs = 12341234 + // an arbitrary, non-zero origin: no caller may assume a tick counts from zero + ticker = TestMonotonicTicker(MILLISECONDS.toNanos(12341234)) } @Test @@ -44,7 +45,7 @@ class ANRWatchDogTest { val sut = ANRWatchDog( - timeProvider, + ticker, interval, 1L, true, @@ -63,7 +64,7 @@ class ANRWatchDogTest { ) // Wait until worker posts the job for the "UI thread" var waitCount = 0 do { - currentTimeMs += 100L + ticker.advance(100, MILLISECONDS) Thread.sleep(100) // Let worker realize this is ANR } while (anr == null && waitCount++ < 100) @@ -91,7 +92,7 @@ class ANRWatchDogTest { val sut = ANRWatchDog( - timeProvider, + ticker, interval, 1L, true, @@ -107,7 +108,7 @@ class ANRWatchDogTest { var waitCount = 0 do { - currentTimeMs += 100L + ticker.advance(100, MILLISECONDS) Thread.sleep(100) // Let worker realize his runner always runs } while (!invoked && waitCount++ < 100) @@ -143,7 +144,7 @@ class ANRWatchDogTest { val sut = ANRWatchDog( - timeProvider, + ticker, interval, 1L, true, @@ -162,7 +163,7 @@ class ANRWatchDogTest { ) // Wait until worker posts the job for the "UI thread" var waitCount = 0 do { - currentTimeMs += 100L + ticker.advance(100, MILLISECONDS) Thread.sleep(100) // Let worker realize this is ANR } while (anr == null && waitCount++ < 100) @@ -199,7 +200,7 @@ class ANRWatchDogTest { val sut = ANRWatchDog( - timeProvider, + ticker, interval, 1L, true, @@ -218,7 +219,7 @@ class ANRWatchDogTest { ) // Wait until worker posts the job for the "UI thread" var waitCount = 0 do { - currentTimeMs += 100L + ticker.advance(100, MILLISECONDS) Thread.sleep(100L) // Let worker realize this is ANR } while (anr == null && waitCount++ < 100) assertNull(anr) // callback never ran @@ -227,4 +228,39 @@ class ANRWatchDogTest { es.shutdown() } } + + @Test + fun `measures the ANR threshold on the ticker, not on wall time`() { + var anr: ApplicationNotResponding? = null + val handler = mock() + val latch = CountDownLatch(1) + whenever(handler.post(any())).then { latch.countDown() } + whenever(handler.thread).thenReturn(mock()) + + // context is a mock, so ActivityManager is absent and any passed deadline reports an ANR + val sut = + ANRWatchDog( + ticker, + 10L, + 1L, + true, + { a -> anr = a }, + mock(), + handler, + mock(), + AndroidDebugManager(), + ) + val es = Executors.newSingleThreadExecutor() + try { + es.submit { sut.run() } + + assertTrue(latch.await(10L, TimeUnit.SECONDS)) // wait until the watchdog is polling + Thread.sleep(200) // hundreds of polls of wall time while the ticker stands still + + assertNull(anr) + } finally { + sut.interrupt() + es.shutdown() + } + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt index be9251df1db..e8b9afa1896 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt @@ -27,7 +27,10 @@ class SpanFrameMetricsCollectorTest { var timeNanos = 0L var lastKnownChoreographerFrameTimeNanos = 0L - fun getSut(enabled: Boolean = true): SpanFrameMetricsCollector { + fun getSut( + enabled: Boolean = true, + tracesSampleRate: Double? = 1.0, + ): SpanFrameMetricsCollector { whenever(frameMetricsCollector.startCollection(any())) .thenReturn(UUID.randomUUID().toString()) whenever(frameMetricsCollector.getLastKnownFrameStartTimeNanos()).thenAnswer { @@ -36,6 +39,7 @@ class SpanFrameMetricsCollectorTest { options.frameMetricsCollector = frameMetricsCollector options.isEnableFramesTracking = enabled options.dateProvider = SentryAndroidDateProvider() + options.tracesSampleRate = tracesSampleRate return SpanFrameMetricsCollector(options, frameMetricsCollector) } @@ -89,6 +93,24 @@ class SpanFrameMetricsCollectorTest { verify(fixture.frameMetricsCollector, never()).startCollection(any()) } + @Test + fun `When enabled and tracing is enabled, it prepares the frame metrics collector`() { + fixture.getSut() + verify(fixture.frameMetricsCollector).prepare() + } + + @Test + fun `When tracing is disabled, it doesn't prepare the frame metrics collector`() { + fixture.getSut(tracesSampleRate = null) + verify(fixture.frameMetricsCollector, never()).prepare() + } + + @Test + fun `When disabled, it doesn't prepare the frame metrics collector`() { + fixture.getSut(enabled = false) + verify(fixture.frameMetricsCollector, never()).prepare() + } + @Test fun `If disabled but a span is launched, it doesn't register a listener`() { val sut = fixture.getSut(enabled = false) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt index 6d78a8f70c8..76cdac4d1e6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -1,7 +1,7 @@ package io.sentry.android.core.anr -import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.ILogger import io.sentry.IScopes import io.sentry.SentryIntegrationPackageStorage @@ -9,6 +9,9 @@ import io.sentry.SentryOptions import io.sentry.android.core.AppState import io.sentry.android.core.SentryAndroidOptions import io.sentry.test.getProperty +import io.sentry.time.TestMonotonicTicker +import java.util.concurrent.TimeUnit.HOURS +import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -21,6 +24,8 @@ import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) class AnrProfilingIntegrationTest { @@ -30,9 +35,13 @@ class AnrProfilingIntegrationTest { private lateinit var mockScopes: IScopes private lateinit var mockLogger: ILogger private lateinit var options: SentryAndroidOptions + private lateinit var ticker: TestMonotonicTicker @BeforeTest fun setup() { + // A tick origin far from zero, as on any device that has been up a while. Starting at zero + // would let a reading taken against a different origin pass unnoticed. + ticker = TestMonotonicTicker(HOURS.toNanos(30)) mockScopes = mock() mockLogger = mock() options = @@ -49,6 +58,10 @@ class AnrProfilingIntegrationTest { AppState.getInstance().resetInstance() } + /** `SentryAndroidOptions` is final and has no ticker setter, so a spy supplies [ticker]. */ + private fun withTestTicker(options: SentryAndroidOptions): SentryAndroidOptions = + spy(options).also { whenever(it.monotonicTicker).thenReturn(ticker) } + @Test fun `onForeground starts monitoring thread`() { val integration = AnrProfilingIntegration() @@ -163,7 +176,6 @@ class AnrProfilingIntegrationTest { @Test fun `properly walks through state transitions and collects stack traces`() { val mainThread = Thread.currentThread() - SystemClock.setCurrentTimeMillis(1_00) val androidOptions = SentryAndroidOptions().apply { @@ -173,19 +185,19 @@ class AnrProfilingIntegrationTest { } val integration = AnrProfilingIntegration() - integration.register(mockScopes, androidOptions) + integration.register(mockScopes, withTestTicker(androidOptions)) // Drive the state machine synchronously to avoid racing the background polling thread. - SystemClock.setCurrentTimeMillis(1_000) + ticker.advance(900, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) assertTrue(integration.profileManager.load().stacks.isEmpty()) - SystemClock.setCurrentTimeMillis(3_000) + ticker.advance(2_000, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.SUSPICIOUS, integration.state) - SystemClock.setCurrentTimeMillis(6_000) + ticker.advance(3_000, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.ANR_DETECTED, integration.state) assertEquals(2, integration.profileManager.load().stacks.size) @@ -199,7 +211,6 @@ class AnrProfilingIntegrationTest { @Test fun `debugger pauses are treated as idle`() { val mainThread = Thread.currentThread() - SystemClock.setCurrentTimeMillis(1_000) var debuggerConnected = true val androidOptions = @@ -211,16 +222,16 @@ class AnrProfilingIntegrationTest { } val integration = AnrProfilingIntegration() - integration.register(mockScopes, androidOptions) + integration.register(mockScopes, withTestTicker(androidOptions)) - SystemClock.setCurrentTimeMillis(20_000) + ticker.advance(19_000, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) assertEquals(0, integration.numCollectedStacks.get()) // the time spent paused must not count towards an ANR once the debugger detaches debuggerConnected = false - SystemClock.setCurrentTimeMillis(20_100) + ticker.advance(100, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) } @@ -228,7 +239,6 @@ class AnrProfilingIntegrationTest { @Test fun `background foreground transitions don't trigger an ANR`() { val mainThread = Thread.currentThread() - SystemClock.setCurrentTimeMillis(1_000) val androidOptions = SentryAndroidOptions().apply { @@ -238,10 +248,10 @@ class AnrProfilingIntegrationTest { } val integration = AnrProfilingIntegration() - integration.register(mockScopes, androidOptions) + integration.register(mockScopes, withTestTicker(androidOptions)) integration.onBackground() - SystemClock.setCurrentTimeMillis(20_000) + ticker.advance(19_000, MILLISECONDS) integration.onForeground() Thread.sleep(100) @@ -249,6 +259,17 @@ class AnrProfilingIntegrationTest { assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) } + @Test + fun `does not report a stall when the ticker origin is far from zero`() { + val integration = AnrProfilingIntegration() + integration.register(mockScopes, withTestTicker(options)) + + integration.checkMainThread(Thread.currentThread()) + + assertThat(integration.state).isEqualTo(AnrProfilingIntegration.MainThreadState.IDLE) + assertThat(integration.profileManager.load().stacks).isEmpty() + } + @Test fun `does not register when options is not SentryAndroidOptions`() { val plainOptions = @@ -295,7 +316,6 @@ class AnrProfilingIntegrationTest { @Test fun `does not collect stacks when sample rate is zero`() { val mainThread = Thread.currentThread() - SystemClock.setCurrentTimeMillis(1_00) val androidOptions = SentryAndroidOptions().apply { @@ -305,16 +325,16 @@ class AnrProfilingIntegrationTest { } val integration = AnrProfilingIntegration() - integration.register(mockScopes, androidOptions) + integration.register(mockScopes, withTestTicker(androidOptions)) integration.onForeground() // Transition to suspicious - SystemClock.setCurrentTimeMillis(3_000) + ticker.advance(2_900, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.SUSPICIOUS, integration.state) // Transition to ANR - SystemClock.setCurrentTimeMillis(6_000) + ticker.advance(3_000, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.ANR_DETECTED, integration.state) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt index fbd944b7dcf..d92443bdd70 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt @@ -83,17 +83,30 @@ class SentryFrameMetricsCollectorTest { fun getSut( context: Context, buildInfoProvider: BuildInfoProvider = buildInfo, + prepare: Boolean = true, ): SentryFrameMetricsCollector { whenever(activity.window).thenReturn(window) whenever(activity2.window).thenReturn(window2) addOnFrameMetricsAvailableListenerCounter = 0 removeOnFrameMetricsAvailableListenerCounter = 0 - return SentryFrameMetricsCollector( - context, - options, - buildInfoProvider, - windowFrameMetricsManager, - ) + val collector = + SentryFrameMetricsCollector(context, options, buildInfoProvider, windowFrameMetricsManager) + if (prepare) { + collector.prepare() + if (collector.getProperty("handlerThreadStarted")) { + awaitHandler(collector) + } + } + return collector + } + + // the handler is created asynchronously on the HandlerThread once its looper is prepared + fun awaitHandler(collector: SentryFrameMetricsCollector) { + val deadline = System.currentTimeMillis() + 5_000 + while (collector.getProperty("handler") == null) { + check(System.currentTimeMillis() < deadline) { "frame metrics handler was never created" } + Thread.sleep(1) + } } } @@ -133,12 +146,37 @@ class SentryFrameMetricsCollectorTest { @Test fun `handler thread is started lazily on first startCollection`() { - val collector = fixture.getSut(context) - // not started during construction (would block the main thread on getLooper at SDK init) - assertNull(collector.getProperty("handler")) + val collector = fixture.getSut(context, prepare = false) + assertFalse(collector.getProperty("handlerThreadStarted")) collector.startCollection(mock()) - assertNotNull(collector.getProperty("handler")) + assertTrue(collector.getProperty("handlerThreadStarted")) + fixture.awaitHandler(collector) + } + + @Test + fun `prepare starts the handler thread before startCollection`() { + val collector = fixture.getSut(context, prepare = false) + + collector.prepare() + assertTrue(collector.getProperty("handlerThreadStarted")) + fixture.awaitHandler(collector) + } + + @Test + fun `listener is attached once the handler is ready if collection started before it`() { + val collector = fixture.getSut(context, prepare = false) + // pretend the HandlerThread is started but its looper isn't prepared yet + collector.injectForField("handlerThreadStarted", true) + + collector.startCollection(mock()) + collector.onActivityStarted(fixture.activity) + Shadows.shadowOf(Looper.getMainLooper()).idle() + assertEquals(0, fixture.addOnFrameMetricsAvailableListenerCounter) + + collector.onHandlerReady(Handler(Looper.getMainLooper())) + Shadows.shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter) } @Test diff --git a/sentry-android-distribution/build.gradle.kts b/sentry-android-distribution/build.gradle.kts index 967d48119fe..1198867a9cb 100644 --- a/sentry-android-distribution/build.gradle.kts +++ b/sentry-android-distribution/build.gradle.kts @@ -4,7 +4,10 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - alias(libs.plugins.detekt) + id("io.sentry.detekt") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-android-fragment/build.gradle.kts b/sentry-android-fragment/build.gradle.kts index 9384b199959..713323383af 100644 --- a/sentry-android-fragment/build.gradle.kts +++ b/sentry-android-fragment/build.gradle.kts @@ -6,7 +6,10 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) - alias(libs.plugins.detekt) + id("io.sentry.detekt") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-android-integration-tests/README.md b/sentry-android-integration-tests/README.md index 4b3c495a978..2e84e1cbd06 100644 --- a/sentry-android-integration-tests/README.md +++ b/sentry-android-integration-tests/README.md @@ -1,4 +1,10 @@ # Android performance-impact and integration tests +This directory is a separate Gradle build that includes the SDK build, so run its tasks with +`./gradlew -p sentry-android-integration-tests `. The SDK build doesn't include it, so +`./gradlew` from the repository root doesn't build, lint or format these apps. +`gradle.properties`, `gradlew`, `gradle/wrapper` and `gradle/gradle-daemon-jvm.properties` are +symlinks to the SDK build's, so both builds use the same Gradle, JDK and settings. + * [Espresso-based UI tests](./sentry-uitest-android) - run within SauceLabs (see /.sauce/*.yml) * Also used for compatibility test matrix against new AGP versions diff --git a/sentry-android-integration-tests/build.gradle.kts b/sentry-android-integration-tests/build.gradle.kts new file mode 100644 index 00000000000..19c71e99e5d --- /dev/null +++ b/sentry-android-integration-tests/build.gradle.kts @@ -0,0 +1,18 @@ +buildscript { + repositories { google() } + dependencies { + // Replaces the R8 bundled with AGP when the AGP matrix job overrides AGP. See r8 in + // libs.versions.toml. + if (System.getenv("VERSION_AGP") != null) classpath(libs.android.r8) + } +} + +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.errorprone) apply false + alias(libs.plugins.detekt) apply false + alias(libs.plugins.gradle.versions) apply false + alias(libs.plugins.sentry) apply false +} diff --git a/sentry-android-integration-tests/gradle.properties b/sentry-android-integration-tests/gradle.properties new file mode 120000 index 00000000000..7677fb73be8 --- /dev/null +++ b/sentry-android-integration-tests/gradle.properties @@ -0,0 +1 @@ +../gradle.properties \ No newline at end of file diff --git a/sentry-android-integration-tests/gradle/gradle-daemon-jvm.properties b/sentry-android-integration-tests/gradle/gradle-daemon-jvm.properties new file mode 120000 index 00000000000..284e270637d --- /dev/null +++ b/sentry-android-integration-tests/gradle/gradle-daemon-jvm.properties @@ -0,0 +1 @@ +../../gradle/gradle-daemon-jvm.properties \ No newline at end of file diff --git a/sentry-android-integration-tests/gradle/wrapper b/sentry-android-integration-tests/gradle/wrapper new file mode 120000 index 00000000000..3232fe4e272 --- /dev/null +++ b/sentry-android-integration-tests/gradle/wrapper @@ -0,0 +1 @@ +../../gradle/wrapper \ No newline at end of file diff --git a/sentry-android-integration-tests/gradlew b/sentry-android-integration-tests/gradlew new file mode 120000 index 00000000000..502f5a2d3ec --- /dev/null +++ b/sentry-android-integration-tests/gradlew @@ -0,0 +1 @@ +../gradlew \ No newline at end of file diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts index e606ab52553..fc43a6ad062 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts @@ -1,11 +1,16 @@ import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion plugins { id("com.android.application") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + android { compileSdk = libs.versions.compileSdk.get().toInt() compileSdkMinor = libs.versions.compileSdkMinor.get().toInt() @@ -35,20 +40,18 @@ android { buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } androidComponents.beforeVariants { - it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + it.enable = it.buildType != "debug" } } dependencies { - implementation( - kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION) - ) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.material3) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core) - implementation(projects.sentryAndroidCore) + implementation("io.sentry:sentry-android-core:$sentryVersion") } tasks.withType().configureEach { diff --git a/sentry-android-integration-tests/sentry-uitest-android/README.md b/sentry-android-integration-tests/sentry-uitest-android/README.md index d6ccf2f1f93..5150de4764a 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/README.md +++ b/sentry-android-integration-tests/sentry-uitest-android/README.md @@ -5,15 +5,11 @@ By default the envelopes sent to relay are caught by a mock server which allows # How to use -Simply run `./gradlew connectedCheck` to run all ui tests of all modules (requires a connected device, either physical or an emulator). +Simply run `./gradlew -p sentry-android-integration-tests connectedCheck` to run all ui tests of all modules (requires a connected device, either physical or an emulator). -# SauceLabs -To run on saucelabs execute following commands (need also `SAUCE_USERNAME` and `SAUCE_ACCESS_KEY` environment variables): -For End 2 End: -``` -./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest -saucectl run -c .sauce/sentry-uitest-android-end2end.yml -``` +# CI +CI runs these tests on a GitHub Actions emulator in the AGP matrix workflow (`.github/workflows/agp-matrix.yml`). +The emulator must not be an ATD image: PixelCopy returns no data there, and the replay tests need it. # Troubleshooting diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 8f479470cac..5539cac6944 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion plugins { id("com.android.application") @@ -8,8 +9,12 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + android { compileSdk = libs.versions.compileSdk.get().toInt() compileSdkMinor = libs.versions.compileSdkMinor.get().toInt() @@ -28,7 +33,6 @@ android { // This doesn't work on some devices with Android 11+. Clearing package data resets permissions. // Check the readme for more info. testInstrumentationRunnerArguments["clearPackageData"] = "true" - buildConfigField("String", "ENVIRONMENT", "\"${System.getProperty("environment", "")}\"") } testOptions { execution = "ANDROIDX_TEST_ORCHESTRATOR" } @@ -38,14 +42,13 @@ android { // Note that the viewBinding.enabled property is now deprecated. viewBinding = true compose = true - buildConfig = true } composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } signingConfigs { getByName("debug") { - storeFile = rootProject.file("debug.keystore") + storeFile = file("../../debug.keystore") storePassword = "android" keyAlias = "androiddebugkey" keyPassword = "android" @@ -70,6 +73,10 @@ android { // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. checkReleaseBuilds = false + + // This module is the one lint picks to check the build root's Gradle wrapper, and this check + // compares it with the latest Gradle release online, so every new release would fail the build. + disable += "AndroidGradlePluginVersion" } androidComponents.beforeVariants { @@ -86,15 +93,13 @@ if (applySentryIntegrations) { } dependencies { - implementation( - kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION) - ) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) if (applySentryIntegrations) { - implementation(projects.sentryAndroid) - implementation(projects.sentryCompose) + implementation("io.sentry:sentry-android:$sentryVersion") + implementation("io.sentry:sentry-compose:$sentryVersion") } else { - implementation(projects.sentryAndroidCore) + implementation("io.sentry:sentry-android-core:$sentryVersion") } implementation(libs.androidx.appcompat) implementation(libs.androidx.activity.compose) @@ -113,7 +118,7 @@ dependencies { errorprone(libs.nullaway) androidTestUtil(libs.androidx.test.orchestrator) - androidTestImplementation(projects.sentryTestSupport) + androidTestImplementation("io.sentry:sentry-test-support:$sentryVersion") androidTestImplementation(libs.androidx.test.core.ktx) androidTestImplementation(libs.androidx.test.espresso.core) androidTestImplementation(libs.androidx.test.ext.junit) @@ -122,7 +127,7 @@ dependencies { androidTestImplementation(libs.awaitility3.kotlin) androidTestImplementation(libs.kotlin.test.junit) androidTestImplementation(libs.leakcanary.instrumentation) - androidTestImplementation(libs.okhttp.mockwebserver) + androidTestImplementation(libs.okhttp.mockwebserver4) } tasks.withType().configureEach { @@ -133,6 +138,12 @@ tasks.withType().configureEach { } } +detekt { + buildUponDefaultConfig = true + allRules = true + config.setFrom(file("../../detekt.yml")) +} + tasks.withType().configureEach { // Target version of the generated JVM bytecode. It is used for type resolution. jvmTarget = JavaVersion.VERSION_1_8.toString() diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt index 62be835eb2b..529d840b3d3 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt @@ -125,6 +125,9 @@ fun waitUntilIdle() { Espresso.onIdle() } +/** Whether sentry-android-replay is on the classpath (APPLY_SENTRY_INTEGRATIONS=true). */ +fun isReplayAvailable(): Boolean = classExists("io.sentry.android.replay.ReplayIntegration") + fun classExists(className: String): Boolean { try { Class.forName(className) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt index 3fa2c904873..1d77c95663a 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt @@ -3,17 +3,14 @@ package io.sentry.uitest.android import androidx.lifecycle.Lifecycle import androidx.test.core.app.launchActivity import androidx.test.espresso.Espresso -import androidx.test.espresso.IdlingPolicies import androidx.test.espresso.IdlingRegistry import androidx.test.espresso.action.ViewActions import androidx.test.espresso.matcher.ViewMatchers import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.NoOpLogger import io.sentry.ProfilingTraceData import io.sentry.Sentry import io.sentry.SentryEvent import io.sentry.android.core.AndroidLogger -import io.sentry.android.core.BuildInfoProvider import io.sentry.android.core.SentryAndroidOptions import io.sentry.assertEnvelopeProfile import io.sentry.assertEnvelopeTransaction @@ -25,7 +22,6 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue -import org.junit.Assume import org.junit.Assume.assumeNotNull import org.junit.runner.RunWith @@ -196,44 +192,6 @@ class EnvelopeTests : BaseUiTest() { } } - @Test - fun checkTimedOutProfile() { - Assume.assumeFalse( - "Sometimes traceFile does not exist for profiles on emulators and it fails." + - " Although, Android Runtime is the one that manages the file, so something" + - " must be wrong with Debug.startMethodTracing", - BuildInfoProvider(NoOpLogger.getInstance()).isEmulator ?: true, - ) - // We increase the IdlingResources timeout to exceed the profiling timeout - IdlingPolicies.setIdlingResourceTimeout(1, TimeUnit.MINUTES) - initSentry(true) { options: SentryAndroidOptions -> - options.tracesSampleRate = 1.0 - options.profilesSampleRate = 1.0 - } - relayIdlingResource.increment() - val transaction = Sentry.startTransaction("timedOutProfile", "testTimeout") - Thread { - Thread.sleep(35_000) - transaction.finish() - } - .start() - // We call transaction.finish() after 35 seconds, and check profile times out after 30 seconds - - relay.assert { - findEnvelope { - assertEnvelopeTransaction(it.items.toList(), AndroidLogger()).transaction == - "timedOutProfile" - } - .assert { - val transactionItem: SentryTransaction = it.assertTransaction() - // Profile should not be present, as it timed out and is discarded - it.assertNoOtherItems() - assertEquals("timedOutProfile", transactionItem.transaction) - } - assertNoOtherEnvelopes() - } - } - @Test fun sendProfiledTransaction() { // This is a dogfooding test diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt index 9c53f0a022d..86f30dc1a1a 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt @@ -18,9 +18,7 @@ import shark.ReferencePattern class ReplayTest : BaseUiTest() { @Before fun setup() { - // we can't run on GH actions emulator, because they don't allow capturing screenshots properly - @Suppress("KotlinConstantConditions") - assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true)) + assumeThat(isReplayAvailable(), `is`(true)) // crash on swallowed Compose masking errors (e.g. broken obfuscated internals) so regressions // fail this on-device test instead of silently under-masking (see SentryReplayDebug) System.setProperty("io.sentry.replay.compose.fail-fast", "true") diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt index 791006d3713..1569d97e41f 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt @@ -458,8 +458,7 @@ class UserFeedbackUiTest : BaseUiTest() { @Test fun userFeedbackSendEnvelope() { - // GH actions emulator don't allow capturing screenshots properly - val enableReplay = BuildConfig.ENVIRONMENT != "github" + val enableReplay = isReplayAvailable() initSentry(true) { // When sending the feedback we want to wait for relay to receive it. // We can't simply increment the idling resource, diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt index 6d45b2d1f9c..f1099d04348 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -12,17 +12,12 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertTrue -import org.hamcrest.CoreMatchers.`is` -import org.junit.Assume.assumeThat import org.junit.Before class ReplaySnapshotTest : BaseUiTest() { @Before fun setup() { - // GH Actions emulators don't support capturing screenshots for replay - @Suppress("KotlinConstantConditions") - assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true)) // crash on swallowed Compose masking errors (e.g. broken obfuscated internals) so regressions // fail this on-device test instead of silently under-masking (see SentryReplayDebug) System.setProperty("io.sentry.replay.compose.fail-fast", "true") @@ -33,7 +28,8 @@ class ReplaySnapshotTest : BaseUiTest() { val snapshotsDir = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - "sauce_labs_custom_screenshots", + // pulled and uploaded by scripts/test-ui-android.sh in CI + "sentry_replay_snapshots", ) .apply { deleteRecursively() diff --git a/sentry-android-integration-tests/settings.gradle.kts b/sentry-android-integration-tests/settings.gradle.kts new file mode 100644 index 00000000000..5a0d060f7d7 --- /dev/null +++ b/sentry-android-integration-tests/settings.gradle.kts @@ -0,0 +1,66 @@ +pluginManagement { + repositories { + // Prefer local SAGP artifact if one exists; otherwise fall back to libs.versions.toml. + if (providers.gradleProperty("useSagp").isPresent) { + mavenLocal { + content { + includeGroup("io.sentry") + includeGroup("io.sentry.android.gradle") + } + } + } + mavenCentral() + gradlePluginPortal() + google() + } + + // Every build in a composite must use the same AGP, so each build's settings applies the AGP + // matrix job's override. + System.getenv("VERSION_AGP")?.let { agpVersion -> + resolutionStrategy { + eachPlugin { + if (requested.id.id.startsWith("com.android")) { + useVersion(agpVersion) + } + } + } + } +} + +plugins { + id("com.gradle.develocity") version "4.6.1" + id("com.gradle.common-custom-user-data-gradle-plugin") version "2.8.0" +} + +develocity { + buildScan { + termsOfUseUrl.set("https://gradle.com/help/legal-terms-of-use") + termsOfUseAgree.set("yes") + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + mavenLocal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + // gradle.properties links to the SDK build's, so versionName is the SDK version. + version("sentry", providers.gradleProperty("versionName").get()) + } + } +} + +rootProject.name = "sentry-android-integration-tests" + +includeBuild("..") + +// For the io.sentry.spotless and io.sentry.jdk-target convention plugins. +includeBuild("../build-logic") + +include("sentry-uitest-android") +include("sentry-uitest-android-critical") +include("test-app-size") diff --git a/sentry-android-integration-tests/test-app-size/build.gradle.kts b/sentry-android-integration-tests/test-app-size/build.gradle.kts index 7bebbc6cfd0..678ae4103e0 100644 --- a/sentry-android-integration-tests/test-app-size/build.gradle.kts +++ b/sentry-android-integration-tests/test-app-size/build.gradle.kts @@ -1,8 +1,12 @@ plugins { id("com.android.application") id("io.sentry.android.gradle") + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + android { namespace = "io.sentry.tests.size" compileSdk = libs.versions.compileSdk.get().toInt() @@ -13,7 +17,7 @@ android { minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 1 - versionName = project.version.toString() + versionName = sentryVersion } buildTypes { @@ -31,7 +35,7 @@ android { targetCompatibility = JavaVersion.VERSION_17 } androidComponents.beforeVariants { - it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + it.enable = it.buildType != "debug" } } @@ -42,12 +46,12 @@ configurations.configureEach { } dependencies { - implementation(projects.sentryAndroid) - implementation(projects.sentryAndroidTimber) - implementation(projects.sentryAndroidSqlite) - implementation(projects.sentryOkhttp) - implementation(projects.sentryCompose) - implementation(projects.sentryAndroidFragment) + implementation("io.sentry:sentry-android:$sentryVersion") + implementation("io.sentry:sentry-android-timber:$sentryVersion") + implementation("io.sentry:sentry-android-sqlite:$sentryVersion") + implementation("io.sentry:sentry-okhttp:$sentryVersion") + implementation("io.sentry:sentry-compose:$sentryVersion") + implementation("io.sentry:sentry-android-fragment:$sentryVersion") } sentry { diff --git a/sentry-android-integration-tests/test-app-size/proguard-rules.pro b/sentry-android-integration-tests/test-app-size/proguard-rules.pro index 4db0031ab00..e26a4ca5136 100644 --- a/sentry-android-integration-tests/test-app-size/proguard-rules.pro +++ b/sentry-android-integration-tests/test-app-size/proguard-rules.pro @@ -47,5 +47,7 @@ -dontwarn kotlin.math.MathKt -dontwarn okhttp3.EventListener -dontwarn okhttp3.Interceptor +-dontwarn okhttp3.ResponseBody +-dontwarn okio.ForwardingSource # Assume all classes are used to not strip them out, e.g. integrations like Compose or Sqlite -keep class io.sentry.** diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts index 4b097f246c4..ca02d462a70 100644 --- a/sentry-android-navigation/build.gradle.kts +++ b/sentry-android-navigation/build.gradle.kts @@ -6,7 +6,10 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) - alias(libs.plugins.detekt) + id("io.sentry.detekt") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts index 6f8962a8edf..0e348e15eda 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -7,7 +7,10 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.gradle.versions) - alias(libs.plugins.detekt) + id("io.sentry.detekt") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index dbfeeec5550..5627cf47d82 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -4,6 +4,9 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 7794e32c55e..1b51f29cea3 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -8,7 +8,10 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.gradle.versions) - alias(libs.plugins.detekt) + id("io.sentry.detekt") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt index 5405b33eeac..bf8a7667a27 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt @@ -233,10 +233,12 @@ public open class DefaultReplayBreadcrumbConverter() : ReplayBreadcrumbConverter // Add Network Details data when available networkDetailData?.let { networkData -> + val responseDetails = networkData.response + networkData.method?.let { breadcrumbData["method"] = it } - networkData.statusCode?.let { breadcrumbData["statusCode"] = it } + responseDetails?.statusCode?.let { breadcrumbData["statusCode"] = it } networkData.requestBodySize?.let { breadcrumbData["requestBodySize"] = it } - networkData.responseBodySize?.let { breadcrumbData["responseBodySize"] = it } + responseDetails?.size?.let { breadcrumbData["responseBodySize"] = it } networkData.request?.let { request -> val requestData = mutableMapOf() @@ -257,7 +259,7 @@ public open class DefaultReplayBreadcrumbConverter() : ReplayBreadcrumbConverter } } - networkData.response?.let { response -> + responseDetails?.let { response -> val responseData = mutableMapOf() response.size?.let { responseData["size"] = it } response.body?.let { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt index 749d3496698..9d9d5b72823 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt @@ -429,12 +429,12 @@ class DefaultReplayBreadcrumbConverterTest { ) ) fakeOkHttpNetworkDetails.setResponseDetails( - 200, ReplayNetworkRequestOrResponse( + 200, 500L, NetworkBody(mapOf("status" to "success", "message" to "OK")), mapOf("Content-Type" to "text/plain"), - ), + ) ) val hintWithFakeOKHttpNetworkDetails = Hint() hintWithFakeOKHttpNetworkDetails.set(SENTRY_REPLAY_NETWORK_DETAILS, fakeOkHttpNetworkDetails) @@ -491,12 +491,12 @@ class DefaultReplayBreadcrumbConverterTest { ) ) fakeOkHttpNetworkDetails.setResponseDetails( - 404, ReplayNetworkRequestOrResponse( + 404, 550L, NetworkBody(mapOf("status" to "success", "message" to "OK")), mapOf("Content-Type" to "text/plain"), - ), + ) ) val hintWithFakeOKHttpNetworkDetails = Hint() hintWithFakeOKHttpNetworkDetails.set(SENTRY_REPLAY_NETWORK_DETAILS, fakeOkHttpNetworkDetails) @@ -528,6 +528,47 @@ class DefaultReplayBreadcrumbConverterTest { assertEquals(mapOf("Content-Type" to "text/plain"), responseData["headers"]) } + @Test + fun `a response body captured after the breadcrumb was converted does not reach the replay`() { + // Accepted gap: a segment can be cut between the call returning and the app reading the body. + val options = SentryOptions.empty() + val converter = fixture.getSut(options) + val httpBreadcrumb = + Breadcrumb(Date(123L)).apply { + type = "http" + category = "http" + data["url"] = "https://example.com" + data[SpanDataConvention.HTTP_START_TIMESTAMP] = 1000L + data[SpanDataConvention.HTTP_END_TIMESTAMP] = 2000L + } + val networkRequestData = NetworkRequestData("GET") + networkRequestData.setResponseDetails( + ReplayNetworkRequestOrResponse(200, null, null, mapOf("Content-Type" to "application/json")) + ) + // Simulates Scopes.addBreadcrumb, which hands the network details to the converter + options.beforeBreadcrumb?.execute( + httpBreadcrumb, + Hint().apply { set(SENTRY_REPLAY_NETWORK_DETAILS, networkRequestData) }, + ) + + val rrwebEvent = converter.convert(httpBreadcrumb) + networkRequestData.setResponseDetails( + ReplayNetworkRequestOrResponse( + 200, + 21L, + NetworkBody("response body content"), + mapOf("Content-Type" to "application/json"), + ) + ) + + check(rrwebEvent is RRWebSpanEvent) + assertEquals(200, rrwebEvent.data!!["statusCode"]) + val responseData = rrwebEvent.data!!["response"] as Map<*, *> + assertEquals(mapOf("Content-Type" to "application/json"), responseData["headers"]) + assertNull(responseData["body"]) + assertNull(rrwebEvent.data!!["responseBodySize"]) + } + @Test fun `does not convert network details data for non-http breadcrumbs`() { val navigationBreadcrumb = @@ -546,12 +587,12 @@ class DefaultReplayBreadcrumbConverterTest { ) ) networkRequestData.setResponseDetails( - 200, ReplayNetworkRequestOrResponse( + 200, 100L, NetworkBody("response body content"), mapOf("Content-Type" to "application/json"), - ), + ) ) hint.set(SENTRY_REPLAY_NETWORK_DETAILS, networkRequestData) diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index e9812a2f98f..4c91c7bb59a 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -6,7 +6,10 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) - alias(libs.plugins.detekt) + id("io.sentry.detekt") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-android-timber/build.gradle.kts b/sentry-android-timber/build.gradle.kts index cbcd730c743..ef19cfac00a 100644 --- a/sentry-android-timber/build.gradle.kts +++ b/sentry-android-timber/build.gradle.kts @@ -6,7 +6,10 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) - alias(libs.plugins.detekt) + id("io.sentry.detekt") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-android/build.gradle.kts b/sentry-android/build.gradle.kts index 67c40b2f85e..1f6b68f5d81 100644 --- a/sentry-android/build.gradle.kts +++ b/sentry-android/build.gradle.kts @@ -2,6 +2,9 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") } android { diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index 28030fa16d5..06f16dd0d6a 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -5,11 +5,14 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.detekt) + id("io.sentry.detekt") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } 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 f5fb466b21a..400643cef4a 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 @@ -284,6 +284,13 @@ constructor( private fun getHeader(key: String, headers: List): String? = headers.firstOrNull { it.name.equals(key, true) }?.value + private fun isStreamingResponse(response: HttpResponse): Boolean { + val contentType = getHeader("Content-Type", response.headers) ?: return false + // strip parameters such as boundary or charset, e.g. "multipart/mixed; boundary=xyz" + val mediaType = contentType.substringBefore(';').trim() + return STREAMING_MEDIA_TYPES.any { it.equals(mediaType, ignoreCase = true) } + } + private fun getRequestHeaders(headers: List): MutableMap? { if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { val requestHeaders = mutableMapOf() @@ -350,6 +357,25 @@ constructor( // wrap everything up in a try catch block so every exception is swallowed and degraded // gracefully try { + // not possible to get a parameterized url, but we remove at least the + // query string and the fragment. + // url example: https://api.github.com/users/getsentry/repos/#fragment?query=query + // url will be: https://api.github.com/users/getsentry/repos/ + // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/ + // but that's not possible + val urlDetails = UrlUtils.parse(request.url, scopes.options.dataCollectionResolver) + + // return before reading the response body if it's not a target match + if (!PropagationTargetsUtils.contain(failedRequestTargets, urlDetails.urlOrFallback)) { + return + } + + // streaming responses (e.g. multipart incremental delivery, server-sent events) may never + // reach EOF or be unbounded, so peeking at them would block or buffer indefinitely + if (isStreamingResponse(response)) { + return + } + // we pay the price to read the response in the memory to check if there's any errors // GraphQL does not throw status code 400+ for every type of error val body = @@ -366,19 +392,6 @@ constructor( return } - // not possible to get a parameterized url, but we remove at least the - // query string and the fragment. - // url example: https://api.github.com/users/getsentry/repos/#fragment?query=query - // url will be: https://api.github.com/users/getsentry/repos/ - // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/ - // but that's not possible - val urlDetails = UrlUtils.parse(request.url, scopes.options.dataCollectionResolver) - - // return if its not a target match - if (!PropagationTargetsUtils.contain(failedRequestTargets, urlDetails.urlOrFallback)) { - return - } - val mechanism = Mechanism().apply { type = "SentryApollo3Interceptor" } val fingerprints = mutableListOf() @@ -474,6 +487,8 @@ constructor( const val SENTRY_APOLLO_3_OPERATION_TYPE = "SENTRY-APOLLO-3-OPERATION-TYPE" const val DEFAULT_CAPTURE_FAILED_REQUESTS = true + private val STREAMING_MEDIA_TYPES = setOf("multipart/mixed", "text/event-stream") + init { SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-apollo-3", BuildConfig.VERSION_NAME) 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 0921e41fe36..c26a41a4af1 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 @@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.Breadcrumb import io.sentry.DataCategory +import io.sentry.Hint import io.sentry.ILogger import io.sentry.IScopes import io.sentry.ITransaction @@ -49,12 +50,16 @@ import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.SocketPolicy +import okio.Buffer +import okio.ForwardingSource +import okio.buffer import org.junit.Before import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever @@ -297,6 +302,83 @@ class SentryApollo3InterceptorTest { assertTrue(baggageHeaderValues[0].contains("sentry-trace_id")) } + @Test + fun `does not read response body if failed request target does not match`(): Unit = runBlocking { + val bodyRead = + interceptWithContentType( + contentType = null, + url = "https://excluded.example/graphql", + ) + + assertThat(bodyRead).isFalse() + } + + @Test + fun `does not read multipart mixed response body`(): Unit = runBlocking { + val bodyRead = interceptWithContentType("Multipart/Mixed; boundary=graphql") + + assertThat(bodyRead).isFalse() + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `does not read event stream response body`(): Unit = runBlocking { + val bodyRead = interceptWithContentType(" text/event-stream ; charset=utf-8") + + assertThat(bodyRead).isFalse() + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `reads json response body for failed request detection`(): Unit = runBlocking { + val bodyRead = interceptWithContentType("application/json; charset=utf-8") + + assertThat(bodyRead).isTrue() + verify(fixture.scopes).captureEvent(any(), any()) + } + + @Test + fun `reads response body without content type for failed request detection`(): Unit = + runBlocking { + val bodyRead = interceptWithContentType(null) + + assertThat(bodyRead).isTrue() + verify(fixture.scopes).captureEvent(any(), any()) + } + + /** Runs the interceptor against a GraphQL error body and returns whether the body was read. */ + private suspend fun interceptWithContentType( + contentType: String?, + url: String = "https://target.example/graphql", + ): Boolean { + var bodyRead = false + val body = + object : ForwardingSource(Buffer().writeUtf8("{\"errors\":[{\"message\":\"error\"}]}")) { + override fun read(sink: Buffer, byteCount: Long): Long { + bodyRead = true + return super.read(sink, byteCount) + } + } + .buffer() + val request = HttpRequest.Builder(HttpMethod.Post, url).build() + val responseBuilder = HttpResponse.Builder(200).body(body) + contentType?.let { responseBuilder.addHeader("content-type", it) } + val response = responseBuilder.build() + val chain = + object : HttpInterceptorChain { + override suspend fun proceed(request: HttpRequest): HttpResponse = response + } + val interceptor = + SentryApollo3HttpInterceptor( + fixture.scopes, + captureFailedRequests = true, + failedRequestTargets = listOf("target.example"), + ) + + interceptor.intercept(request, chain) + return bodyRead + } + @Test fun `customizer modifies span`() { executeQuery( diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index d42f652c318..5964666b15a 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -5,11 +5,14 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.detekt) + id("io.sentry.detekt") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } @@ -42,7 +45,7 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) testImplementation(libs.okhttp.mockwebserver) - testImplementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0") + testImplementation(libs.kotlin.reflect) } tasks.withType().configureEach { 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 83245be52fe..539bdb25b21 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 @@ -283,6 +283,13 @@ constructor( private fun getHeader(key: String, headers: List): String? = headers.firstOrNull { it.name.equals(key, true) }?.value + private fun isStreamingResponse(response: HttpResponse): Boolean { + val contentType = getHeader("Content-Type", response.headers) ?: return false + // strip parameters such as boundary or charset, e.g. "multipart/mixed; boundary=xyz" + val mediaType = contentType.substringBefore(';').trim().lowercase(Locale.ROOT) + return mediaType in STREAMING_MEDIA_TYPES + } + private fun getRequestHeaders(headers: List): MutableMap? { if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { val requestHeaders = mutableMapOf() @@ -349,6 +356,25 @@ constructor( // wrap everything up in a try catch block so every exception is swallowed and degraded // gracefully try { + // not possible to get a parameterized url, but we remove at least the + // query string and the fragment. + // url example: https://api.github.com/users/getsentry/repos/#fragment?query=query + // url will be: https://api.github.com/users/getsentry/repos/ + // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/ + // but that's not possible + val urlDetails = UrlUtils.parse(request.url, scopes.options.dataCollectionResolver) + + // return before reading the response body if it's not a target match + if (!PropagationTargetsUtils.contain(failedRequestTargets, urlDetails.urlOrFallback)) { + return + } + + // streaming responses (e.g. multipart incremental delivery, server-sent events) may never + // reach EOF or be unbounded, so peeking at them would block or buffer indefinitely + if (isStreamingResponse(response)) { + return + } + // we pay the price to read the response in the memory to check if there's any errors // GraphQL does not throw status code 400+ for every type of error val body = @@ -365,19 +391,6 @@ constructor( return } - // not possible to get a parameterized url, but we remove at least the - // query string and the fragment. - // url example: https://api.github.com/users/getsentry/repos/#fragment?query=query - // url will be: https://api.github.com/users/getsentry/repos/ - // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/ - // but that's not possible - val urlDetails = UrlUtils.parse(request.url, scopes.options.dataCollectionResolver) - - // return if it's not a target match - if (!PropagationTargetsUtils.contain(failedRequestTargets, urlDetails.urlOrFallback)) { - return - } - val mechanism = Mechanism().apply { type = "SentryApollo4Interceptor" } val fingerprints = mutableListOf() @@ -471,6 +484,8 @@ constructor( companion object { const val DEFAULT_CAPTURE_FAILED_REQUESTS = true + private val STREAMING_MEDIA_TYPES = setOf("multipart/mixed", "text/event-stream") + init { SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-apollo-4", BuildConfig.VERSION_NAME) 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 f296abf615c..665a4a299c9 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 @@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.Breadcrumb import io.sentry.DataCategory +import io.sentry.Hint import io.sentry.ILogger import io.sentry.IScopes import io.sentry.ITransaction @@ -54,12 +55,16 @@ import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.SocketPolicy +import okio.Buffer +import okio.ForwardingSource +import okio.buffer import org.junit.Before import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever @@ -311,6 +316,83 @@ abstract class SentryApollo4HttpInterceptorTest( assertTrue(baggageHeaderValues[0].contains("sentry-trace_id")) } + @Test + fun `does not read response body if failed request target does not match`(): Unit = runBlocking { + val bodyRead = + interceptWithContentType( + contentType = null, + url = "https://excluded.example/graphql", + ) + + assertThat(bodyRead).isFalse() + } + + @Test + fun `does not read multipart mixed response body`(): Unit = runBlocking { + val bodyRead = interceptWithContentType("Multipart/Mixed; boundary=graphql") + + assertThat(bodyRead).isFalse() + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `does not read event stream response body`(): Unit = runBlocking { + val bodyRead = interceptWithContentType(" text/event-stream ; charset=utf-8") + + assertThat(bodyRead).isFalse() + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `reads json response body for failed request detection`(): Unit = runBlocking { + val bodyRead = interceptWithContentType("application/json; charset=utf-8") + + assertThat(bodyRead).isTrue() + verify(fixture.scopes).captureEvent(any(), any()) + } + + @Test + fun `reads response body without content type for failed request detection`(): Unit = + runBlocking { + val bodyRead = interceptWithContentType(null) + + assertThat(bodyRead).isTrue() + verify(fixture.scopes).captureEvent(any(), any()) + } + + /** Runs the interceptor against a GraphQL error body and returns whether the body was read. */ + private suspend fun interceptWithContentType( + contentType: String?, + url: String = "https://target.example/graphql", + ): Boolean { + var bodyRead = false + val body = + object : ForwardingSource(Buffer().writeUtf8("{\"errors\":[{\"message\":\"error\"}]}")) { + override fun read(sink: Buffer, byteCount: Long): Long { + bodyRead = true + return super.read(sink, byteCount) + } + } + .buffer() + val request = HttpRequest.Builder(HttpMethod.Post, url).build() + val responseBuilder = HttpResponse.Builder(200).body(body) + contentType?.let { responseBuilder.addHeader("content-type", it) } + val response = responseBuilder.build() + val chain = + object : HttpInterceptorChain { + override suspend fun proceed(request: HttpRequest): HttpResponse = response + } + val interceptor = + SentryApollo4HttpInterceptor( + fixture.scopes, + captureFailedRequests = true, + failedRequestTargets = listOf("target.example"), + ) + + interceptor.intercept(request, chain) + return bodyRead + } + @Test fun `customizer modifies span`() { executeQuery( diff --git a/sentry-apollo-5/build.gradle.kts b/sentry-apollo-5/build.gradle.kts index 4d97069a707..f06b85cdc62 100644 --- a/sentry-apollo-5/build.gradle.kts +++ b/sentry-apollo-5/build.gradle.kts @@ -9,6 +9,9 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } @@ -39,7 +42,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) - testImplementation(libs.okhttp.mockwebserver5) + testImplementation(libs.okhttp.mockwebserver) } tasks.withType().configureEach { diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index fbffae9455c..c10f2b66851 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -5,11 +5,14 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.detekt) + id("io.sentry.detekt") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } @@ -40,7 +43,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) - testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.okhttp.mockwebserver4) } tasks.withType().configureEach { diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index 5ae67eae915..7baee9dd076 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java index 09c2ed8b5d2..ced363d4a58 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java @@ -42,16 +42,15 @@ public final class JavaContinuousProfiler implements IContinuousProfiler, RateLimiter.IRateLimitObserver { private static final long MAX_CHUNK_DURATION_MILLIS = 10000; - private final List invalidFilenameChars = - Arrays.asList( - ".", ",", ":", ";", "|", "\\", "=", "%", "&", "[", "]", "(", ")", "<", ">", "{", "}", "!", - "*", "?", "~", "\"", "'", "$", "`", "^"); + // async-profiler splits its arguments on "," and expands "%" patterns in the file name. + 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 hasValidOptions = false; private boolean isRunning = false; private @Nullable IScopes scopes; private @Nullable Future stopFuture; @@ -92,7 +91,7 @@ private void initializeProfiler() throws Exception { private boolean init() { if (isInitialized) { - return true; + return hasValidOptions; } isInitialized = true; @@ -107,6 +106,7 @@ private boolean init() { profilingTracesHz); return false; } + hasValidOptions = true; return true; } 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 index 7764249e3eb..745139a20a6 100644 --- 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 @@ -421,6 +421,31 @@ class JavaContinuousProfilerTest { ) } + @Test + fun `profiler starts when the traces dir path contains a dot`() { + val profiler = fixture.getSut { + it.profilingTracesDirPath = File("tmp", "sentry.traces").absolutePath + } + File(fixture.options.profilingTracesDirPath!!).mkdirs() + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + profiler.close(true) + } + + @Test + fun `profiler stays disabled when the traces dir is created after its first start`() { + val tracesDir = File(fixture.options.profilingTracesDirPath!!) + tracesDir.deleteRecursively() + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertFalse(profiler.isRunning) + + tracesDir.mkdirs() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertFalse(profiler.isRunning) + } + fun withMockScopes(closure: () -> Unit) = Mockito.mockStatic(Sentry::class.java).use { it.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) diff --git a/sentry-bom/build.gradle.kts b/sentry-bom/build.gradle.kts index f219c964e52..bd10c31f831 100644 --- a/sentry-bom/build.gradle.kts +++ b/sentry-bom/build.gradle.kts @@ -1,6 +1,9 @@ plugins { `java-platform` // used to build Maven BOM `maven-publish` + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") } dependencies { @@ -10,12 +13,13 @@ dependencies { !it.name.startsWith("sentry-samples") && it.name != project.name && !it.name.endsWith("-bom") && - !it.name.contains("test", ignoreCase = true) && - !it.name.contains("sentry-android-distribution") + !it.name.contains("test", ignoreCase = true) } .forEach { project -> evaluationDependsOn(project.path) - project.publishing.publications + // container projects such as sentry-opentelemetry have no build file and publish nothing + val publishing = project.extensions.findByType() ?: return@forEach + publishing.publications .mapNotNull { it as? MavenPublication } .filter { !it.artifactId.endsWith("-kotlinMultiplatform") && !it.artifactId.endsWith("-metadata") diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 3bfc82b48be..158058507f8 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -8,10 +8,13 @@ plugins { alias(libs.plugins.kotlin.compose) id("com.android.library") alias(libs.plugins.gradle.versions) - alias(libs.plugins.detekt) + id("io.sentry.detekt") alias(libs.plugins.dokka) alias(libs.plugins.dokka.javadoc) `maven-publish` // necessary for publishMavenLocal task to publish correct artifacts + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish.multiplatform") id("io.sentry.api-validation") } diff --git a/sentry-graphql-22/api/sentry-graphql-22.api b/sentry-graphql-22/api/sentry-graphql-22.api index 2bee0cf765d..b1e5b00adf2 100644 --- a/sentry-graphql-22/api/sentry-graphql-22.api +++ b/sentry-graphql-22/api/sentry-graphql-22.api @@ -7,8 +7,10 @@ public final class io/sentry/graphql22/SentryInstrumentation : graphql/execution public static final field SENTRY_EXCEPTIONS_CONTEXT_KEY Ljava/lang/String; public static final field SENTRY_SCOPES_CONTEXT_KEY Ljava/lang/String; public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Lio/sentry/graphql/ExceptionReporter;Ljava/util/List;)V + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Lio/sentry/graphql/ExceptionReporter;Ljava/util/List;Z)V public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Z)V public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;ZLjava/util/List;)V + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;ZLjava/util/List;Z)V public fun (Lio/sentry/graphql/SentrySubscriptionHandler;Z)V public fun beginExecuteOperation (Lgraphql/execution/instrumentation/parameters/InstrumentationExecuteOperationParameters;Lgraphql/execution/instrumentation/InstrumentationState;)Lgraphql/execution/instrumentation/InstrumentationContext; public fun beginExecution (Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;Lgraphql/execution/instrumentation/InstrumentationState;)Lgraphql/execution/instrumentation/InstrumentationContext; diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts index 4b01f5769f5..0d51375c359 100644 --- a/sentry-graphql-22/build.gradle.kts +++ b/sentry-graphql-22/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-graphql-22/src/main/java/io/sentry/graphql22/SentryInstrumentation.java b/sentry-graphql-22/src/main/java/io/sentry/graphql22/SentryInstrumentation.java index 264c0a99da2..d4833cafee3 100644 --- a/sentry-graphql-22/src/main/java/io/sentry/graphql22/SentryInstrumentation.java +++ b/sentry-graphql-22/src/main/java/io/sentry/graphql22/SentryInstrumentation.java @@ -77,11 +77,35 @@ public SentryInstrumentation( final @NotNull SentrySubscriptionHandler subscriptionHandler, final boolean captureRequestBodyForNonSubscriptions, final @NotNull List ignoredErrorTypes) { + this( + beforeSpan, + subscriptionHandler, + captureRequestBodyForNonSubscriptions, + ignoredErrorTypes, + false); + } + + /** + * @param beforeSpan callback when a span is created + * @param subscriptionHandler can report subscription errors + * @param captureRequestBodyForNonSubscriptions false if request bodies should not be captured by + * this integration for query and mutation operations + * @param ignoredErrorTypes list of error types that should not be captured and sent to Sentry + * @param enableSpecificationErrorTypeFiltering whether ignored error types should also be matched + * against {@link graphql.ErrorClassification#toSpecification(graphql.GraphQLError)} + */ + public SentryInstrumentation( + final @Nullable SentryGraphqlInstrumentation.BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final boolean captureRequestBodyForNonSubscriptions, + final @NotNull List ignoredErrorTypes, + final boolean enableSpecificationErrorTypeFiltering) { this( beforeSpan, subscriptionHandler, new ExceptionReporter(captureRequestBodyForNonSubscriptions), - ignoredErrorTypes); + ignoredErrorTypes, + enableSpecificationErrorTypeFiltering); } @TestOnly @@ -90,9 +114,24 @@ public SentryInstrumentation( final @NotNull SentrySubscriptionHandler subscriptionHandler, final @NotNull ExceptionReporter exceptionReporter, final @NotNull List ignoredErrorTypes) { + this(beforeSpan, subscriptionHandler, exceptionReporter, ignoredErrorTypes, false); + } + + @TestOnly + public SentryInstrumentation( + final @Nullable SentryGraphqlInstrumentation.BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull List ignoredErrorTypes, + final boolean enableSpecificationErrorTypeFiltering) { this.instrumentation = new SentryGraphqlInstrumentation( - beforeSpan, subscriptionHandler, exceptionReporter, ignoredErrorTypes, TRACE_ORIGIN); + beforeSpan, + subscriptionHandler, + exceptionReporter, + ignoredErrorTypes, + enableSpecificationErrorTypeFiltering, + TRACE_ORIGIN); SentryIntegrationPackageStorage.getInstance().addIntegration("GraphQL-v22"); } diff --git a/sentry-graphql-core/api/sentry-graphql-core.api b/sentry-graphql-core/api/sentry-graphql-core.api index 3b68787e8b6..82fd6dc07a3 100644 --- a/sentry-graphql-core/api/sentry-graphql-core.api +++ b/sentry-graphql-core/api/sentry-graphql-core.api @@ -45,7 +45,9 @@ public final class io/sentry/graphql/SentryGraphqlInstrumentation { public static final field SENTRY_EXCEPTIONS_CONTEXT_KEY Ljava/lang/String; public static final field SENTRY_SCOPES_CONTEXT_KEY Ljava/lang/String; public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Lio/sentry/graphql/ExceptionReporter;Ljava/util/List;Ljava/lang/String;)V + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Lio/sentry/graphql/ExceptionReporter;Ljava/util/List;ZLjava/lang/String;)V public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;ZLjava/util/List;Ljava/lang/String;)V + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;ZLjava/util/List;ZLjava/lang/String;)V public fun beginExecuteOperation (Lgraphql/execution/instrumentation/parameters/InstrumentationExecuteOperationParameters;)V public fun beginExecution (Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;Lio/sentry/graphql/SentryGraphqlInstrumentation$TracingState;)V public fun createState ()Lgraphql/execution/instrumentation/InstrumentationState; diff --git a/sentry-graphql-core/build.gradle.kts b/sentry-graphql-core/build.gradle.kts index 7869fb0cb34..73dd1a5f27e 100644 --- a/sentry-graphql-core/build.gradle.kts +++ b/sentry-graphql-core/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java index b1f2892500a..cb1757e30d4 100644 --- a/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java @@ -55,6 +55,7 @@ public final class SentryGraphqlInstrumentation { private final @NotNull SentrySubscriptionHandler subscriptionHandler; private final @NotNull ExceptionReporter exceptionReporter; private final @NotNull List ignoredErrorTypes; + private final boolean enableSpecificationErrorTypeFiltering; private final @NotNull String traceOrigin; /** @@ -72,11 +73,38 @@ public SentryGraphqlInstrumentation( final boolean captureRequestBodyForNonSubscriptions, final @NotNull List ignoredErrorTypes, final @NotNull String traceOrigin) { + this( + beforeSpan, + subscriptionHandler, + captureRequestBodyForNonSubscriptions, + ignoredErrorTypes, + false, + traceOrigin); + } + + /** + * @param beforeSpan callback when a span is created + * @param subscriptionHandler can report subscription errors + * @param captureRequestBodyForNonSubscriptions false if request bodies should not be captured by + * this integration for query and mutation operations + * @param ignoredErrorTypes list of error types that should not be captured and sent to Sentry + * @param enableSpecificationErrorTypeFiltering whether ignored error types should also be matched + * against {@link ErrorClassification#toSpecification(GraphQLError)} + * @param traceOrigin origin used for spans created by this integration + */ + public SentryGraphqlInstrumentation( + final @Nullable BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final boolean captureRequestBodyForNonSubscriptions, + final @NotNull List ignoredErrorTypes, + final boolean enableSpecificationErrorTypeFiltering, + final @NotNull String traceOrigin) { this( beforeSpan, subscriptionHandler, new ExceptionReporter(captureRequestBodyForNonSubscriptions), ignoredErrorTypes, + enableSpecificationErrorTypeFiltering, traceOrigin); } @@ -87,10 +115,22 @@ public SentryGraphqlInstrumentation( final @NotNull ExceptionReporter exceptionReporter, final @NotNull List ignoredErrorTypes, final @NotNull String traceOrigin) { + this(beforeSpan, subscriptionHandler, exceptionReporter, ignoredErrorTypes, false, traceOrigin); + } + + @TestOnly + public SentryGraphqlInstrumentation( + final @Nullable BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull List ignoredErrorTypes, + final boolean enableSpecificationErrorTypeFiltering, + final @NotNull String traceOrigin) { this.beforeSpan = beforeSpan; this.subscriptionHandler = subscriptionHandler; this.exceptionReporter = exceptionReporter; this.ignoredErrorTypes = ignoredErrorTypes; + this.enableSpecificationErrorTypeFiltering = enableSpecificationErrorTypeFiltering; this.traceOrigin = traceOrigin; } @@ -127,8 +167,7 @@ public void instrumentExecutionResultComplete( final @NotNull List errors = result.getErrors(); if (errors != null) { for (GraphQLError error : errors) { - String errorType = getErrorType(error); - if (!isIgnored(errorType)) { + if (!isIgnored(error)) { exceptionReporter.captureThrowable( new RuntimeException(error.getMessage()), new ExceptionReporter.ExceptionDetails( @@ -158,19 +197,38 @@ private boolean isIgnored(final @Nullable String errorType) { || ignoredErrorTypes.contains(errorType); } - private @Nullable String getErrorType(final @Nullable GraphQLError error) { + private boolean isIgnored(final @Nullable GraphQLError error) { if (error == null) { - return null; + return false; } final @Nullable ErrorClassification errorType = error.getErrorType(); if (errorType != null) { - return errorType.toString(); + if (isIgnored(errorType.toString())) { + return true; + } + if (!enableSpecificationErrorTypeFiltering) { + return false; + } + try { + final @Nullable Object specification = errorType.toSpecification(error); + if (specification instanceof String && isIgnored((String) specification)) { + return true; + } + if (specification instanceof Map) { + final @Nullable Object specificationType = ((Map) specification).get("type"); + return specificationType instanceof String && isIgnored((String) specificationType); + } + } catch (Throwable throwable) { + ExceptionUtils.rethrowIfFatal(throwable); + // Fall back to capturing the error if a custom classification cannot be serialized. + } + return false; } final @Nullable Map extensions = error.getExtensions(); if (extensions != null) { - return StringUtils.toString(extensions.get("errorType")); + return isIgnored(StringUtils.toString(extensions.get("errorType"))); } - return null; + return false; } public void beginExecuteOperation( diff --git a/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/SentryGraphqlInstrumentationTest.kt b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/SentryGraphqlInstrumentationTest.kt new file mode 100644 index 00000000000..09843710bea --- /dev/null +++ b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/SentryGraphqlInstrumentationTest.kt @@ -0,0 +1,198 @@ +package io.sentry.graphql + +import graphql.ErrorClassification +import graphql.ExecutionResultImpl +import graphql.GraphQLContext +import graphql.GraphQLError +import graphql.GraphqlErrorException +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters +import io.sentry.IScopes +import kotlin.test.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryGraphqlInstrumentationTest { + private val scopes = mock() + private val exceptionReporter = mock() + private val parameters = mock() + + init { + whenever(parameters.graphQLContext) + .thenReturn( + GraphQLContext.newContext() + .of(SentryGraphqlInstrumentation.SENTRY_SCOPES_CONTEXT_KEY, scopes) + .build() + ) + } + + @Test + fun `ignores error by classification string`() { + captureError( + error(CustomErrorClassification("SOME_ERROR", mapOf("type" to "OTHER_ERROR"))), + ignoredErrorTypes = listOf("SOME_ERROR"), + ) + + verify(exceptionReporter, never()).captureThrowable(any(), any(), any()) + } + + @Test + fun `captures error by classification specification type when filtering is disabled`() { + captureError( + error( + CustomErrorClassification( + "graphql.validation.interpolation.ResourceBundleMessageInterpolator\$ValidationErrorType@1", + mapOf("type" to "ExtendedValidationError"), + ) + ), + ignoredErrorTypes = listOf("ExtendedValidationError"), + ) + + verify(exceptionReporter).captureThrowable(any(), any(), any()) + } + + @Test + fun `does not inspect classification specification when filtering is disabled`() { + val classification = mock() + whenever(classification.toString()).thenReturn("SOME_ERROR") + + captureError( + error(classification), + ignoredErrorTypes = listOf("SPECIFICATION_ERROR"), + ) + + verify(classification, never()).toSpecification(any()) + } + + @Test + fun `ignores error by classification specification type when filtering is enabled`() { + captureError( + error( + CustomErrorClassification( + "graphql.validation.interpolation.ResourceBundleMessageInterpolator\$ValidationErrorType@1", + mapOf("type" to "ExtendedValidationError"), + ) + ), + ignoredErrorTypes = listOf("ExtendedValidationError"), + enableSpecificationErrorTypeFiltering = true, + ) + + verify(exceptionReporter, never()).captureThrowable(any(), any(), any()) + } + + @Test + fun `ignores error by string classification specification`() { + captureError( + error(CustomErrorClassification("SOME_ERROR", "SPECIFICATION_ERROR")), + ignoredErrorTypes = listOf("SPECIFICATION_ERROR"), + enableSpecificationErrorTypeFiltering = true, + ) + + verify(exceptionReporter, never()).captureThrowable(any(), any(), any()) + } + + @Test + fun `captures error when classification specification has no type`() { + captureError( + error(CustomErrorClassification("SOME_ERROR", mapOf("constraint" to "@Size"))), + enableSpecificationErrorTypeFiltering = true, + ) + + verify(exceptionReporter).captureThrowable(any(), any(), any()) + } + + @Test + fun `captures error when classification specification is null`() { + captureError( + error(CustomErrorClassification("SOME_ERROR", null)), + enableSpecificationErrorTypeFiltering = true, + ) + + verify(exceptionReporter).captureThrowable(any(), any(), any()) + } + + @Test + fun `captures error when classification specification type is unexpected`() { + captureError( + error(CustomErrorClassification("SOME_ERROR", mapOf("type" to 42))), + enableSpecificationErrorTypeFiltering = true, + ) + + verify(exceptionReporter).captureThrowable(any(), any(), any()) + } + + @Test + fun `captures error when classification specification throws`() { + captureError( + error(ThrowingErrorClassification), + enableSpecificationErrorTypeFiltering = true, + ) + + verify(exceptionReporter).captureThrowable(any(), any(), any()) + } + + @Test + fun `ignores error by extensions when classification is null`() { + val error = mock() + whenever(error.errorType).thenReturn(null) + whenever(error.extensions).thenReturn(mapOf("errorType" to "EXTENSION_ERROR")) + + captureError(error, ignoredErrorTypes = listOf("EXTENSION_ERROR")) + + verify(exceptionReporter, never()).captureThrowable(any(), any(), any()) + } + + @Test + fun `captures non-ignored error`() { + captureError( + error(CustomErrorClassification("SOME_ERROR", mapOf("type" to "SPECIFICATION_ERROR"))), + ignoredErrorTypes = listOf("OTHER_ERROR"), + enableSpecificationErrorTypeFiltering = true, + ) + + verify(exceptionReporter).captureThrowable(any(), any(), any()) + } + + private fun captureError( + error: GraphQLError, + ignoredErrorTypes: List = emptyList(), + enableSpecificationErrorTypeFiltering: Boolean = false, + ) { + val instrumentation = + SentryGraphqlInstrumentation( + null, + NoOpSubscriptionHandler.getInstance(), + exceptionReporter, + ignoredErrorTypes, + enableSpecificationErrorTypeFiltering, + "manual.test", + ) + val result = ExecutionResultImpl.newExecutionResult().addError(error).build() + + instrumentation.instrumentExecutionResultComplete(parameters, result, null) + } + + private fun error(errorClassification: ErrorClassification): GraphQLError = + GraphqlErrorException.newErrorException() + .message("exception message") + .errorClassification(errorClassification) + .build() + + private class CustomErrorClassification( + private val stringValue: String, + private val specification: Any?, + ) : ErrorClassification { + override fun toSpecification(error: GraphQLError): Any? = specification + + override fun toString(): String = stringValue + } + + private object ThrowingErrorClassification : ErrorClassification { + override fun toSpecification(error: GraphQLError): Any = + throw AssertionError("failed to create specification") + + override fun toString(): String = "SOME_ERROR" + } +} diff --git a/sentry-graphql/api/sentry-graphql.api b/sentry-graphql/api/sentry-graphql.api index 2e5f81ae760..4f58e5ee912 100644 --- a/sentry-graphql/api/sentry-graphql.api +++ b/sentry-graphql/api/sentry-graphql.api @@ -7,8 +7,10 @@ public final class io/sentry/graphql/SentryInstrumentation : graphql/execution/i public static final field SENTRY_EXCEPTIONS_CONTEXT_KEY Ljava/lang/String; public static final field SENTRY_SCOPES_CONTEXT_KEY Ljava/lang/String; public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Lio/sentry/graphql/ExceptionReporter;Ljava/util/List;)V + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Lio/sentry/graphql/ExceptionReporter;Ljava/util/List;Z)V public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Z)V public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;ZLjava/util/List;)V + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;ZLjava/util/List;Z)V public fun (Lio/sentry/graphql/SentrySubscriptionHandler;Z)V public fun beginExecuteOperation (Lgraphql/execution/instrumentation/parameters/InstrumentationExecuteOperationParameters;)Lgraphql/execution/instrumentation/InstrumentationContext; public fun beginExecution (Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;)Lgraphql/execution/instrumentation/InstrumentationContext; diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index 772bcb03923..5b862e4e448 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java b/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java index b8d35697f1f..0d0736f8b70 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java @@ -74,11 +74,35 @@ public SentryInstrumentation( final @NotNull SentrySubscriptionHandler subscriptionHandler, final boolean captureRequestBodyForNonSubscriptions, final @NotNull List ignoredErrorTypes) { + this( + beforeSpan, + subscriptionHandler, + captureRequestBodyForNonSubscriptions, + ignoredErrorTypes, + false); + } + + /** + * @param beforeSpan callback when a span is created + * @param subscriptionHandler can report subscription errors + * @param captureRequestBodyForNonSubscriptions false if request bodies should not be captured by + * this integration for query and mutation operations + * @param ignoredErrorTypes list of error types that should not be captured and sent to Sentry + * @param enableSpecificationErrorTypeFiltering whether ignored error types should also be matched + * against {@link graphql.ErrorClassification#toSpecification(graphql.GraphQLError)} + */ + public SentryInstrumentation( + final @Nullable SentryGraphqlInstrumentation.BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final boolean captureRequestBodyForNonSubscriptions, + final @NotNull List ignoredErrorTypes, + final boolean enableSpecificationErrorTypeFiltering) { this( beforeSpan, subscriptionHandler, new ExceptionReporter(captureRequestBodyForNonSubscriptions), - ignoredErrorTypes); + ignoredErrorTypes, + enableSpecificationErrorTypeFiltering); } @TestOnly @@ -87,9 +111,24 @@ public SentryInstrumentation( final @NotNull SentrySubscriptionHandler subscriptionHandler, final @NotNull ExceptionReporter exceptionReporter, final @NotNull List ignoredErrorTypes) { + this(beforeSpan, subscriptionHandler, exceptionReporter, ignoredErrorTypes, false); + } + + @TestOnly + public SentryInstrumentation( + final @Nullable SentryGraphqlInstrumentation.BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull List ignoredErrorTypes, + final boolean enableSpecificationErrorTypeFiltering) { this.instrumentation = new SentryGraphqlInstrumentation( - beforeSpan, subscriptionHandler, exceptionReporter, ignoredErrorTypes, TRACE_ORIGIN); + beforeSpan, + subscriptionHandler, + exceptionReporter, + ignoredErrorTypes, + enableSpecificationErrorTypeFiltering, + TRACE_ORIGIN); SentryIntegrationPackageStorage.getInstance().addIntegration("GraphQL"); } diff --git a/sentry-jcache/build.gradle.kts b/sentry-jcache/build.gradle.kts index 1e6fc567fdd..de5ff284e7d 100644 --- a/sentry-jcache/build.gradle.kts +++ b/sentry-jcache/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-jdbc/build.gradle.kts b/sentry-jdbc/build.gradle.kts index 5e4df38ed41..f58175c703d 100644 --- a/sentry-jdbc/build.gradle.kts +++ b/sentry-jdbc/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-jul/build.gradle.kts b/sentry-jul/build.gradle.kts index b5886226318..6c8d918de6e 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts index 5d2f99600f3..17f07eb7332 100644 --- a/sentry-kafka/build.gradle.kts +++ b/sentry-kafka/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index 79332d18b80..848b19819f4 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -7,8 +7,11 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) - alias(libs.plugins.detekt) + id("io.sentry.detekt") id("io.sentry.animalsniffer.android") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index c0ca7dac26b..6f26d029df8 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -4,12 +4,15 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.detekt) + id("io.sentry.detekt") id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-launchdarkly-android/build.gradle.kts b/sentry-launchdarkly-android/build.gradle.kts index 50dd3ea97a7..f2d7a4b813a 100644 --- a/sentry-launchdarkly-android/build.gradle.kts +++ b/sentry-launchdarkly-android/build.gradle.kts @@ -2,6 +2,9 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-launchdarkly-server/build.gradle.kts b/sentry-launchdarkly-server/build.gradle.kts index 49dec717830..6e6df9e428c 100644 --- a/sentry-launchdarkly-server/build.gradle.kts +++ b/sentry-launchdarkly-server/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 9bd080467ac..009455c9c1e 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-logback/build.gradle.kts b/sentry-logback/build.gradle.kts index 462a958ad98..401d41b5afd 100644 --- a/sentry-logback/build.gradle.kts +++ b/sentry-logback/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-okhttp/build.gradle.kts b/sentry-okhttp/build.gradle.kts index c9979ad6d37..dbda6e5c9cd 100644 --- a/sentry-okhttp/build.gradle.kts +++ b/sentry-okhttp/build.gradle.kts @@ -4,12 +4,15 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.detekt) + id("io.sentry.detekt") id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/CapturedResponseBody.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/CapturedResponseBody.kt new file mode 100644 index 00000000000..02e725c38a5 --- /dev/null +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/CapturedResponseBody.kt @@ -0,0 +1,116 @@ +package io.sentry.okhttp + +import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean +import okhttp3.MediaType +import okhttp3.ResponseBody +import okio.Buffer +import okio.BufferedSource +import okio.ForwardingSource +import okio.Source +import okio.buffer + +/** + * A [ResponseBody] that copies the bytes the application reads into a capped buffer. + * + * @param delegate the body to capture from. + * @param maxBytes the maximum number of bytes to retain; capture stops once it is reached. + * @param onCaptured invoked at most once, as soon as the capture can no longer grow: the cap is + * reached, the stream ends, a read fails, or the body is closed. Runs synchronously on the thread + * that read or closed the body, so it must not block. A body abandoned without any of those never + * reaches it. `isComplete` is false when the bytes are only a prefix of the body. + */ +internal class CapturedResponseBody( + private val delegate: ResponseBody, + private val maxBytes: Long, + private val onCaptured: (bytes: ByteArray, isComplete: Boolean) -> Unit, +) : ResponseBody() { + + private val captured = Buffer() + private val capturedOut = captured.outputStream() + + private val reported = AtomicBoolean(false) + @Volatile private var bytesRead = 0L + @Volatile private var endOfStream = false + + private val capturingSourceLazy = lazy { CapturingSource(delegate.source()).buffer() } + private val capturingSource: BufferedSource by capturingSourceLazy + + override fun contentType(): MediaType? = delegate.contentType() + + override fun contentLength(): Long = delegate.contentLength() + + override fun source(): BufferedSource = capturingSource + + override fun close() { + // Closing the delegate notifies listeners, which may serialize the breadcrumb this capture + // belongs to, so report first. + delegate.use { + try { + reportCaptured() + } finally { + // the application may still hold the source, and must not read buffered bytes from it + if (capturingSourceLazy.isInitialized()) { + capturingSource.close() + } + } + } + } + + private inner class CapturingSource(source: Source) : ForwardingSource(source) { + override fun read(sink: Buffer, byteCount: Long): Long { + val sinkBefore = sink.size + val read = + try { + super.read(sink, byteCount) + } catch (e: IOException) { + // What arrived is the evidence for this very failure, and a caller handling it is not + // obliged to close the body. + reportCaptured() + throw e + } + + if (read > 0L) { + bytesRead += read + if (!reported.get()) { + val capFull = + synchronized(captured) { + // checked again under the lock, so nothing is copied into a drained capture + if (reported.get()) { + return@synchronized false + } + // the application may not have taken everything in the sink yet, hence the offset + val toTake = minOf(maxBytes - captured.size, sink.size - sinkBefore) + if (toTake > 0L) { + sink.copyTo(capturedOut, sinkBefore, toTake) + } + captured.size >= maxBytes + } + if (capFull) { + reportCaptured() + } + } + } else if (read == -1L) { + endOfStream = true + reportCaptured() + } + return read + } + + override fun close() { + try { + reportCaptured() + } finally { + super.close() + } + } + } + + private fun reportCaptured() { + if (reported.compareAndSet(false, true)) { + val length = delegate.contentLength() + val isComplete = endOfStream || (length >= 0L && bytesRead >= length) + onCaptured(synchronized(captured) { captured.readByteArray() }, isComplete) + } + } +} diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEventListener.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEventListener.kt index b43fcfdfb66..12836a732b0 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEventListener.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEventListener.kt @@ -245,11 +245,9 @@ public open class SentryOkHttpEventListener( } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return okHttpEvent.setProtocol(protocol?.name) - okHttpEvent.setError(ioe.message) - okHttpEvent.onEventFinish(CONNECT_EVENT) { - it.throwable = ioe - it.status = SpanStatus.INTERNAL_ERROR - } + // A failed route doesn't fail the call: OkHttp moves on to the next route, and OkHttp 5 races + // routes for Happy Eyeballs. If no route connects, callFailed() reports the error. + okHttpEvent.onEventFinish(CONNECT_EVENT) } override fun connectionAcquired(call: Call, connection: Connection) { 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 1cfe0ae1da7..7341cc57a84 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -20,7 +20,6 @@ import io.sentry.TypeCheckHint.OKHTTP_REQUEST import io.sentry.TypeCheckHint.OKHTTP_RESPONSE import io.sentry.TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS import io.sentry.clientreport.DiscardReason -import io.sentry.okhttp.SentryOkHttpInterceptor.BeforeSpanCallback import io.sentry.transport.CurrentDateProvider import io.sentry.util.ExceptionUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion @@ -54,6 +53,7 @@ import org.jetbrains.annotations.VisibleForTesting * @param failedRequestTargets The SDK will only capture HTTP Client errors if the HTTP Request URL * is a match for any of the defined targets. */ +@Suppress("TooManyFunctions") // one function per concern of the interception pipeline public open class SentryOkHttpInterceptor( private val scopes: IScopes = ScopesAdapter.getInstance(), private val beforeSpan: BeforeSpanCallback? = null, @@ -117,13 +117,19 @@ public open class SentryOkHttpInterceptor( var response: Response? = null var code: Int? = null + // Network details only end up in a replay, so capturing them while no replay is recording or + // buffering is wasted work. val networkDetailData = - NetworkDetailCaptureUtils.initializeForUrl( - request.url.toString(), - request.method, - scopes.options.sessionReplay.networkDetailAllowUrls, - scopes.options.sessionReplay.networkDetailDenyUrls, - ) + if (scopes.options.replayController.isRecording) { + NetworkDetailCaptureUtils.initializeForUrl( + request.url.toString(), + request.method, + scopes.options.sessionReplay.networkDetailAllowUrls, + scopes.options.sessionReplay.networkDetailDenyUrls, + ) + } else { + null + } try { val requestBuilder = request.newBuilder() @@ -195,6 +201,9 @@ public open class SentryOkHttpInterceptor( } } + // after the client error was captured from the untouched response + response = response.withNetworkBodyCapture(networkDetailData) + return response } catch (e: IOException) { span?.apply { @@ -207,20 +216,6 @@ public open class SentryOkHttpInterceptor( // this only works correctly if SentryOkHttpInterceptor is the last one in the chain okHttpEvent?.setRequest(request) - response?.let { - networkDetailData?.setResponseDetails( - it.code, - NetworkDetailCaptureUtils.createResponse( - it, - it.body?.contentLength(), - scopes.options.sessionReplay.isNetworkCaptureBodies, - { resp: Response -> resp.extractResponseBody(scopes.options.logger) }, - scopes.options.sessionReplay.networkResponseHeaders, - { resp: Response -> resp.headers.toMap() }, - ), - ) - } - // Set network details on the OkHttpEvent so it can include them in the breadcrumb hint okHttpEvent?.setNetworkDetails(networkDetailData) @@ -321,41 +316,96 @@ public open class SentryOkHttpInterceptor( logger, ) } catch (e: Exception) { - logger.log(io.sentry.SentryLevel.ERROR, "Failed to parse buffered request body: ${e.message}") + logger.log(SentryLevel.ERROR, "Failed to parse buffered request body: ${e.message}") return null } } - /** Extracts the body content from an OkHttp Response safely */ - private fun Response.extractResponseBody(logger: ILogger): NetworkBody? { - return body?.let { responseBody -> - try { - val contentType = responseBody.contentType() - val contentTypeString = contentType?.toString() - val maxBodySize = SentryReplayOptions.MAX_NETWORK_BODY_SIZE - - // Peek at the body (doesn't consume it) - // We +1 here in order to properly truncate within NetworkBodyParser.fromBytes - // and be able to distinguish from an oversized request and a request matching maxBodySize - val peekBody = peekBody(maxBodySize.toLong() + 1) - val bodyBytes = peekBody.bytes() - - val charset = contentType?.charset(Charsets.UTF_8)?.name() ?: "UTF-8" - return NetworkBodyParser.fromBytes( - bodyBytes, - contentTypeString, - charset, - maxBodySize, - logger, - ) - } catch (e: Exception) { - logger.log( - io.sentry.SentryLevel.ERROR, - "Failed to read http response body for Network Details: ${e.message}", - ) - null - } + /** + * Returns this response with a body whose content is captured for replay while the application + * reads it. + * + * Reading the body up front, as [Response.peekBody] does, deadlocks on a response of unknown + * length: server-sent events, a long-poll, a chunked endpoint that stays open, and every response + * OkHttp decompressed itself. The caller would never receive the response at all. + * + * Every body therefore goes through [CapturedResponseBody], which captures only what the + * application reads. A body nobody reads leaves the status code and the headers recorded below, + * and no body. + */ + private fun Response.withNetworkBodyCapture(networkDetailData: NetworkRequestData?): Response { + val responseBody = body + if (networkDetailData == null || responseBody == null) { + return this } + + val knownBodySize = responseBody.contentLength().takeIf { it >= 0L } + + // The status code and the headers are known now, so record them before anything is consumed. A + // stream that stays open for minutes never gets further than this. + recordResponse(networkDetailData, knownBodySize) { null } + + if (!scopes.options.sessionReplay.isNetworkCaptureBodies) { + return this + } + + val logger = scopes.options.logger + // One byte above the limit, so NetworkBodyParser.fromBytes can tell a truncated body from one + // that happens to match the limit exactly. + val captureCap = SentryReplayOptions.MAX_NETWORK_BODY_SIZE.toLong() + 1 + val contentType = responseBody.contentType() + val contentTypeString = contentType?.toString() + val charset = contentType?.charset(Charsets.UTF_8)?.name() ?: "UTF-8" + + return newBuilder() + .body( + CapturedResponseBody(responseBody, captureCap) { capturedBytes, isComplete -> + // This runs on the thread consuming the body, inside its read or close, so a failure in + // here must stay in here. + try { + recordResponse(networkDetailData, knownBodySize ?: capturedBytes.size.toLong()) { + NetworkBodyParser.fromBytes( + capturedBytes, + contentTypeString, + charset, + SentryReplayOptions.MAX_NETWORK_BODY_SIZE, + !isComplete, + logger, + ) + } + } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) { + ExceptionUtils.rethrowIfFatal(e) + logger.log( + SentryLevel.ERROR, + "Failed to capture the http response body for Network Details", + e, + ) + } + } + ) + .build() + } + + /** + * Records what is known about this response, replacing anything recorded for it before. Called + * once before the body is consumed and once with the captured body. + */ + private fun Response.recordResponse( + networkDetailData: NetworkRequestData, + bodySize: Long?, + body: () -> NetworkBody?, + ) { + networkDetailData.setResponseDetails( + NetworkDetailCaptureUtils.createResponse( + this, + code, + bodySize, + true, + { body() }, + scopes.options.sessionReplay.networkResponseHeaders, + { resp: Response -> resp.headers.toMap() }, + ) + ) } private fun finishSpan( diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/CapturedResponseBodyTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/CapturedResponseBodyTest.kt new file mode 100644 index 00000000000..0a5829d9c5f --- /dev/null +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/CapturedResponseBodyTest.kt @@ -0,0 +1,379 @@ +package io.sentry.okhttp + +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody +import okio.Buffer +import okio.BufferedSource +import okio.Source +import okio.Timeout +import okio.buffer + +class CapturedResponseBodyTest { + + /** Emits one chunk per read, then EOF. Records how many reads happened. */ + private class ChunkedSource(private val chunks: List) : Source { + var reads = 0 + private set + + private var index = 0 + private var closed = false + + override fun read(sink: Buffer, byteCount: Long): Long { + reads++ + if (index >= chunks.size) return -1L + val chunk = chunks[index++] + sink.write(chunk) + return chunk.size.toLong() + } + + override fun timeout(): Timeout = Timeout.NONE + + override fun close() { + closed = true + } + + val isClosed: Boolean + get() = closed + } + + private class FailingSource(private val bytesBeforeFailure: Int) : Source { + var isClosed = false + private set + + private var written = 0 + + override fun read(sink: Buffer, byteCount: Long): Long { + if (written >= bytesBeforeFailure) throw IOException("connection reset") + sink.write(ByteArray(bytesBeforeFailure) { 'x'.code.toByte() }) + written += bytesBeforeFailure + return bytesBeforeFailure.toLong() + } + + override fun timeout(): Timeout = Timeout.NONE + + override fun close() { + isClosed = true + } + } + + /** Emits one chunk, then parks until it is closed, like a stream that has gone quiet. */ + private class ParkingSource(private val chunk: ByteArray) : Source { + private val closed = CountDownLatch(1) + private var sent = false + + override fun read(sink: Buffer, byteCount: Long): Long { + if (!sent) { + sent = true + sink.write(chunk) + return chunk.size.toLong() + } + closed.await(10, TimeUnit.SECONDS) + return -1L + } + + override fun timeout(): Timeout = Timeout.NONE + + override fun close() { + closed.countDown() + } + } + + private fun bodyOf( + source: Source, + type: String? = "text/plain", + length: Long = -1L, + ): ResponseBody = + object : ResponseBody() { + override fun contentType(): MediaType? = type?.toMediaType() + + override fun contentLength(): Long = length + + override fun source(): BufferedSource = source.buffer() + } + + private val completeness = mutableListOf() + + private fun capture( + source: Source, + maxBytes: Long, + type: String? = "text/plain", + length: Long = -1L, + ): Pair> { + val captured = mutableListOf() + val wrapper = + CapturedResponseBody(bodyOf(source, type, length), maxBytes) { bytes, isComplete -> + captured.add(bytes) + completeness.add(isComplete) + } + return wrapper to captured + } + + @Test + fun `does not read anything before the application does`() { + val source = ChunkedSource(listOf("hello".toByteArray())) + val (wrapper, captured) = capture(source, 1024) + + assertEquals(0, source.reads, "the wrapper must not read the body up front") + assertTrue(captured.isEmpty(), "nothing can be captured before the application reads") + assertEquals("text/plain", wrapper.contentType()?.toString(), "content type is delegated") + } + + @Test + fun `forwards every byte to the application unchanged`() { + val payload = "data: event-0\n\ndata: event-1\n\n".toByteArray() + val (wrapper, _) = capture(ChunkedSource(listOf(payload)), 1024) + + assertContentEquals(payload, wrapper.source().readByteArray()) + } + + @Test + fun `captures the whole body when it is consumed and ends`() { + val (wrapper, captured) = + capture( + ChunkedSource(listOf("hello ".toByteArray(), "world".toByteArray())), + 1024, + ) + + wrapper.source().readByteArray() + + assertEquals(1, captured.size, "the capture must be reported exactly once") + assertEquals("hello world", captured.single()?.decodeToString()) + } + + @Test + fun `passes small events through as they arrive and captures them on close`() { + val events = (0 until 3).map { "data: event-$it\n\n".toByteArray() } + val source = ChunkedSource(events) + val (wrapper, captured) = capture(source, 1024) + + val application = wrapper.source() + for (expected in events) { + assertContentEquals(expected, application.readByteArray(expected.size.toLong())) + } + + assertTrue(captured.isEmpty(), "an open stream has nothing final to report yet") + + wrapper.close() + + assertEquals(1, captured.size) + assertEquals( + "data: event-0\n\ndata: event-1\n\ndata: event-2\n\n", + captured.single()?.decodeToString(), + ) + } + + @Test + fun `captures what was read when the body is closed early`() { + val (wrapper, captured) = + capture(ChunkedSource(listOf("first ".toByteArray(), "second".toByteArray())), 1024) + + val application = wrapper.source() + assertEquals("first ", application.readUtf8(6)) + + wrapper.close() + + assertEquals(1, captured.size) + assertEquals("first ", captured.single()?.decodeToString()) + assertEquals(listOf(false), completeness, "the rest of the body never arrived") + } + + @Test + fun `does not read a body the application never read`() { + val source = ChunkedSource(listOf("failure".toByteArray())) + val (wrapper, captured) = capture(source, 1024, length = 7L) + + wrapper.close() + + assertEquals(0, source.reads, "the capture must not read on its own account") + assertTrue(captured.single()!!.isEmpty()) + } + + @Test + fun `reports an empty capture for an empty body`() { + val (wrapper, captured) = capture(ChunkedSource(emptyList()), 1024) + + wrapper.source().readByteArray() + wrapper.close() + + assertEquals(1, captured.size) + assertEquals(0, captured.single()?.size) + } + + @Test + fun `never captures more than the cap but still delivers the whole body`() { + val payload = "0123456789abcdefghij".toByteArray() + val (wrapper, captured) = capture(ChunkedSource(listOf(payload)), 8) + + assertContentEquals( + payload, + wrapper.source().readByteArray(), + "the application is not truncated", + ) + + assertEquals(1, captured.size, "reaching the cap is final, so it is reported once") + assertEquals("01234567", captured.single()?.decodeToString()) + } + + @Test + fun `captures every byte when the application reads less than a chunk at a time`() { + // The capture copies out of the application's sink at an offset, because a BufferedSource keeps + // what the application did not take yet in that same sink. + val chunks = (0 until 5).map { i -> "chunk-$i--".toByteArray() } + val whole = chunks.joinToString("") { it.decodeToString() } + val (wrapper, captured) = capture(ChunkedSource(chunks), 1024) + + val source = wrapper.source() + val read = StringBuilder() + while (!source.exhausted()) { + read.append(source.readUtf8(minOf(3L, source.buffer.size.coerceAtLeast(1L)))) + } + + assertEquals(whole, read.toString(), "the application receives the whole body") + assertEquals(whole, captured.single()?.decodeToString(), "and the capture holds the same bytes") + } + + @Test + fun `closes the delegate even when the capture callback throws`() { + val source = ChunkedSource(listOf("payload".toByteArray())) + val body = bodyOf(source) + val wrapper = + CapturedResponseBody(body, 1024) { _, _ -> throw IllegalStateException("parse failed") } + + assertFailsWith { wrapper.close() } + assertTrue(source.isClosed, "the delegate is closed even though the callback threw") + } + + @Test + fun `reports once when the body is closed while another thread is reading it`() { + // Cancelling a stream from elsewhere closes the body from a thread other than the consumer, so + // the capture is read and written at the same time. + val chunk = "data: event-0\n\n".toByteArray() + val source = ParkingSource(chunk) + val (wrapper, captured) = capture(source, 1024) + val chunkRead = CountDownLatch(1) + val readDone = CountDownLatch(1) + val reader = Thread { + val bodySource = wrapper.source() + bodySource.readByteArray(chunk.size.toLong()) + chunkRead.countDown() + bodySource.readByteArray() + readDone.countDown() + } + reader.isDaemon = true + reader.start() + + assertTrue(chunkRead.await(10, TimeUnit.SECONDS), "the reader must have received the chunk") + wrapper.close() + + assertTrue( + readDone.await(10, TimeUnit.SECONDS), + "the reader must finish once the body is closed", + ) + assertEquals( + 1, + captured.size, + "the capture is reported once, by whichever thread got there first", + ) + assertContentEquals(chunk, captured.single()) + } + + @Test + fun `reports a body that ended as complete`() { + val (wrapper, _) = capture(ChunkedSource(listOf("done".toByteArray())), 1024) + + wrapper.source().readByteArray() + + assertEquals(listOf(true), completeness) + } + + @Test + fun `reports a body of known length as complete once all of it was read`() { + // the application read exactly the declared length and closed, without ever seeing the end + val payload = "payload".toByteArray() + val (wrapper, _) = capture(ChunkedSource(listOf(payload)), 1024, length = payload.size.toLong()) + + wrapper.source().readByteArray(payload.size.toLong()) + wrapper.close() + + assertEquals(listOf(true), completeness) + } + + @Test + fun `closes the source the application holds when the body is closed`() { + val (wrapper, _) = + capture(ChunkedSource(listOf("first ".toByteArray(), "second".toByteArray())), 1024) + val application = wrapper.source() + application.readUtf8(3) + + wrapper.close() + + assertFailsWith { application.readUtf8(3) } + } + + @Test + fun `reports only once when the body ends and is then closed`() { + val (wrapper, captured) = capture(ChunkedSource(listOf("done".toByteArray())), 1024) + + wrapper.source().readByteArray() + wrapper.close() + wrapper.source().close() + + assertEquals(1, captured.size, "the capture is reported exactly once") + } + + @Test + fun `delegates content type and content length`() { + val type = "text/event-stream".toMediaType() + val delegate = + object : ResponseBody() { + override fun contentType(): MediaType? = type + + override fun contentLength(): Long = -1L + + override fun source(): BufferedSource = ChunkedSource(emptyList()).buffer() + } + + val wrapper = CapturedResponseBody(delegate, 16) { _, _ -> } + + assertEquals(type, wrapper.contentType()) + assertEquals(-1L, wrapper.contentLength()) + } + + @Test + fun `propagates read failures to the application`() { + val source = FailingSource(bytesBeforeFailure = 4) + val (wrapper, captured) = capture(source, 1024) + + assertFailsWith { wrapper.source().readByteArray() } + assertEquals( + "xxxx", + captured.single()?.decodeToString(), + "what arrived before the failure is reported, since nothing more can arrive", + ) + assertEquals(listOf(false), completeness, "a stream that broke is only a prefix of the body") + + wrapper.close() + assertEquals(1, captured.size, "closing afterwards does not report a second time") + assertTrue(source.isClosed, "the connection must still be releasable after a failure") + } + + @Test + fun `closes the delegate body`() { + val source = ChunkedSource(listOf("x".toByteArray())) + val (wrapper, _) = capture(source, 1024) + + assertFalse(source.isClosed) + wrapper.close() + assertTrue(source.isClosed, "the application must still be able to release the connection") + } +} diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventListenerTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventListenerTest.kt index e4772145879..7f9915f13b1 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventListenerTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventListenerTest.kt @@ -1,5 +1,6 @@ package io.sentry.okhttp +import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.IScopes import io.sentry.SentryOptions @@ -9,6 +10,7 @@ import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.mockServerRequestTimeoutMillis +import java.io.IOException import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals @@ -26,7 +28,6 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.SocketPolicy import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.spy @@ -281,7 +282,7 @@ class SentryOkHttpEventListenerTest { fun `call span status is not overridden if not null`() { val mockListener = mock() lateinit var call: Call - whenever(mockListener.connectStart(any(), anyOrNull(), anyOrNull())).then { + whenever(mockListener.requestHeadersStart(any())).then { val okHttpEvent = SentryOkHttpEventListener.eventMap[call] val callSpan = okHttpEvent?.callSpan assertNotNull(callSpan) @@ -300,6 +301,48 @@ class SentryOkHttpEventListenerTest { assertEquals(SpanStatus.UNKNOWN, callSpan.status) } + @Test + fun `a failed route does not fail the call when the next route connects`() { + val sut = fixture.getSut() + val listener = fixture.sentryOkHttpEventListener + val call = sut.newCall(getRequest()) + val response = mock() + whenever(response.code).thenReturn(200) + whenever(response.protocol).thenReturn(Protocol.HTTP_1_1) + + listener.callStart(call) + listener.connectStart(call, mock(), mock()) + listener.connectFailed(call, mock(), mock(), null, IOException("Connection refused")) + listener.connectStart(call, mock(), mock()) + listener.connectEnd(call, mock(), mock(), Protocol.HTTP_1_1) + listener.responseHeadersStart(call) + listener.responseHeadersEnd(call, response) + val callSpan = SentryOkHttpEventListener.eventMap[call]!!.callSpan!! + listener.callEnd(call) + + assertThat(callSpan.status).isEqualTo(SpanStatus.OK) + assertThat(callSpan.throwable).isNull() + assertThat(callSpan.getData("error_message")).isNull() + } + + @Test + fun `a call that fails to connect on every route is marked as failed`() { + val sut = fixture.getSut() + val listener = fixture.sentryOkHttpEventListener + val call = sut.newCall(getRequest()) + val error = IOException("Connection refused") + + listener.callStart(call) + listener.connectStart(call, mock(), mock()) + listener.connectFailed(call, mock(), mock(), null, error) + val callSpan = SentryOkHttpEventListener.eventMap[call]!!.callSpan!! + listener.callFailed(call, error) + + assertThat(callSpan.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + assertThat(callSpan.throwable).isSameInstanceAs(error) + assertThat(callSpan.getData("error_message")).isEqualTo("Connection refused") + } + private fun verifyDelegation( listener: SentryOkHttpEventListener, originalListener: EventListener, 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 d6c4c56c378..baa37a7716a 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -57,6 +57,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -90,10 +91,12 @@ class SentryOkHttpInterceptorTest { sendDefaultPii: Boolean = false, eventListener: EventListener? = null, additionalInterceptors: List = emptyList(), + isReplayRecording: Boolean = true, optionsConfiguration: Sentry.OptionsConfiguration? = null, ): OkHttpClient { options = SentryOptions().also { + it.setReplayController(mock { on { isRecording } doReturn isReplayRecording }) optionsConfiguration?.configure(it) it.dsn = "https://key@sentry.io/proj" if (includeMockServerInTracePropagationTargets) { @@ -381,6 +384,27 @@ class SentryOkHttpInterceptorTest { assertNotNull(networkDetails.response) } + @Test + fun `does not capture Session Replay network details when replay is not recording`() { + val sut = + fixture.getSut( + isReplayRecording = false, + optionsConfiguration = { it.sessionReplay.setNetworkDetailAllowUrls(listOf(".*")) }, + ) + + sut.newCall(postRequest()).execute().close() + + val hint = argumentCaptor() + verify(fixture.scopes).addBreadcrumb(any(), hint.capture()) + assertThat( + hint.firstValue.getAs( + TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS, + NetworkRequestData::class.java, + ) + ) + .isNull() + } + @SuppressWarnings("SwallowedException") @Test fun `adds breadcrumb when http calls results in exception`() { diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorUnknownContentLengthTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorUnknownContentLengthTest.kt new file mode 100644 index 00000000000..b6257bfc50b --- /dev/null +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorUnknownContentLengthTest.kt @@ -0,0 +1,560 @@ +package io.sentry.okhttp + +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.TypeCheckHint +import io.sentry.util.network.NetworkBody +import io.sentry.util.network.NetworkRequestData +import java.io.ByteArrayOutputStream +import java.io.Closeable +import java.io.IOException +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import okio.GzipSink +import okio.buffer +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * Response bodies of unknown length: server-sent events, long-poll, chunked endpoints that stay + * open. + * + * These used to hang. Capturing the body means peeking it, and OkHttp implements a peek as "keep + * reading until the peek size is reached or the stream ends". For these responses neither happens, + * so the thread that called `execute()` never received the response at all. + */ +class SentryOkHttpInterceptorUnknownContentLengthTest { + + private val scopes = mock() + private lateinit var options: SentryOptions + private lateinit var sut: OkHttpClient + + private fun setUpSut(captureBodies: Boolean = true) { + options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + setReplayController(mock { on { isRecording } doReturn true }) + sessionReplay.setNetworkDetailAllowUrls(listOf(".*")) + sessionReplay.setNetworkCaptureBodies(captureBodies) + } + whenever(scopes.options).thenReturn(options) + sut = OkHttpClient.Builder().addInterceptor(SentryOkHttpInterceptor(scopes)).build() + } + + private fun requestTo(url: String) = Request.Builder().url(url).build() + + /** The network details the breadcrumb points at; read after the body has been consumed. */ + private fun networkDetails(): NetworkRequestData { + val hint = argumentCaptor() + verify(scopes).addBreadcrumb(any(), hint.capture()) + return assertNotNull( + hint.firstValue.getAs( + TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS, + NetworkRequestData::class.java, + ) + ) + } + + private fun capturedBody(): String? = networkDetails().response?.body?.body as String? + + private fun capturedBodyValue(): Any? = networkDetails().response?.body?.body + + // --------------------------------------------------------------------------------------- + // the regression + // --------------------------------------------------------------------------------------- + + @Test + fun `returns the response even though the body never ends`() { + setUpSut() + EventStreamServer(listOf("data: event-0\n\n"), closeStream = false).use { stream -> + val executor = Executors.newSingleThreadExecutor { r -> Thread(r).apply { isDaemon = true } } + val call: Call = sut.newCall(requestTo(stream.url)) + try { + val response = executor.submit { call.execute() }.get(5, TimeUnit.SECONDS) + assertEquals(200, response.code) + + // The stream is still open and nothing has been consumed, so the capture cannot have + // happened yet. Everything that is already known must still reach the breadcrumb. + val details = networkDetails() + assertEquals(200, details.statusCode) + assertNotNull(details.response, "the response is recorded before the body is consumed") + assertNull(details.response?.body, "but its body is not known yet") + + response.close() + } finally { + executor.shutdownNow() + } + } + } + + @Test + fun `delivers each small event to the application as it arrives`() { + setUpSut() + val events = (0 until 3).map { "data: event-$it\n\n" } + EventStreamServer(events, closeStream = true).use { stream -> + sut.newCall(requestTo(stream.url)).execute().use { response -> + val source = assertNotNull(response.body).source() + for (event in events) { + assertEquals( + event, + source.readUtf8(event.length.toLong()), + "the application must see every event", + ) + } + assertTrue(source.exhausted(), "the stream ended cleanly") + } + } + } + + // --------------------------------------------------------------------------------------- + // what ends up captured + // --------------------------------------------------------------------------------------- + + @Test + fun `captures a body of unknown length once the stream ends`() { + setUpSut() + val events = listOf("data: event-0\n\n", "data: event-1\n\n") + EventStreamServer(events, closeStream = true).use { stream -> + sut.newCall(requestTo(stream.url)).execute().use { it.body?.string() } + val details = networkDetails() + assertEquals(200, details.statusCode) + assertEquals(events.joinToString(""), capturedBody()) + assertEquals(events.joinToString("").length.toLong(), details.responseBodySize) + } + } + + @Test + fun `reports a JSON body closed before it ended as truncated, not invalid`() { + setUpSut() + val firstChunk = """{"items":[1,2,""" + EventStreamServer( + listOf(firstChunk, "3]}"), + closeStream = false, + contentType = "application/json", + ) + .use { stream -> + sut.newCall(requestTo(stream.url)).execute().use { response -> + assertNotNull(response.body).source().readUtf8(firstChunk.length.toLong()) + } + + assertEquals( + listOf(NetworkBody.NetworkBodyWarning.JSON_TRUNCATED), + networkDetails().response?.body?.warnings, + ) + } + } + + @Test + fun `captures a body of unknown length when the application closes it before the stream ends`() { + setUpSut() + EventStreamServer(listOf("data: event-0\n\n", "data: event-1\n\n"), closeStream = false).use { + stream -> + sut.newCall(requestTo(stream.url)).execute().use { response -> + assertEquals("data: event-0\n\n", assertNotNull(response.body).source().readUtf8(15)) + } + + assertEquals("data: event-0\n\n", capturedBody()) + } + } + + @Test + fun `records the response of a body of unknown length the application never reads`() { + setUpSut() + EventStreamServer(listOf("data: event-0\n\n"), closeStream = true).use { stream -> + sut.newCall(requestTo(stream.url)).execute().close() + + val details = networkDetails() + assertEquals(200, details.statusCode) + assertNull( + capturedBody(), + "an unread stream is never peeked, so there is nothing to report; the response is still recorded", + ) + } + } + + @Test + fun `captures an error response body of unknown length`() { + setUpSut() + EventStreamServer(listOf("data: boom\n\n"), closeStream = true, statusCode = 500).use { stream + -> + sut.newCall(requestTo(stream.url)).execute().use { it.body?.string() } + + val details = networkDetails() + assertEquals(500, details.statusCode) + assertEquals("data: boom\n\n", capturedBody()) + } + } + + @Test + fun `caps the captured body without truncating the body the application reads`() { + setUpSut() + val events = (0 until 200).map { "data: " + "x".repeat(1000) + "\n\n" } + val whole = events.joinToString("") + EventStreamServer(events, closeStream = true).use { stream -> + val received = sut.newCall(requestTo(stream.url)).execute().use { it.body?.string() } + + assertEquals(whole.length, received?.length, "the application must receive the whole stream") + val captured = assertNotNull(capturedBody()) + assertEquals( + io.sentry.SentryReplayOptions.MAX_NETWORK_BODY_SIZE, + captured.length, + "the capture must stop at the cap", + ) + assertEquals(whole.substring(0, captured.length), captured) + assertEquals( + listOf(NetworkBody.NetworkBodyWarning.TEXT_TRUNCATED), + networkDetails().response?.body?.warnings, + "a capped capture must be reported as truncated, not as a complete body", + ) + } + } + + @Test + fun `does not capture bodies when network body capture is turned off`() { + setUpSut(captureBodies = false) + EventStreamServer(listOf("data: event-0\n\n"), closeStream = true).use { stream -> + sut.newCall(requestTo(stream.url)).execute().use { it.body?.string() } + + assertEquals(200, networkDetails().statusCode) + assertNull(capturedBody()) + } + } + + // --------------------------------------------------------------------------------------- + // how the body is actually consumed + // --------------------------------------------------------------------------------------- + + @Test + fun `captures a body consumed from the callback of an asynchronous call`() { + // enqueue() hands the response to a dispatcher thread, so the capture is completed by a thread + // other than the one that started the call. That is the publication NetworkRequestData guards. + setUpSut() + val events = (0 until 3).map { "data: event-$it\n\n" } + EventStreamServer(events, closeStream = true).use { stream -> + val body = AtomicReference() + val thread = AtomicReference() + val done = CountDownLatch(1) + + sut + .newCall(requestTo(stream.url)) + .enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) = done.countDown() + + override fun onResponse(call: Call, response: Response) { + response.use { body.set(it.body?.string()) } + thread.set(Thread.currentThread().name) + done.countDown() + } + } + ) + + assertTrue(done.await(10, TimeUnit.SECONDS), "the callback must be reached") + assertEquals(events.joinToString(""), body.get()) + assertNotEquals( + Thread.currentThread().name, + thread.get(), + "the body is consumed off the calling thread", + ) + assertEquals(events.joinToString(""), capturedBody()) + assertEquals(200, networkDetails().statusCode) + } + } + + @Test + fun `captures what arrived when a stream is cancelled while a reader is parked on it`() { + // How an SSE client consumes a stream: line by line on its own thread, until the call is + // cancelled. Nothing closes the body afterwards, so the broken read is what completes the + // capture, with the events that did arrive. + setUpSut() + val events = (0 until 3).map { "data: event-$it\n\n" } + EventStreamServer(events, closeStream = false).use { stream -> + val call = sut.newCall(requestTo(stream.url)) + val response = call.execute() + val readThree = CountDownLatch(3) + val failed = CountDownLatch(1) + val reader = Thread { + @Suppress("SwallowedException") // cancelling the call is how this read is meant to end + try { + val source = response.body!!.source() + while (true) { + val line = source.readUtf8Line() ?: break + if (line.startsWith("data:")) readThree.countDown() + } + } catch (e: IOException) { + failed.countDown() + } + } + reader.isDaemon = true + reader.start() + + assertTrue(readThree.await(10, TimeUnit.SECONDS), "the reader must receive the events") + call.cancel() + + assertTrue(failed.await(10, TimeUnit.SECONDS), "the parked read must end with an IOException") + assertEquals(200, networkDetails().statusCode) + assertEquals(events.joinToString(""), capturedBody()) + } + } + + @Test + fun `captures what arrived when the server drops the connection mid stream`() { + setUpSut() + val events = (0 until 3).map { "data: event-$it\n\n" } + EventStreamServer(events, closeStream = false, dropAfterEvents = true).use { stream -> + val response = sut.newCall(requestTo(stream.url)).execute() + + assertFailsWith { response.body?.string() } + + assertEquals(200, networkDetails().statusCode) + assertEquals(events.joinToString(""), capturedBody()) + } + } + + @Test + fun `captures a body of unknown length over HTTP2`() { + // HTTP/2 has no chunked encoding and ends a body with an empty DATA frame, so it reaches the + // wrapper by a different route than the HTTP/1.1 tests above. + setUpSut() + val payload = "data: over-h2\n\n" + MockWebServer().use { server -> + server.protocols = listOf(Protocol.H2_PRIOR_KNOWLEDGE) + server.enqueue( + MockResponse() + .setBody(payload) + .removeHeader("Content-Length") + .setHeader("Content-Type", "text/event-stream") + ) + sut = sut.newBuilder().protocols(listOf(Protocol.H2_PRIOR_KNOWLEDGE)).build() + + val response = sut.newCall(requestTo(server.url("/events").toString())).execute() + assertEquals(payload, response.use { it.body?.string() }) + + assertEquals(Protocol.H2_PRIOR_KNOWLEDGE, response.protocol) + assertEquals(-1L, response.body?.contentLength(), "no known length on the h2 body") + assertEquals(payload, capturedBody()) + assertEquals(200, networkDetails().statusCode) + } + } + + // --------------------------------------------------------------------------------------- + // responses with a known length + // --------------------------------------------------------------------------------------- + + @Test + fun `captures a gzipped body, which okhttp hands over without a known length`() { + // OkHttp asks for gzip itself and decompresses transparently, dropping Content-Length on the + // way, so an application interceptor sees -1 for an ordinary gzipped JSON response. + setUpSut() + val json = """{"hello":"world"}""" + val gzipped = Buffer() + GzipSink(gzipped).buffer().use { it.writeUtf8(json) } + MockWebServer().use { server -> + server.enqueue( + MockResponse() + .setBody(gzipped) + .setHeader("Content-Encoding", "gzip") + .setHeader("Content-Type", "application/json") + ) + + val response = sut.newCall(requestTo(server.url("/json").toString())).execute() + assertEquals(json, response.use { it.body?.string() }) + + assertEquals( + -1L, + response.body?.contentLength(), + "okhttp reports no length for a gzipped body", + ) + assertEquals(mapOf("hello" to "world"), capturedBodyValue()) + assertEquals(200, networkDetails().statusCode) + } + } + + @Test + fun `captures a body with a known length as the application reads it`() { + setUpSut() + MockWebServer().use { server -> + server.enqueue(MockResponse().setBody("response body").setResponseCode(200)) + + sut.newCall(requestTo(server.url("/hello").toString())).execute().use { it.body?.string() } + + assertEquals("response body", capturedBody()) + } + } + + @Test + fun `records an error response the application never reads, without its body`() { + // Nothing is read on the capture's own account, not even a body that would end on its own, so + // the interceptor costs a consumer no more than the bytes it asked for itself. + setUpSut() + MockWebServer().use { server -> + server.enqueue(MockResponse().setBody("failure").setResponseCode(500)) + + sut.newCall(requestTo(server.url("/hello").toString())).execute().close() + + assertEquals(500, networkDetails().statusCode) + assertNull(capturedBody()) + } + } + + @Test + fun `handles a zero length body`() { + setUpSut() + MockWebServer().use { server -> + server.enqueue(MockResponse().setResponseCode(204)) + + sut.newCall(requestTo(server.url("/hello").toString())).execute().use { response -> + assertEquals(204, response.code) + assertEquals(0, assertNotNull(response.body).contentLength()) + } + + assertEquals(204, networkDetails().statusCode) + assertNull(capturedBody()) + } + } + + @Test + fun `handles a zero length body of unknown length`() { + setUpSut() + EventStreamServer(emptyList(), closeStream = true).use { stream -> + sut.newCall(requestTo(stream.url)).execute().use { response -> + assertEquals("", response.body?.string()) + } + + assertEquals(200, networkDetails().statusCode) + assertNull(capturedBody()) + } + } + + @Test + fun `keeps the connection usable after a response of unknown length is closed`() { + setUpSut() + EventStreamServer(listOf("data: event-0\n\n"), closeStream = true).use { stream -> + // a second call on the same client proves the connection was released properly + repeat(3) { + sut.newCall(requestTo(stream.url)).execute().use { assertEquals(200, it.code) } + } + } + } + + /** + * Minimal HTTP/1.1 origin that streams one chunk per event and can leave the stream open, so the + * response body never ends. + */ + private class EventStreamServer( + private val events: List, + private val closeStream: Boolean, + private val statusCode: Int = 200, + private val dropAfterEvents: Boolean = false, + private val contentType: String = "text/event-stream", + ) : Closeable { + private val server = ServerSocket(0, 8, InetAddress.getLoopbackAddress()) + private val connections = CopyOnWriteArrayList() + + init { + Thread({ acceptLoop() }, "event-stream-server").apply { isDaemon = true }.start() + } + + val url: String + get() = "http://127.0.0.1:${server.localPort}/events" + + @Suppress("SwallowedException") // the server socket is closed when the test finishes + private fun acceptLoop() { + while (!server.isClosed) { + val socket = + try { + server.accept() + } catch (e: IOException) { + return + } + connections.add(socket) + Thread({ serve(socket) }, "event-stream-connection").apply { isDaemon = true }.start() + } + } + + @Suppress("SwallowedException") // a client that hangs up mid-stream is normal here + private fun serve(socket: Socket) { + try { + socket.use { + readRequestHead(it.getInputStream()) + val output = it.getOutputStream() + val headers = + "HTTP/1.1 $statusCode OK\r\n" + + "Content-Type: $contentType\r\n" + + "Cache-Control: no-cache\r\n" + + "Transfer-Encoding: chunked\r\n\r\n" + output.write(headers.toByteArray()) + output.flush() + for (event in events) { + val bytes = event.toByteArray() + output.write("${bytes.size.toString(16)}\r\n".toByteArray()) + output.write(bytes) + output.write("\r\n".toByteArray()) + output.flush() + } + if (dropAfterEvents) { + // hang up without the terminating chunk, the way a mobile connection dies + return + } + if (closeStream) { + output.write("0\r\n\r\n".toByteArray()) + output.flush() + } else { + // hold the connection open: the body stays open ended + Thread.sleep(30_000) + } + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } catch (e: IOException) { + // the client went away + } + } + + private fun readRequestHead(input: java.io.InputStream) { + val head = ByteArrayOutputStream() + while (true) { + val byte = input.read() + if (byte == -1) { + return + } + head.write(byte) + if (head.size() >= 4 && head.toString(Charsets.ISO_8859_1).endsWith("\r\n\r\n")) { + return + } + } + } + + override fun close() { + runCatching { server.close() } + connections.forEach { runCatching { it.close() } } + } + } +} diff --git a/sentry-openfeature/build.gradle.kts b/sentry-openfeature/build.gradle.kts index 8220d7e6a67..dfadca655bc 100644 --- a/sentry-openfeature/build.gradle.kts +++ b/sentry-openfeature/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-openfeign/build.gradle.kts b/sentry-openfeign/build.gradle.kts index 2afb9d287a9..c583cb11180 100644 --- a/sentry-openfeign/build.gradle.kts +++ b/sentry-openfeign/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts index 03c009b969d..b9ee2400d49 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts @@ -7,6 +7,9 @@ plugins { id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.shadow) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts index ca58cdaec28..8f50b096146 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts @@ -7,6 +7,9 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/SentryAutoConfigurationCustomizerProvider.java b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/SentryAutoConfigurationCustomizerProvider.java index 5f29f0289a2..e11ae4c72b3 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/SentryAutoConfigurationCustomizerProvider.java +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/SentryAutoConfigurationCustomizerProvider.java @@ -5,11 +5,13 @@ import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder; import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessorBuilder; import io.sentry.InitPriority; import io.sentry.Sentry; import io.sentry.SentryOptions; import io.sentry.internal.ManifestVersionReader; import io.sentry.protocol.SdkVersion; +import java.time.Duration; import java.util.HashMap; import java.util.Map; import java.util.logging.Level; @@ -106,7 +108,16 @@ private SdkTracerProviderBuilder configureSdkTracerProvider( return tracerProvider .setSampler(new SentrySampler()) .addSpanProcessor(new OtelSentrySpanProcessor()) - .addSpanProcessor(BatchSpanProcessor.builder(new SentrySpanExporter()).build()); + .addSpanProcessor(createBatchSpanProcessor(config)); + } + + private BatchSpanProcessor createBatchSpanProcessor(ConfigProperties config) { + final BatchSpanProcessorBuilder builder = BatchSpanProcessor.builder(new SentrySpanExporter()); + final @Nullable Duration scheduleDelay = config.getDuration("otel.bsp.schedule.delay"); + if (scheduleDelay != null) { + builder.setScheduleDelay(scheduleDelay); + } + return builder.build(); } private Map getDefaultProperties() { diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts index c02ca0ca468..f1cde540aa6 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless-spring/build.gradle.kts @@ -2,6 +2,9 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") } dependencies { diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts index c3a29cb03e6..aea663f7a0a 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts @@ -5,6 +5,9 @@ plugins { id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-opentelemetry/sentry-opentelemetry-bom/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bom/build.gradle.kts index d945002b716..2876823b067 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bom/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bom/build.gradle.kts @@ -1,6 +1,9 @@ plugins { `java-platform` `maven-publish` + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") } javaPlatform.allowDependencies() diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts index a176e76d4ef..b6b2349e8f7 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -7,6 +7,9 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts index b49df2df044..79fff3a8937 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -7,6 +7,9 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts index 1ff16cd0a31..0258dec66e5 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp-spring/build.gradle.kts @@ -1,6 +1,9 @@ plugins { `java-library` id("io.sentry.javadoc") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") } dependencies { diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index a2be906c743..1f8349d3149 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -7,6 +7,9 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-quartz/api/sentry-quartz.api b/sentry-quartz/api/sentry-quartz.api index bb8b142a912..95179351c17 100644 --- a/sentry-quartz/api/sentry-quartz.api +++ b/sentry-quartz/api/sentry-quartz.api @@ -7,6 +7,7 @@ public final class io/sentry/quartz/SentryJobListener : org/quartz/JobListener { public static final field SENTRY_CHECK_IN_ID_KEY Ljava/lang/String; public static final field SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY Ljava/lang/String; public static final field SENTRY_SLUG_KEY Ljava/lang/String; + public static final field SENTRY_UPSERT_MONITOR_CONFIG_KEY Ljava/lang/String; public fun ()V public fun (Lio/sentry/IScopes;)V public fun getName ()Ljava/lang/String; diff --git a/sentry-quartz/build.gradle.kts b/sentry-quartz/build.gradle.kts index 3a3fbaec2e9..a970986ffcd 100644 --- a/sentry-quartz/build.gradle.kts +++ b/sentry-quartz/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } @@ -34,6 +37,8 @@ dependencies { testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) + testImplementation(libs.google.truth) + testImplementation(libs.quartz) } tasks.withType().configureEach { diff --git a/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java b/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java index 408ae9a8c85..5d48cbbaf87 100644 --- a/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java +++ b/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java @@ -5,19 +5,32 @@ import io.sentry.CheckInStatus; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; +import io.sentry.MonitorConfig; import io.sentry.ScopesAdapter; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryLevel; import io.sentry.protocol.SentryId; import io.sentry.util.LifecycleHelper; +import io.sentry.util.MonitorConfigUtils; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import java.util.regex.Pattern; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.quartz.CronTrigger; import org.quartz.JobDataMap; +import org.quartz.JobDetail; import org.quartz.JobExecutionContext; import org.quartz.JobExecutionException; import org.quartz.JobListener; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.SimpleTrigger; +import org.quartz.Trigger; public final class SentryJobListener implements JobListener { @@ -30,6 +43,19 @@ public final class SentryJobListener implements JobListener { public static final String SENTRY_SLUG_KEY = "sentry-slug"; public static final String SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY = "sentry-scope-lifecycle"; + /** + * Job or trigger data key. Set it to {@code "true"} to send the trigger's schedule with + * check-ins, so Sentry creates or updates the monitor. + */ + public static final String SENTRY_UPSERT_MONITOR_CONFIG_KEY = "sentry-upsert-monitor-config"; + + private static final @NotNull List DAY_NAMES = + Arrays.asList("SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"); + + // Quartz ignores a step after a name, so SUN/2 is every Sunday + private static final @NotNull Pattern NAME_STEP = + Pattern.compile("(^|,)[A-Za-z]{3}(-[A-Za-z]{3})?/"); + private final @NotNull IScopes scopes; public SentryJobListener() { @@ -53,15 +79,18 @@ public void jobToBeExecuted(final @NotNull JobExecutionContext context) { if (maybeSlug == null) { return; } + final @Nullable MonitorConfig monitorConfig = + shouldUpsertMonitorConfig(context) ? monitorConfigFromTrigger(context) : null; final @NotNull ISentryLifecycleToken lifecycleToken = scopes.forkedScopes("SentryJobListener").makeCurrent(); + context.put(SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY, lifecycleToken); TracingUtils.startNewTrace(scopes); final @NotNull String slug = maybeSlug; final @NotNull CheckIn checkIn = new CheckIn(slug, CheckInStatus.IN_PROGRESS); + checkIn.setMonitorConfig(monitorConfig); final @NotNull SentryId checkInId = scopes.captureCheckIn(checkIn); context.put(SENTRY_CHECK_IN_ID_KEY, checkInId); context.put(SENTRY_SLUG_KEY, slug); - context.put(SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY, lifecycleToken); } catch (Throwable t) { scopes .getOptions() @@ -82,6 +111,154 @@ public void jobToBeExecuted(final @NotNull JobExecutionContext context) { return null; } + private boolean shouldUpsertMonitorConfig(final @NotNull JobExecutionContext context) { + final @Nullable JobDataMap jobDataMap = context.getMergedJobDataMap(); + if (jobDataMap == null) { + return false; + } + final @Nullable Object o = jobDataMap.get(SENTRY_UPSERT_MONITOR_CONFIG_KEY); + return o != null && "true".equalsIgnoreCase(o.toString().trim()); + } + + private @Nullable MonitorConfig monitorConfigFromTrigger( + final @NotNull JobExecutionContext context) { + try { + final @Nullable Trigger trigger = context.getTrigger(); + // Sentry can't express calendar exclusions + if (trigger == null || trigger.getCalendarName() != null) { + return null; + } + // each trigger would overwrite the others' schedule + if (hasSeveralTriggers(context)) { + return null; + } + if (trigger instanceof CronTrigger) { + final @NotNull CronTrigger cronTrigger = (CronTrigger) trigger; + final @Nullable String cron = toSixFieldCron(cronTrigger.getCronExpression()); + if (cron == null) { + return null; + } + final @Nullable TimeZone timeZone = cronTrigger.getTimeZone(); + return MonitorConfigUtils.fromSchedule( + cron, timeZone == null ? null : timeZone.getID(), null); + } + if (trigger instanceof SimpleTrigger) { + final @NotNull SimpleTrigger simpleTrigger = (SimpleTrigger) trigger; + if (simpleTrigger.getRepeatCount() != SimpleTrigger.REPEAT_INDEFINITELY) { + return null; + } + return MonitorConfigUtils.fromSchedule(null, null, simpleTrigger.getRepeatInterval()); + } + return null; + } catch (RuntimeException | SchedulerException | NoSuchMethodError | AbstractMethodError e) { + // Trigger implementations may come from other Quartz versions or third parties + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, "Could not derive a monitor config from the Quartz trigger.", e); + return null; + } + } + + private static boolean hasSeveralTriggers(final @NotNull JobExecutionContext context) + throws SchedulerException { + final @Nullable Scheduler scheduler = context.getScheduler(); + final @Nullable JobDetail jobDetail = context.getJobDetail(); + if (scheduler == null || jobDetail == null) { + return false; + } + return scheduler.getTriggersOfJob(jobDetail.getKey()).size() > 1; + } + + /** + * Quartz numbers days of week 1-7 from Sunday; crontab uses 0-6. Null if a year is set or a name + * has a step. + */ + static @Nullable String toSixFieldCron(final @Nullable String quartzCron) { + if (quartzCron == null) { + return null; + } + final @NotNull String[] fields = quartzCron.trim().split("\\s+", -1); + if (fields.length == 7 && !"*".equals(fields[6])) { + return null; + } + if (fields.length != 6 && fields.length != 7) { + return null; + } + if (NAME_STEP.matcher(fields[4]).find() || NAME_STEP.matcher(fields[5]).find()) { + return null; + } + final @Nullable String dayOfWeek = toZeroBasedDayOfWeek(fields[5]); + if (dayOfWeek == null) { + return null; + } + final @NotNull StringBuilder cron = new StringBuilder(); + for (int i = 0; i < 5; i++) { + cron.append(fields[i]).append(' '); + } + return cron.append(dayOfWeek).toString(); + } + + private static @Nullable String toZeroBasedDayOfWeek(final @NotNull String field) { + final @NotNull StringBuilder result = new StringBuilder(); + for (final @NotNull String item : field.split(",", -1)) { + final @Nullable String converted = toZeroBasedDayOfWeekItem(item.toUpperCase(Locale.ROOT)); + if (converted == null) { + return null; + } + if (result.length() > 0) { + result.append(','); + } + result.append(converted); + } + return result.toString(); + } + + private static @Nullable String toZeroBasedDayOfWeekItem(final @NotNull String item) { + if (item.startsWith("*") || item.startsWith("?")) { + // '*' starts on Sunday in both + return item; + } + final int hash = item.indexOf('#'); + if (hash >= 0) { + final @Nullable Integer day = toZeroBasedDay(item.substring(0, hash)); + return day == null ? null : day + item.substring(hash); + } + if (item.length() > 1 && item.endsWith("L")) { + final @Nullable Integer day = toZeroBasedDay(item.substring(0, item.length() - 1)); + return day == null ? null : day + "L"; + } + final int slash = item.indexOf('/'); + final @NotNull String days = slash >= 0 ? item.substring(0, slash) : item; + final @NotNull String step = slash >= 0 ? item.substring(slash) : ""; + final @NotNull String[] range = days.split("-", -1); + if (range.length > 2) { + return null; + } + final @Nullable Integer start = toZeroBasedDay(range[0]); + if (start == null) { + return null; + } + if (range.length == 2) { + final @Nullable Integer end = toZeroBasedDay(range[1]); + return end == null ? null : start + "-" + end + step; + } + if (slash < 0 || start == 6) { + return String.valueOf(start); + } + // a step from a single day ends on Saturday in Quartz, but on Sunday (7) in crontab + return start + "-6" + step; + } + + private static @Nullable Integer toZeroBasedDay(final @NotNull String day) { + if (day.matches("[1-7]")) { + return Integer.parseInt(day) - 1; + } + final int index = DAY_NAMES.indexOf(day); + return index >= 0 ? index : null; + } + @Override public void jobExecutionVetoed(JobExecutionContext context) { // do nothing diff --git a/sentry-quartz/src/test/kotlin/io/sentry/quartz/SentryJobListenerTest.kt b/sentry-quartz/src/test/kotlin/io/sentry/quartz/SentryJobListenerTest.kt new file mode 100644 index 00000000000..7f6ddd68bab --- /dev/null +++ b/sentry-quartz/src/test/kotlin/io/sentry/quartz/SentryJobListenerTest.kt @@ -0,0 +1,332 @@ +package io.sentry.quartz + +import com.google.common.truth.Truth.assertThat +import io.sentry.CheckIn +import io.sentry.CheckInStatus +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.MonitorConfig +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +import java.util.TimeZone +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.quartz.CalendarIntervalScheduleBuilder +import org.quartz.CronScheduleBuilder +import org.quartz.JobDataMap +import org.quartz.JobDetail +import org.quartz.JobExecutionContext +import org.quartz.JobKey +import org.quartz.Scheduler +import org.quartz.SimpleScheduleBuilder +import org.quartz.Trigger +import org.quartz.TriggerBuilder + +class SentryJobListenerTest { + + private val scopes = mock() + private val lifecycleToken = mock() + + @BeforeTest + fun setup() { + val forkedScopes = mock() + whenever(scopes.forkedScopes(any())).thenReturn(forkedScopes) + whenever(forkedScopes.makeCurrent()).thenReturn(lifecycleToken) + whenever(scopes.options).thenReturn(SentryOptions()) + } + + @Test + fun `cron trigger is sent as crontab monitor config with timezone`() { + val config = + inProgressMonitorConfig( + cronTrigger("0 15 10 ? * MON-FRI", TimeZone.getTimeZone("America/New_York")) + ) + + assertThat(config).isNotNull() + assertThat(config!!.schedule.type).isEqualTo("crontab") + assertThat(config.schedule.value).isEqualTo("15 10 * * 1-5") + assertThat(config.timezone).isEqualTo("America/New_York") + } + + @Test + fun `cron trigger with wildcard year is sent as crontab`() { + val config = inProgressMonitorConfig(cronTrigger("0 0 2 * * ? *")) + + assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") + } + + @Test + fun `cron trigger with specific year sends no monitor config`() { + assertThat(inProgressMonitorConfig(cronTrigger("0 0 2 * * ? 2030"))).isNull() + } + + @Test + fun `cron trigger with variable seconds sends no monitor config`() { + assertThat(inProgressMonitorConfig(cronTrigger("0/30 * * * * ?"))).isNull() + } + + @Test + fun `cron trigger days of week are converted to start at zero`() { + val config = inProgressMonitorConfig(cronTrigger("0 0 9 ? * 2-6")) + + assertThat(config?.schedule?.value).isEqualTo("0 9 * * 1-5") + } + + @Test + fun `cron trigger with syntax Sentry rejects sends no monitor config`() { + for (cron in + listOf("0 0 12 15W * ?", "0 0 12 L-3 * ?", "0 0 12 ? * FRI-MON", "0 0 22-2 * * ?")) { + assertThat(inProgressMonitorConfig(cronTrigger(cron))).isNull() + } + } + + @Test + fun `cron trigger with whole hour offset zone is sent as Etc zone`() { + val config = inProgressMonitorConfig(cronTrigger("0 0 2 * * ?", TimeZone.getTimeZone("GMT+2"))) + + assertThat(config?.timezone).isEqualTo("Etc/GMT-2") + } + + @Test + fun `cron trigger with zone Sentry rejects sends no monitor config`() { + val trigger = cronTrigger("0 0 2 * * ?", TimeZone.getTimeZone("GMT+05:30")) + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `simple trigger with whole minute interval is sent as interval monitor config`() { + val trigger = + TriggerBuilder.newTrigger() + .withSchedule(SimpleScheduleBuilder.repeatMinutelyForever(5)) + .build() + + val config = inProgressMonitorConfig(trigger) + + assertThat(config).isNotNull() + assertThat(config!!.schedule.type).isEqualTo("interval") + assertThat(config.schedule.value).isEqualTo("5") + assertThat(config.schedule.unit).isEqualTo("minute") + } + + @Test + fun `simple trigger with sub minute interval sends no monitor config`() { + val trigger = + TriggerBuilder.newTrigger() + .withSchedule(SimpleScheduleBuilder.repeatSecondlyForever(90)) + .build() + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `simple trigger without repeat sends no monitor config`() { + val trigger = + TriggerBuilder.newTrigger().withSchedule(SimpleScheduleBuilder.simpleSchedule()).build() + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `simple trigger with a finite repeat count sends no monitor config`() { + val trigger = + TriggerBuilder.newTrigger() + .withSchedule(SimpleScheduleBuilder.repeatMinutelyForTotalCount(5)) + .build() + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `scope token is stored even if capturing the check-in fails`() { + val jobDataMap = JobDataMap() + jobDataMap[SentryJobListener.SENTRY_SLUG_KEY] = "my-job" + val context = mock() + whenever(context.mergedJobDataMap).thenReturn(jobDataMap) + whenever(context.trigger).thenReturn(cronTrigger("0 0 2 * * ?")) + whenever(scopes.captureCheckIn(any())).thenThrow(IllegalStateException("boom")) + + SentryJobListener(scopes).jobToBeExecuted(context) + + verify(context).put(SentryJobListener.SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY, lifecycleToken) + } + + @Test + fun `other triggers send no monitor config`() { + val trigger = + TriggerBuilder.newTrigger() + .withSchedule( + CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withIntervalInDays(1) + ) + .build() + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `trigger with a calendar sends no monitor config`() { + val trigger = + TriggerBuilder.newTrigger() + .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?")) + .modifiedByCalendar("holidays") + .build() + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `sends no monitor config by default`() { + assertThat(inProgressMonitorConfig(cronTrigger("0 0 2 * * ?"), JobDataMap())).isNull() + } + + @Test + fun `upsert monitor config as boolean true sends monitor config`() { + val jobDataMap = JobDataMap() + jobDataMap[SentryJobListener.SENTRY_UPSERT_MONITOR_CONFIG_KEY] = true + + assertThat(inProgressMonitorConfig(cronTrigger("0 0 2 * * ?"), jobDataMap)).isNotNull() + } + + @Test + fun `upsert monitor config true sends monitor config`() { + val jobDataMap = JobDataMap() + jobDataMap[SentryJobListener.SENTRY_UPSERT_MONITOR_CONFIG_KEY] = "true" + + assertThat(inProgressMonitorConfig(cronTrigger("0 0 2 * * ?"), jobDataMap)).isNotNull() + } + + @Test + fun `upsert monitor config false sends no monitor config`() { + val jobDataMap = JobDataMap() + jobDataMap[SentryJobListener.SENTRY_UPSERT_MONITOR_CONFIG_KEY] = "false" + + assertThat(inProgressMonitorConfig(cronTrigger("0 0 2 * * ?"), jobDataMap)).isNull() + } + + @Test + fun `upsert monitor config as boolean false sends no monitor config`() { + val jobDataMap = JobDataMap() + jobDataMap[SentryJobListener.SENTRY_UPSERT_MONITOR_CONFIG_KEY] = false + + assertThat(inProgressMonitorConfig(cronTrigger("0 0 2 * * ?"), jobDataMap)).isNull() + } + + @Test + fun `job with one trigger sends monitor config`() { + val trigger = cronTrigger("0 0 2 * * ?") + + assertThat(inProgressMonitorConfig(trigger, triggersOfJob = listOf(trigger))).isNotNull() + } + + @Test + fun `job with several triggers sends no monitor config`() { + val trigger = cronTrigger("0 0 2 * * ?") + val triggers = listOf(trigger, cronTrigger("0 0 3 * * ?")) + + assertThat(inProgressMonitorConfig(trigger, triggersOfJob = triggers)).isNull() + } + + @Test + fun `failing trigger still sends the check-in without monitor config`() { + val trigger = mock() + whenever(trigger.cronExpression).thenThrow(IllegalStateException("boom")) + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `trigger throwing an error still sends the check-in without monitor config`() { + val trigger = mock() + whenever(trigger.cronExpression).thenThrow(NoSuchMethodError("boom")) + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `quartz cron is converted to six fields`() { + val expected = + mapOf( + "0 0 12 * * ?" to "0 0 12 * * ?", + "0 0 12 * * ? *" to "0 0 12 * * ?", + "0 0 12 ? * 1,7" to "0 0 12 ? * 0,6", + "0 0 12 ? * SUN,sat" to "0 0 12 ? * 0,6", + "0 0 12 ? * MON-FRI" to "0 0 12 ? * 1-5", + "0 0 12 ? * 6#3" to "0 0 12 ? * 5#3", + "0 0 12 ? * FRI#3" to "0 0 12 ? * 5#3", + "0 0 12 ? * 6L" to "0 0 12 ? * 5L", + "0 0 12 ? * FRIL" to "0 0 12 ? * 5L", + "0 0 12 ? * 1/2" to "0 0 12 ? * 0-6/2", + "0 0 12 ? * 2/2" to "0 0 12 ? * 1-6/2", + "0 0 12 ? * 7/2" to "0 0 12 ? * 6", + "0 0 12 ? * 2-6/2" to "0 0 12 ? * 1-5/2", + "0 0 12 ? * */2" to "0 0 12 ? * */2", + "0 0 12 L * ?" to "0 0 12 L * ?", + ) + for ((quartz, cron) in expected) { + assertThat(SentryJobListener.toSixFieldCron(quartz)).isEqualTo(cron) + } + } + + @Test + fun `quartz cron that cannot be converted returns null`() { + for (quartz in + listOf( + "0 0 12 * * ? 2030", + "0 0 12 ? * L", + "0 0 12 ? * 8", + "0 0 12 ? * XYZL", + "0 0 12 ? * SUN/2", + "0 0 12 ? * mon-wed/3", + "0 0 12 ? * 1,MON/2", + "0 0 12 * JUN/3 ?", + "0 12 * * *", + )) { + assertThat(SentryJobListener.toSixFieldCron(quartz)).isNull() + } + } + + private fun cronTrigger(cron: String, timeZone: TimeZone = TimeZone.getTimeZone("UTC")): Trigger = + TriggerBuilder.newTrigger() + .withSchedule(CronScheduleBuilder.cronSchedule(cron).inTimeZone(timeZone)) + .build() + + private fun upsertJobDataMap(): JobDataMap { + val jobDataMap = JobDataMap() + jobDataMap[SentryJobListener.SENTRY_UPSERT_MONITOR_CONFIG_KEY] = "true" + return jobDataMap + } + + private fun inProgressMonitorConfig( + trigger: Trigger, + jobDataMap: JobDataMap = upsertJobDataMap(), + triggersOfJob: List? = null, + ): MonitorConfig? { + jobDataMap[SentryJobListener.SENTRY_SLUG_KEY] = "my-job" + val context = mock() + whenever(context.mergedJobDataMap).thenReturn(jobDataMap) + whenever(context.trigger).thenReturn(trigger) + if (triggersOfJob != null) { + val jobKey = JobKey.jobKey("my-job") + val jobDetail = mock() + whenever(jobDetail.key).thenReturn(jobKey) + val scheduler = mock() + whenever(scheduler.getTriggersOfJob(jobKey)).thenReturn(triggersOfJob) + whenever(context.jobDetail).thenReturn(jobDetail) + whenever(context.scheduler).thenReturn(scheduler) + } + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + + SentryJobListener(scopes).jobToBeExecuted(context) + + assertThat(checkInCaptor.allValues).hasSize(1) + assertThat(checkInCaptor.firstValue.monitorSlug).isEqualTo("my-job") + assertThat(checkInCaptor.firstValue.status).isEqualTo(CheckInStatus.IN_PROGRESS.apiName()) + return checkInCaptor.firstValue.monitorConfig + } +} diff --git a/sentry-reactor/build.gradle.kts b/sentry-reactor/build.gradle.kts index 403f1c4e6f2..449149d320f 100644 --- a/sentry-reactor/build.gradle.kts +++ b/sentry-reactor/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-samples/build.gradle.kts b/sentry-samples/build.gradle.kts new file mode 100644 index 00000000000..3a344b8e8d2 --- /dev/null +++ b/sentry-samples/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.spring) apply false + alias(libs.plugins.spring.dependency.management) apply false + alias(libs.plugins.gradle.versions) apply false + id("io.sentry.spotless") apply false + id("io.sentry.jdk-target") apply false +} diff --git a/sentry-samples/gradle.properties b/sentry-samples/gradle.properties new file mode 120000 index 00000000000..7677fb73be8 --- /dev/null +++ b/sentry-samples/gradle.properties @@ -0,0 +1 @@ +../gradle.properties \ No newline at end of file diff --git a/sentry-samples/gradle/gradle-daemon-jvm.properties b/sentry-samples/gradle/gradle-daemon-jvm.properties new file mode 120000 index 00000000000..284e270637d --- /dev/null +++ b/sentry-samples/gradle/gradle-daemon-jvm.properties @@ -0,0 +1 @@ +../../gradle/gradle-daemon-jvm.properties \ No newline at end of file diff --git a/sentry-samples/gradle/wrapper b/sentry-samples/gradle/wrapper new file mode 120000 index 00000000000..3232fe4e272 --- /dev/null +++ b/sentry-samples/gradle/wrapper @@ -0,0 +1 @@ +../../gradle/wrapper \ No newline at end of file diff --git a/sentry-samples/gradlew b/sentry-samples/gradlew new file mode 120000 index 00000000000..502f5a2d3ec --- /dev/null +++ b/sentry-samples/gradlew @@ -0,0 +1 @@ +../gradlew \ No newline at end of file diff --git a/sentry-samples/sentry-samples-android/README.md b/sentry-samples/sentry-samples-android/README.md index b065b91e955..2c2b328e43e 100644 --- a/sentry-samples/sentry-samples-android/README.md +++ b/sentry-samples/sentry-samples-android/README.md @@ -8,15 +8,19 @@ profiling) and integrations (Compose, OkHttp, SQLite, etc.). Install the app on your device or emulator: ``` -./gradlew :sentry-samples:sentry-samples-android:installDebug +./gradlew -p sentry-samples/sentry-samples-android installDebug ``` -or simply open the project in Android Studio and run the `sentry-samples-android` configuration. +or open the `sentry-samples/sentry-samples-android` directory in Android Studio and run the `sentry-samples-android` configuration. + +The sample is a separate Gradle build that includes the SDK build, so changes to the SDK show up in the next build of the sample, with nothing to publish first. +The SDK build doesn't include the sample, so `./gradlew` from the repository root doesn't build, lint or format it. +`gradle.properties`, `gradlew`, `gradle/wrapper` and `gradle/gradle-daemon-jvm.properties` are symlinks to the SDK build's, so both builds use the same Gradle, JDK and settings. You can also apply the [Sentry Android Gradle Plugin](https://github.com/getsentry/sentry-android-gradle-plugin) (SAGP) when building (not applied by default): ``` -./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp +./gradlew -p sentry-samples/sentry-samples-android installDebug -PuseSagp ``` In Android Studio, add `useSagp=` (empty value) to `gradle.properties`, or pass `-PuseSagp` as a Gradle project property. @@ -55,7 +59,7 @@ Re-run `publishToMavenLocal` after each SAGP change. Then build from sentry-java: ``` -./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp +./gradlew -p sentry-samples/sentry-samples-android installDebug -PuseSagp ``` ## Viewing SDK output diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index f832020037d..cf63b11ff68 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -5,9 +5,21 @@ import io.sentry.android.gradle.extensions.InstrumentationFeature import io.sentry.android.gradle.extensions.SentryPluginExtension import org.apache.tools.ant.taskdefs.condition.Os import org.gradle.internal.extensions.stdlib.capitalized +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion + +buildscript { + repositories { google() } + dependencies { + // Replaces the R8 bundled with AGP when the AGP matrix job overrides AGP. See r8 in + // libs.versions.toml. + if (System.getenv("VERSION_AGP") != null) classpath(libs.android.r8) + } +} plugins { - id("com.android.application") + alias(libs.plugins.android.application) + id("io.sentry.spotless") + id("io.sentry.jdk-target") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) @@ -15,6 +27,8 @@ plugins { alias(libs.plugins.sqldelight) } +val sentryVersion = libs.versions.sentry.get() + if (providers.gradleProperty("useSagp").isPresent) { apply(plugin = "io.sentry.android.gradle") } @@ -51,7 +65,7 @@ android { minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 2 - versionName = project.version.toString() + versionName = sentryVersion externalNativeBuild { cmake { @@ -63,7 +77,7 @@ android { } } - ndk { abiFilters.addAll(Config.Android.abiFilters) } + ndk { abiFilters.addAll(listOf("x86", "armeabi-v7a", "x86_64", "arm64-v8a")) } } lint { @@ -102,7 +116,7 @@ android { signingConfigs { getByName("debug") { - storeFile = rootProject.file("debug.keystore") + storeFile = file("../../debug.keystore") storePassword = "android" keyAlias = "androiddebugkey" keyPassword = "android" @@ -144,7 +158,7 @@ android { val toggleNativeLoggingTask = project.tasks.register(taskName) { mergedManifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)) - rootDir.set(project.rootDir.absolutePath) + repoDir.set(layout.projectDirectory.dir("../..").asFile.canonicalPath) } project.afterEvaluate { (variant as? VariantImpl<*>)?.taskContainer?.assembleTask?.configure { @@ -171,24 +185,22 @@ sqldelight { } dependencies { - implementation( - kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION) - ) - - implementation(projects.sentryAndroid) - implementation(projects.sentryAndroidFragment) - implementation(projects.sentryAndroidNdk) - implementation(projects.sentryAndroidNavigation) - implementation(projects.sentryAndroidNavigation3) - implementation(projects.sentryAndroidSqlite) - implementation(projects.sentryAndroidTimber) - implementation(projects.sentryCompose) - implementation(projects.sentryKotlinExtensions) - implementation(projects.sentryOkhttp) - implementation(projects.sentrySpotlight) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + + implementation("io.sentry:sentry-android:$sentryVersion") + implementation("io.sentry:sentry-android-fragment:$sentryVersion") + implementation("io.sentry:sentry-android-ndk:$sentryVersion") + implementation("io.sentry:sentry-android-navigation:$sentryVersion") + implementation("io.sentry:sentry-android-navigation3:$sentryVersion") + implementation("io.sentry:sentry-android-sqlite:$sentryVersion") + implementation("io.sentry:sentry-android-timber:$sentryVersion") + implementation("io.sentry:sentry-compose:$sentryVersion") + implementation("io.sentry:sentry-kotlin-extensions:$sentryVersion") + implementation("io.sentry:sentry-okhttp:$sentryVersion") + implementation("io.sentry:sentry-spotlight:$sentryVersion") // how to exclude androidx if release health feature is disabled - // implementation(projects.sentryAndroid) { + // implementation("io.sentry:sentry-android:$sentryVersion") { // exclude(group = "androidx.lifecycle", module = "lifecycle-process") // exclude(group = "androidx.lifecycle", module = "lifecycle-common-java8") // exclude(group = "androidx.core", module = "core") @@ -229,7 +241,7 @@ dependencies { ksp(libs.androidx.room.compiler) ksp(libs.androidx.room3.compiler) - debugImplementation(projects.sentryAndroidDistribution) + debugImplementation("io.sentry:sentry-android-distribution:$sentryVersion") debugImplementation(libs.leakcanary) testImplementation(libs.google.truth) @@ -237,7 +249,7 @@ dependencies { } abstract class ToggleNativeLoggingTask : Exec() { - @get:Input abstract val rootDir: Property + @get:Input abstract val repoDir: Property @get:InputFile abstract val mergedManifest: RegularFileProperty @@ -255,7 +267,7 @@ abstract class ToggleNativeLoggingTask : Exec() { args.add(0, "cmd") args.add(1, "/c") } - args.add("${rootDir.get()}/scripts/toggle-codec-logs.sh") + args.add("${repoDir.get()}/scripts/toggle-codec-logs.sh") args.add(if (value) "enable" else "disable") commandLine(args) super.exec() diff --git a/sentry-samples/sentry-samples-android/gradle.properties b/sentry-samples/sentry-samples-android/gradle.properties new file mode 120000 index 00000000000..03ca90c515b --- /dev/null +++ b/sentry-samples/sentry-samples-android/gradle.properties @@ -0,0 +1 @@ +../../gradle.properties \ No newline at end of file diff --git a/sentry-samples/sentry-samples-android/gradle/gradle-daemon-jvm.properties b/sentry-samples/sentry-samples-android/gradle/gradle-daemon-jvm.properties new file mode 120000 index 00000000000..b6233ee4449 --- /dev/null +++ b/sentry-samples/sentry-samples-android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1 @@ +../../../gradle/gradle-daemon-jvm.properties \ No newline at end of file diff --git a/sentry-samples/sentry-samples-android/gradle/wrapper b/sentry-samples/sentry-samples-android/gradle/wrapper new file mode 120000 index 00000000000..166ffc09a82 --- /dev/null +++ b/sentry-samples/sentry-samples-android/gradle/wrapper @@ -0,0 +1 @@ +../../../gradle/wrapper \ No newline at end of file diff --git a/sentry-samples/sentry-samples-android/gradlew b/sentry-samples/sentry-samples-android/gradlew new file mode 120000 index 00000000000..343e0d2caa4 --- /dev/null +++ b/sentry-samples/sentry-samples-android/gradlew @@ -0,0 +1 @@ +../../gradlew \ No newline at end of file diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/README.md b/sentry-samples/sentry-samples-android/macrobenchmark/README.md similarity index 97% rename from sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/README.md rename to sentry-samples/sentry-samples-android/macrobenchmark/README.md index 9914825584d..23c5fab6921 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/README.md +++ b/sentry-samples/sentry-samples-android/macrobenchmark/README.md @@ -1,4 +1,4 @@ -# sentry-uitest-android-macrobenchmark +# macrobenchmark Jetpack Macrobenchmark for cold-start of `sentry-samples-android`, used to evaluate SDK-init performance changes on a real device in a **stable, reproducible** way. Not run in CI. @@ -28,7 +28,7 @@ unlocked-clock device after ~14 iterations, inflating the tail of longer runs. Connect a device, then: ```bash -./gradlew :sentry-android-integration-tests:sentry-uitest-android-macrobenchmark:connectedBenchmarkAndroidTest +./gradlew -p sentry-samples/sentry-samples-android :macrobenchmark:connectedBenchmarkAndroidTest ``` Results print to the console and are written to diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts b/sentry-samples/sentry-samples-android/macrobenchmark/build.gradle.kts similarity index 94% rename from sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts rename to sentry-samples/sentry-samples-android/macrobenchmark/build.gradle.kts index cab7c28f934..a26fe088cd7 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/macrobenchmark/build.gradle.kts @@ -1,5 +1,7 @@ plugins { id("com.android.test") + id("io.sentry.spotless") + id("io.sentry.jdk-target") alias(libs.plugins.kotlin.android) } @@ -26,7 +28,7 @@ android { } } - targetProjectPath = ":sentry-samples:sentry-samples-android" + targetProjectPath = ":" // Run the test in its own process so it measures the target app cold, not itself. experimentalProperties["android.experimental.self-instrumenting"] = true } diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/macrobenchmark/src/main/AndroidManifest.xml similarity index 100% rename from sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/AndroidManifest.xml rename to sentry-samples/sentry-samples-android/macrobenchmark/src/main/AndroidManifest.xml diff --git a/sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt b/sentry-samples/sentry-samples-android/macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt similarity index 100% rename from sentry-android-integration-tests/sentry-uitest-android-macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt rename to sentry-samples/sentry-samples-android/macrobenchmark/src/main/java/io/sentry/uitest/android/macrobenchmark/SentryStartupBenchmark.kt diff --git a/sentry-samples/sentry-samples-android/settings.gradle.kts b/sentry-samples/sentry-samples-android/settings.gradle.kts new file mode 100644 index 00000000000..9d024bf958a --- /dev/null +++ b/sentry-samples/sentry-samples-android/settings.gradle.kts @@ -0,0 +1,64 @@ +pluginManagement { + repositories { + // Prefer local SAGP artifact if one exists; otherwise fall back to libs.versions.toml. + if (providers.gradleProperty("useSagp").isPresent) { + mavenLocal { + content { + includeGroup("io.sentry") + includeGroup("io.sentry.android.gradle") + } + } + } + mavenCentral() + gradlePluginPortal() + google() + } + + // Every build in a composite must use the same AGP, so each build's settings applies the AGP + // matrix job's override. + System.getenv("VERSION_AGP")?.let { agpVersion -> + resolutionStrategy { + eachPlugin { + if (requested.id.id.startsWith("com.android")) { + useVersion(agpVersion) + } + } + } + } +} + +plugins { + id("com.gradle.develocity") version "4.6.1" + id("com.gradle.common-custom-user-data-gradle-plugin") version "2.8.0" +} + +develocity { + buildScan { + termsOfUseUrl.set("https://gradle.com/help/legal-terms-of-use") + termsOfUseAgree.set("yes") + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + mavenLocal() + } + versionCatalogs { + create("libs") { + from(files("../../gradle/libs.versions.toml")) + // gradle.properties links to the SDK build's, so versionName is the SDK version. + version("sentry", providers.gradleProperty("versionName").get()) + } + } +} + +rootProject.name = "sentry-samples-android" + +includeBuild("../..") + +// For the io.sentry.spotless and io.sentry.jdk-target convention plugins. +includeBuild("../../build-logic") + +include("macrobenchmark") diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index beb07409067..d73711b4c9b 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -16,7 +16,8 @@ - + + { @@ -19,8 +23,8 @@ dependencies { implementation(libs.ktor.client.core) implementation(libs.ktor.client.java) implementation(libs.kotlinx.coroutines) - implementation(projects.sentry) - implementation(projects.sentryKtorClient) + implementation("io.sentry:sentry:$sentryVersion") + implementation("io.sentry:sentry-ktor-client:$sentryVersion") } tasks.test { useJUnitPlatform() } diff --git a/sentry-samples/sentry-samples-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-log4j2/build.gradle.kts index d320d2b3334..ea8373c939f 100644 --- a/sentry-samples/sentry-samples-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-log4j2/build.gradle.kts @@ -3,12 +3,16 @@ plugins { application alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + application { mainClass.set("io.sentry.samples.log4j2.Main") } dependencies { - implementation(projects.sentryLog4j2) + implementation("io.sentry:sentry-log4j2:$sentryVersion") implementation(libs.log4j.api) implementation(libs.log4j.core) } diff --git a/sentry-samples/sentry-samples-logback/build.gradle.kts b/sentry-samples/sentry-samples-logback/build.gradle.kts index 12644bf2ded..c3e10054745 100644 --- a/sentry-samples/sentry-samples-logback/build.gradle.kts +++ b/sentry-samples/sentry-samples-logback/build.gradle.kts @@ -3,12 +3,16 @@ plugins { application alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + application { mainClass.set("io.sentry.samples.logback.Main") } dependencies { - implementation(projects.sentryLogback) + implementation("io.sentry:sentry-logback:$sentryVersion") implementation(libs.logback.classic) } diff --git a/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts b/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts index 2f1f252d17e..4503c1b6ebb 100644 --- a/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts +++ b/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts @@ -1,4 +1,5 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import io.sentry.gradle.MergeSpringMetadataAction +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -7,8 +8,12 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + application { mainClass.set("io.sentry.samples.netflix.dgs.NetlixDgsApplication") } group = "io.sentry.sample.spring-boot" @@ -22,10 +27,10 @@ java.targetCompatibility = JavaVersion.VERSION_11 dependencies { implementation(platform(libs.springboot2.bom)) implementation(libs.springboot.starter.web) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBootStarter) - implementation(projects.sentryGraphql) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-starter:$sentryVersion") + implementation("io.sentry:sentry-graphql:$sentryVersion") implementation(platform("com.netflix.graphql.dgs:graphql-dgs-platform-dependencies:4.9.2")) implementation("com.netflix.graphql.dgs:graphql-dgs-subscriptions-websockets-autoconfigure:4.9.2") implementation("com.netflix.graphql.dgs:graphql-dgs-spring-boot-starter") diff --git a/sentry-samples/sentry-samples-openfeign/build.gradle.kts b/sentry-samples/sentry-samples-openfeign/build.gradle.kts index 39193a3383c..8208b96d3a6 100644 --- a/sentry-samples/sentry-samples-openfeign/build.gradle.kts +++ b/sentry-samples/sentry-samples-openfeign/build.gradle.kts @@ -2,8 +2,12 @@ plugins { java application alias(libs.plugins.gradle.versions) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + application { mainClass.set("io.sentry.samples.openfeign.Main") } configure { @@ -12,8 +16,8 @@ configure { } dependencies { - implementation(projects.sentry) - implementation(projects.sentryOpenfeign) + implementation("io.sentry:sentry:$sentryVersion") + implementation("io.sentry:sentry-openfeign:$sentryVersion") implementation(libs.feign.core) implementation(libs.feign.gson) } diff --git a/sentry-samples/sentry-samples-servlet/build.gradle.kts b/sentry-samples/sentry-samples-servlet/build.gradle.kts index ee758c3e129..bc4182c9786 100644 --- a/sentry-samples/sentry-samples-servlet/build.gradle.kts +++ b/sentry-samples/sentry-samples-servlet/build.gradle.kts @@ -1,4 +1,10 @@ -plugins { war } +plugins { + war + id("io.sentry.spotless") + id("io.sentry.jdk-target") +} + +val sentryVersion = libs.versions.sentry.get() group = "io.sentry.sample.servlet" @@ -9,7 +15,7 @@ java.sourceCompatibility = JavaVersion.VERSION_11 java.targetCompatibility = JavaVersion.VERSION_11 dependencies { - implementation(projects.sentryServlet) + implementation("io.sentry:sentry-servlet:$sentryVersion") implementation("javax.servlet:javax.servlet-api:4.0.1") } diff --git a/sentry-samples/sentry-samples-spring-7/build.gradle.kts b/sentry-samples/sentry-samples-spring-7/build.gradle.kts index 83017cba374..ef201480a52 100644 --- a/sentry-samples/sentry-samples-spring-7/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-7/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.springframework.boot.gradle.plugin.SpringBootPlugin @@ -10,8 +10,12 @@ plugins { alias(libs.plugins.kotlin.spring) id("war") alias(libs.plugins.gretty) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + application { mainClass.set("io.sentry.samples.spring7.Main") } // Ensure WAR is up to date before run task @@ -29,16 +33,16 @@ dependencyManagement { } dependencies { - implementation(Config.Libs.springWeb) - implementation(Config.Libs.springAop) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.springSecurityWeb) - implementation(Config.Libs.springSecurityConfig) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpring7) - implementation(projects.sentryLogback) - implementation(projects.sentryAsyncProfiler) + implementation("org.springframework:spring-webmvc") + implementation("org.springframework:spring-aop") + implementation(libs.aspectjweaver) + implementation("org.springframework.security:spring-security-web") + implementation("org.springframework.security:spring-security-config") + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-7:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") implementation(libs.jackson.databind) implementation(libs.logback.classic) implementation(libs.servlet.jakarta.api) diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-log4j2/build.gradle.kts index 86ae3560d22..00bdefcdb1a 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -6,8 +6,12 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + group = "io.sentry.sample.spring-boot-4-log4j2" version = "0.0.1-SNAPSHOT" @@ -36,15 +40,15 @@ dependencies { implementation(libs.springboot4.starter.websocket) implementation(libs.springboot4.starter.restclient) implementation(libs.springboot4.starter.webclient) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBoot4Starter) - implementation(projects.sentryGraphql22) - implementation(projects.sentryQuartz) - implementation(projects.sentryAsyncProfiler) + implementation(libs.aspectjweaver) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-4-starter:$sentryVersion") + implementation("io.sentry:sentry-graphql-22:$sentryVersion") + implementation("io.sentry:sentry-quartz:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") - implementation(projects.sentryLog4j2) + implementation("io.sentry:sentry-log4j2:$sentryVersion") implementation("org.springframework.boot:spring-boot-starter-log4j2") modules { module("org.springframework.boot:spring-boot-starter-logging") { @@ -61,9 +65,9 @@ dependencies { // kafka implementation(libs.springboot4.starter.kafka) - implementation(projects.sentryKafka) + implementation("io.sentry:sentry-kafka:$sentryVersion") // database query tracing - implementation(projects.sentryJdbc) + implementation("io.sentry:sentry-jdbc:$sentryVersion") runtimeOnly(libs.hsqldb) } diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts index b8f4cf762a4..ea8196b846e 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -6,8 +6,12 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + group = "io.sentry.sample.spring-boot-4" version = "0.0.1-SNAPSHOT" @@ -36,22 +40,22 @@ dependencies { implementation(libs.springboot4.starter.websocket) implementation(libs.springboot4.starter.webclient) implementation(libs.springboot4.starter.restclient) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBoot4Starter) - implementation(projects.sentryLogback) - implementation(projects.sentryGraphql22) - implementation(projects.sentryQuartz) - implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) - implementation(projects.sentryAsyncProfiler) + implementation(libs.aspectjweaver) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-4-starter:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-graphql-22:$sentryVersion") + implementation("io.sentry:sentry-quartz:$sentryVersion") + implementation("io.sentry:sentry-opentelemetry-agentless-spring:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") // kafka implementation(libs.springboot4.starter.kafka) - implementation(projects.sentryKafka) + implementation("io.sentry:sentry-kafka:$sentryVersion") // database query tracing - implementation(projects.sentryJdbc) + implementation("io.sentry:sentry-jdbc:$sentryVersion") runtimeOnly(libs.hsqldb) } diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts index 8e9d87bbf24..90928af421f 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.springframework.boot.gradle.tasks.run.BootRun @@ -7,8 +7,12 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + group = "io.sentry.sample.spring-boot-4" version = "0.0.1-SNAPSHOT" @@ -37,26 +41,26 @@ dependencies { implementation(libs.springboot4.starter.websocket) implementation(libs.springboot4.starter.webclient) implementation(libs.springboot4.starter.restclient) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBoot4Starter) - implementation(projects.sentryLogback) - implementation(projects.sentryGraphql22) - implementation(projects.sentryQuartz) - implementation(projects.sentryAsyncProfiler) + implementation(libs.aspectjweaver) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-4-starter:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-graphql-22:$sentryVersion") + implementation("io.sentry:sentry-quartz:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") implementation(libs.otel) // kafka implementation(libs.springboot4.starter.kafka) - implementation(projects.sentryKafka) + implementation("io.sentry:sentry-kafka:$sentryVersion") // cache tracing implementation(libs.springboot4.starter.cache) implementation(libs.caffeine) // database query tracing - implementation(projects.sentryJdbc) + implementation("io.sentry:sentry-jdbc:$sentryVersion") runtimeOnly(libs.hsqldb) } @@ -67,9 +71,8 @@ tasks.register("bootRunWithAgent").configure { mainClass = mainBootRunTask.mainClass classpath = mainBootRunTask.classpath - val versionName = project.properties["versionName"] as String val agentJarPath = - "$rootDir/sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/sentry-opentelemetry-agent-$versionName.jar" + "$rootDir/../sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/sentry-opentelemetry-agent-$sentryVersion.jar" val dsn = System.getenv("SENTRY_DSN") diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts index bad23b8167a..2a33a4bd46a 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -6,8 +6,12 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + group = "io.sentry.sample.spring-boot-4-otlp" version = "0.0.1-SNAPSHOT" @@ -36,22 +40,22 @@ dependencies { implementation(libs.springboot4.starter.websocket) implementation(libs.springboot4.starter.restclient) implementation(libs.springboot4.starter.webclient) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBoot4Starter) - implementation(projects.sentryLogback) - implementation(projects.sentryGraphql22) - implementation(projects.sentryQuartz) - implementation(projects.sentryAsyncProfiler) - implementation(projects.sentryOpentelemetry.sentryOpentelemetryOtlpSpring) + implementation(libs.aspectjweaver) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-4-starter:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-graphql-22:$sentryVersion") + implementation("io.sentry:sentry-quartz:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") + implementation("io.sentry:sentry-opentelemetry-otlp-spring:$sentryVersion") // cache tracing implementation(libs.springboot4.starter.cache) implementation(libs.caffeine) // database query tracing - implementation(projects.sentryJdbc) + implementation("io.sentry:sentry-jdbc:$sentryVersion") runtimeOnly(libs.hsqldb) } diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts index 6b1f3d91770..6a12eaadb5d 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -6,20 +6,24 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + group = "io.sentry.sample.spring-boot-4-webflux" version = "0.0.1-SNAPSHOT" dependencies { - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBoot4Starter) - implementation(projects.sentryLogback) - implementation(projects.sentryJdbc) - implementation(projects.sentryGraphql22) - implementation(projects.sentryAsyncProfiler) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-4-starter:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-jdbc:$sentryVersion") + implementation("io.sentry:sentry-graphql-22:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") implementation(libs.context.propagation) implementation(libs.springboot4.starter.actuator) implementation(libs.springboot4.starter.graphql) diff --git a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts index 7d83c62b2b9..da74005836c 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -6,8 +6,12 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + group = "io.sentry.sample.spring-boot-4" version = "0.0.1-SNAPSHOT" @@ -36,14 +40,14 @@ dependencies { implementation(libs.springboot4.starter.websocket) implementation(libs.springboot4.starter.restclient) implementation(libs.springboot4.starter.webclient) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBoot4Starter) - implementation(projects.sentryLogback) - implementation(projects.sentryGraphql22) - implementation(projects.sentryQuartz) - implementation(projects.sentryAsyncProfiler) + implementation(libs.aspectjweaver) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-4-starter:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-graphql-22:$sentryVersion") + implementation("io.sentry:sentry-quartz:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") // cache tracing implementation(libs.springboot4.starter.cache) @@ -51,13 +55,13 @@ dependencies { // kafka implementation(libs.springboot4.starter.kafka) - implementation(projects.sentryKafka) + implementation("io.sentry:sentry-kafka:$sentryVersion") // okhttp client instrumentation - implementation(projects.sentryOkhttp) + implementation("io.sentry:sentry-okhttp:$sentryVersion") implementation(libs.okhttp) // database query tracing - implementation(projects.sentryJdbc) + implementation("io.sentry:sentry-jdbc:$sentryVersion") runtimeOnly(libs.hsqldb) } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/build.gradle.kts index 06314c2b28f..3b87c57b893 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -6,8 +6,12 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + group = "io.sentry.sample.spring-boot-jakarta-log4j2" version = "0.0.1-SNAPSHOT" @@ -38,11 +42,11 @@ dependencies { implementation(libs.springboot3.starter.web) implementation(libs.springboot3.starter.webflux) implementation(libs.springboot3.starter.websocket) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBootStarterJakarta) - implementation(projects.sentryLog4j2) + implementation(libs.aspectjweaver) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-starter-jakarta:$sentryVersion") + implementation("io.sentry:sentry-log4j2:$sentryVersion") implementation("org.springframework.boot:spring-boot-starter-log4j2") modules { module("org.springframework.boot:spring-boot-starter-logging") { @@ -52,10 +56,10 @@ dependencies { ) } } - implementation(projects.sentryGraphql22) - implementation(projects.sentryQuartz) - implementation(projects.sentryAsyncProfiler) - implementation(projects.sentryOpenfeature) + implementation("io.sentry:sentry-graphql-22:$sentryVersion") + implementation("io.sentry:sentry-quartz:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") + implementation("io.sentry:sentry-openfeature:$sentryVersion") // cache tracing implementation(libs.springboot3.starter.cache) @@ -63,12 +67,12 @@ dependencies { // kafka implementation(libs.spring.kafka3) - implementation(projects.sentryKafka) + implementation("io.sentry:sentry-kafka:$sentryVersion") // OpenFeature SDK implementation(libs.openfeature) // database query tracing - implementation(projects.sentryJdbc) + implementation("io.sentry:sentry-jdbc:$sentryVersion") runtimeOnly(libs.hsqldb) } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts index 883c622d6ee..d1fe6ad9911 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -6,8 +6,12 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + group = "io.sentry.sample.spring-boot-jakarta" version = "0.0.1-SNAPSHOT" @@ -39,25 +43,25 @@ dependencies { implementation(libs.springboot3.starter.web) implementation(libs.springboot3.starter.webflux) implementation(libs.springboot3.starter.websocket) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBootStarterJakarta) - implementation(projects.sentryLogback) - implementation(projects.sentryGraphql22) - implementation(projects.sentryQuartz) - implementation(projects.sentryAsyncProfiler) - implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) + implementation(libs.aspectjweaver) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-starter-jakarta:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-graphql-22:$sentryVersion") + implementation("io.sentry:sentry-quartz:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") + implementation("io.sentry:sentry-opentelemetry-agentless-spring:$sentryVersion") // kafka implementation(libs.spring.kafka3) - implementation(projects.sentryKafka) + implementation("io.sentry:sentry-kafka:$sentryVersion") // cache tracing implementation(libs.springboot3.starter.cache) implementation(libs.caffeine) // database query tracing - implementation(projects.sentryJdbc) + implementation("io.sentry:sentry-jdbc:$sentryVersion") runtimeOnly(libs.hsqldb) } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts index e6d7d9c31ba..ff1e2a27ac7 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.springframework.boot.gradle.tasks.run.BootRun @@ -7,8 +7,12 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + group = "io.sentry.sample.spring-boot-jakarta" version = "0.0.1-SNAPSHOT" @@ -39,26 +43,26 @@ dependencies { implementation(libs.springboot3.starter.web) implementation(libs.springboot3.starter.webflux) implementation(libs.springboot3.starter.websocket) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBootStarterJakarta) - implementation(projects.sentryLogback) - implementation(projects.sentryGraphql22) - implementation(projects.sentryQuartz) + implementation(libs.aspectjweaver) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-starter-jakarta:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-graphql-22:$sentryVersion") + implementation("io.sentry:sentry-quartz:$sentryVersion") implementation(libs.otel) - implementation(projects.sentryAsyncProfiler) + implementation("io.sentry:sentry-async-profiler:$sentryVersion") // kafka implementation(libs.spring.kafka3) - implementation(projects.sentryKafka) + implementation("io.sentry:sentry-kafka:$sentryVersion") // cache tracing implementation(libs.springboot3.starter.cache) implementation(libs.caffeine) // database query tracing - implementation(projects.sentryJdbc) + implementation("io.sentry:sentry-jdbc:$sentryVersion") runtimeOnly(libs.hsqldb) } @@ -69,9 +73,8 @@ tasks.register("bootRunWithAgent").configure { mainClass = mainBootRunTask.mainClass classpath = mainBootRunTask.classpath - val versionName = project.properties["versionName"] as String val agentJarPath = - "$rootDir/sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/sentry-opentelemetry-agent-$versionName.jar" + "$rootDir/../sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/sentry-opentelemetry-agent-$sentryVersion.jar" val dsn = System.getenv("SENTRY_DSN") diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index e3591289cdb..d8709bf0062 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -6,8 +6,12 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + group = "io.sentry.sample.spring-boot-jakarta" version = "0.0.1-SNAPSHOT" @@ -38,15 +42,15 @@ dependencies { implementation(libs.springboot3.starter.web) implementation(libs.springboot3.starter.webflux) implementation(libs.springboot3.starter.websocket) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBootStarterJakarta) - implementation(projects.sentryLogback) - implementation(projects.sentryGraphql22) - implementation(projects.sentryQuartz) - implementation(projects.sentryAsyncProfiler) - implementation(projects.sentryOpenfeature) + implementation(libs.aspectjweaver) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-starter-jakarta:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-graphql-22:$sentryVersion") + implementation("io.sentry:sentry-quartz:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") + implementation("io.sentry:sentry-openfeature:$sentryVersion") // cache tracing implementation(libs.springboot3.starter.cache) @@ -54,16 +58,16 @@ dependencies { // kafka implementation(libs.spring.kafka3) - implementation(projects.sentryKafka) + implementation("io.sentry:sentry-kafka:$sentryVersion") // OpenFeature SDK implementation(libs.openfeature) // okhttp client instrumentation - implementation(projects.sentryOkhttp) + implementation("io.sentry:sentry-okhttp:$sentryVersion") implementation(libs.okhttp) // database query tracing - implementation(projects.sentryJdbc) + implementation("io.sentry:sentry-jdbc:$sentryVersion") runtimeOnly(libs.hsqldb) } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts index 62c5bcbd3aa..9234da89f4c 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts @@ -1,4 +1,5 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import io.sentry.gradle.MergeSpringMetadataAction +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -7,8 +8,12 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + // Spring scans this app's own bytecode with its repackaged ASM at startup, and the oldest version // the Spring Boot 2.x matrix covers (2.4.13, Spring 5.3.13) fails on class files as new as the // toolchain's: "Unsupported class file major version 65". Only the app is scanned, so its test @@ -44,21 +49,21 @@ dependencies { implementation(libs.springboot.starter.web) implementation(libs.springboot.starter.webflux) implementation(libs.springboot.starter.websocket) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBootStarter) - implementation(projects.sentryLogback) - implementation(projects.sentryGraphql) - implementation(projects.sentryQuartz) - implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) - implementation(projects.sentryAsyncProfiler) + implementation(libs.aspectjweaver) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-starter:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-graphql:$sentryVersion") + implementation("io.sentry:sentry-quartz:$sentryVersion") + implementation("io.sentry:sentry-opentelemetry-agentless-spring:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") implementation(libs.spring.kafka2) - implementation(projects.sentryKafka) + implementation("io.sentry:sentry-kafka:$sentryVersion") // database query tracing - implementation(projects.sentryJdbc) + implementation("io.sentry:sentry-jdbc:$sentryVersion") runtimeOnly(libs.hsqldb) } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts index 652727f7468..c67bdbb33fe 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts @@ -1,4 +1,5 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import io.sentry.gradle.MergeSpringMetadataAction +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -7,8 +8,12 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + // Spring scans this app's own bytecode with its repackaged ASM at startup, and the oldest version // the Spring Boot 2.x matrix covers (2.4.13, Spring 5.3.13) fails on class files as new as the // toolchain's: "Unsupported class file major version 65". Only the app is scanned, so its test @@ -43,21 +48,21 @@ dependencies { implementation(libs.springboot.starter.web) implementation(libs.springboot.starter.webflux) implementation(libs.springboot.starter.websocket) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBootStarter) - implementation(projects.sentryLogback) - implementation(projects.sentryGraphql) - implementation(projects.sentryQuartz) - implementation(projects.sentryAsyncProfiler) + implementation(libs.aspectjweaver) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-starter:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-graphql:$sentryVersion") + implementation("io.sentry:sentry-quartz:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") implementation(libs.otel) implementation(libs.spring.kafka2) - implementation(projects.sentryKafka) + implementation("io.sentry:sentry-kafka:$sentryVersion") // database query tracing - implementation(projects.sentryJdbc) + implementation("io.sentry:sentry-jdbc:$sentryVersion") runtimeOnly(libs.hsqldb) } @@ -89,9 +94,8 @@ tasks.register("bootRunWithAgent").configure { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") classpath = sourceSets["main"].runtimeClasspath - val versionName = project.properties["versionName"] as String val agentJarPath = - "$rootDir/sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/sentry-opentelemetry-agent-$versionName.jar" + "$rootDir/../sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/sentry-opentelemetry-agent-$sentryVersion.jar" val dsn = System.getenv("SENTRY_DSN") diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts index e9d4b2f6905..78a6811dad5 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -6,8 +6,12 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + group = "io.sentry.sample.spring-boot-webflux-jakarta" version = "0.0.1-SNAPSHOT" @@ -22,13 +26,13 @@ dependencyManagement { extra["kotlin-coroutines.version"] = "1.9.0" dependencies { - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBootStarterJakarta) - implementation(projects.sentryLogback) - implementation(projects.sentryJdbc) - implementation(projects.sentryGraphql22) - implementation(projects.sentryAsyncProfiler) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-starter-jakarta:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-jdbc:$sentryVersion") + implementation("io.sentry:sentry-graphql-22:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") implementation(libs.context.propagation) implementation(libs.springboot3.starter.actuator) implementation(libs.springboot3.starter.graphql) diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts index d01da9f56c5..07eca851939 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -1,4 +1,5 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import io.sentry.gradle.MergeSpringMetadataAction +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -7,8 +8,12 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + // Spring scans this app's own bytecode with its repackaged ASM at startup, and the oldest version // the Spring Boot 2.x matrix covers (2.4.13, Spring 5.3.13) fails on class files as new as the // toolchain's: "Unsupported class file major version 65". Only the app is scanned, so its test @@ -30,12 +35,12 @@ dependencies { implementation(libs.springboot.starter.actuator) implementation(libs.springboot.starter.graphql) implementation(libs.springboot.starter.webflux) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBootStarter) - implementation(projects.sentryLogback) - implementation(projects.sentryGraphql) - implementation(projects.sentryAsyncProfiler) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-starter:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-graphql:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") } val runtimeClasspath = configurations.named("runtimeClasspath") diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index b4f8bc105ec..13e91956a2d 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -1,4 +1,5 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import io.sentry.gradle.MergeSpringMetadataAction +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -7,8 +8,12 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + // Spring scans this app's own bytecode with its repackaged ASM at startup, and the oldest version // the Spring Boot 2.x matrix covers (2.4.13, Spring 5.3.13) fails on class files as new as the // toolchain's: "Unsupported class file major version 65". Only the app is scanned, so its test @@ -47,22 +52,22 @@ dependencies { implementation(libs.caffeine) implementation(libs.spring.kafka2) - implementation(projects.sentryKafka) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) - implementation(projects.sentrySpringBootStarter) - implementation(projects.sentryLogback) - implementation(projects.sentryGraphql) - implementation(projects.sentryQuartz) - implementation(projects.sentryAsyncProfiler) + implementation("io.sentry:sentry-kafka:$sentryVersion") + implementation(libs.aspectjweaver) + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8", getKotlinPluginVersion())) + implementation("io.sentry:sentry-spring-boot-starter:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-graphql:$sentryVersion") + implementation("io.sentry:sentry-quartz:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") // okhttp client instrumentation - implementation(projects.sentryOkhttp) + implementation("io.sentry:sentry-okhttp:$sentryVersion") implementation(libs.okhttp) // database query tracing - implementation(projects.sentryJdbc) + implementation("io.sentry:sentry-jdbc:$sentryVersion") runtimeOnly(libs.hsqldb) } diff --git a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts index 3a159bb6e11..81d4f35997a 100644 --- a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts @@ -7,8 +7,12 @@ plugins { alias(libs.plugins.kotlin.spring) id("war") alias(libs.plugins.gretty) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + application { mainClass.set("io.sentry.samples.spring.jakarta.Main") } // Ensure WAR is up to date before run task @@ -27,16 +31,16 @@ dependencyManagement { } dependencies { - implementation(Config.Libs.springWeb) - implementation(Config.Libs.springAop) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.springSecurityWeb) - implementation(Config.Libs.springSecurityConfig) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib)) - implementation(projects.sentrySpringJakarta) - implementation(projects.sentryLogback) - implementation(projects.sentryAsyncProfiler) + implementation("org.springframework:spring-webmvc") + implementation("org.springframework:spring-aop") + implementation(libs.aspectjweaver) + implementation("org.springframework.security:spring-security-web") + implementation("org.springframework.security:spring-security-config") + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8")) + implementation("io.sentry:sentry-spring-jakarta:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") implementation(libs.jackson.databind) implementation(libs.logback.classic) implementation(libs.servlet.jakarta.api) diff --git a/sentry-samples/sentry-samples-spring/build.gradle.kts b/sentry-samples/sentry-samples-spring/build.gradle.kts index 15df9480dcc..bfeb2bb8525 100644 --- a/sentry-samples/sentry-samples-spring/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring/build.gradle.kts @@ -7,8 +7,12 @@ plugins { alias(libs.plugins.kotlin.spring) id("war") alias(libs.plugins.gretty) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } +val sentryVersion = libs.versions.sentry.get() + // Spring scans this app's own bytecode with its repackaged ASM at startup, and the oldest version // the Spring Boot 2.x matrix covers (2.4.13, Spring 5.3.13) fails on class files as new as the // toolchain's: "Unsupported class file major version 65". Only the app is scanned, so its test @@ -38,16 +42,16 @@ dependencyManagement { } dependencies { - implementation(Config.Libs.springWeb) - implementation(Config.Libs.springAop) - implementation(Config.Libs.aspectj) - implementation(Config.Libs.springSecurityWeb) - implementation(Config.Libs.springSecurityConfig) - implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib)) - implementation(projects.sentrySpring) - implementation(projects.sentryLogback) - implementation(projects.sentryAsyncProfiler) + implementation("org.springframework:spring-webmvc") + implementation("org.springframework:spring-aop") + implementation(libs.aspectjweaver) + implementation("org.springframework.security:spring-security-web") + implementation("org.springframework.security:spring-security-config") + implementation(libs.kotlin.reflect) + implementation(kotlin("stdlib-jdk8")) + implementation("io.sentry:sentry-spring:$sentryVersion") + implementation("io.sentry:sentry-logback:$sentryVersion") + implementation("io.sentry:sentry-async-profiler:$sentryVersion") implementation(libs.jackson.databind) implementation(libs.logback.classic) implementation(libs.servlet.api) diff --git a/sentry-samples/settings.gradle.kts b/sentry-samples/settings.gradle.kts new file mode 100644 index 00000000000..ad2fe3e3db6 --- /dev/null +++ b/sentry-samples/settings.gradle.kts @@ -0,0 +1,68 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("com.gradle.develocity") version "4.6.1" + id("com.gradle.common-custom-user-data-gradle-plugin") version "2.8.0" +} + +develocity { + buildScan { + termsOfUseUrl.set("https://gradle.com/help/legal-terms-of-use") + termsOfUseAgree.set("yes") + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + mavenLocal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + // gradle.properties links to the SDK build's, so versionName is the SDK version. + version("sentry", providers.gradleProperty("versionName").get()) + } + } +} + +rootProject.name = "sentry-samples" + +includeBuild("..") + +// For the io.sentry.spotless and io.sentry.jdk-target convention plugins. +includeBuild("../build-logic") + +include("sentry-samples-console") +include("sentry-samples-console-opentelemetry-noagent") +include("sentry-samples-console-otlp") +include("sentry-samples-jul") +include("sentry-samples-ktor-client") +include("sentry-samples-log4j2") +include("sentry-samples-logback") +include("sentry-samples-netflix-dgs") +include("sentry-samples-openfeign") +include("sentry-samples-servlet") +include("sentry-samples-spring") +include("sentry-samples-spring-7") +include("sentry-samples-spring-boot") +include("sentry-samples-spring-boot-4") +include("sentry-samples-spring-boot-4-log4j2") +include("sentry-samples-spring-boot-4-opentelemetry") +include("sentry-samples-spring-boot-4-opentelemetry-noagent") +include("sentry-samples-spring-boot-4-otlp") +include("sentry-samples-spring-boot-4-webflux") +include("sentry-samples-spring-boot-jakarta") +include("sentry-samples-spring-boot-jakarta-log4j2") +include("sentry-samples-spring-boot-jakarta-opentelemetry") +include("sentry-samples-spring-boot-jakarta-opentelemetry-noagent") +include("sentry-samples-spring-boot-opentelemetry") +include("sentry-samples-spring-boot-opentelemetry-noagent") +include("sentry-samples-spring-boot-webflux") +include("sentry-samples-spring-boot-webflux-jakarta") +include("sentry-samples-spring-jakarta") diff --git a/sentry-servlet-jakarta/build.gradle.kts b/sentry-servlet-jakarta/build.gradle.kts index 1c445b61360..1b4cac996f7 100644 --- a/sentry-servlet-jakarta/build.gradle.kts +++ b/sentry-servlet-jakarta/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-servlet/build.gradle.kts b/sentry-servlet/build.gradle.kts index a323c040ce8..18ebdb87f61 100644 --- a/sentry-servlet/build.gradle.kts +++ b/sentry-servlet/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-spotlight/build.gradle.kts b/sentry-spotlight/build.gradle.kts index 522e5312cb0..7172eb89f9b 100644 --- a/sentry-spotlight/build.gradle.kts +++ b/sentry-spotlight/build.gradle.kts @@ -9,6 +9,9 @@ plugins { alias(libs.plugins.gradle.versions) id("io.sentry.animalsniffer.android") alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-spring-7/api/sentry-spring-7.api b/sentry-spring-7/api/sentry-spring-7.api index 94aedd30034..a781ee47096 100644 --- a/sentry-spring-7/api/sentry-spring-7.api +++ b/sentry-spring-7/api/sentry-spring-7.api @@ -141,12 +141,14 @@ public final class io/sentry/spring7/cache/SentryCacheWrapper : org/springframew public abstract interface annotation class io/sentry/spring7/checkin/SentryCheckIn : java/lang/annotation/Annotation { public abstract fun heartbeat ()Z public abstract fun monitorSlug ()Ljava/lang/String; + public abstract fun upsertMonitorConfig ()Z public abstract fun value ()Ljava/lang/String; } public class io/sentry/spring7/checkin/SentryCheckInAdvice : org/aopalliance/intercept/MethodInterceptor, org/springframework/context/EmbeddedValueResolverAware { public fun ()V public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Z)V public fun invoke (Lorg/aopalliance/intercept/MethodInvocation;)Ljava/lang/Object; public fun setEmbeddedValueResolver (Lorg/springframework/util/StringValueResolver;)V } @@ -167,6 +169,11 @@ public class io/sentry/spring7/checkin/SentryQuartzConfiguration { public fun schedulerFactoryBeanCustomizer ()Lorg/springframework/boot/quartz/autoconfigure/SchedulerFactoryBeanCustomizer; } +public final class io/sentry/spring7/checkin/SentryScheduledCheckInPointcut : org/springframework/aop/support/StaticMethodMatcherPointcut { + public fun (Ljava/util/List;)V + public fun matches (Ljava/lang/reflect/Method;Ljava/lang/Class;)Z +} + public final class io/sentry/spring7/checkin/SentrySchedulerFactoryBeanCustomizer : org/springframework/boot/quartz/autoconfigure/SchedulerFactoryBeanCustomizer { public fun ()V public fun customize (Lorg/springframework/scheduling/quartz/SchedulerFactoryBean;)V diff --git a/sentry-spring-7/build.gradle.kts b/sentry-spring-7/build.gradle.kts index 90b7614867b..64c26019256 100644 --- a/sentry-spring-7/build.gradle.kts +++ b/sentry-spring-7/build.gradle.kts @@ -11,6 +11,9 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) alias(libs.plugins.springboot4) apply false + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } @@ -33,7 +36,7 @@ dependencies { compileOnly(Config.Libs.springWeb) compileOnly(Config.Libs.springAop) compileOnly(Config.Libs.springSecurityWeb) - compileOnly(Config.Libs.aspectj) + compileOnly(libs.aspectjweaver) compileOnly(libs.context.propagation) compileOnly(libs.jetbrains.annotations) compileOnly(libs.nopen.annotations) diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java index 47cd80ab5b2..ac17f85a985 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java @@ -29,6 +29,16 @@ */ boolean heartbeat() default false; + /** + * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so + * Sentry creates or updates the monitor. + * + *

Heartbeat check-ins never send a monitor config. + * + * @return true to send a monitor config, false by default + */ + boolean upsertMonitorConfig() default false; + /** * Monitor slug. If not set, no check-in will be sent. * diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java index a14f0d4c96a..2f83a079d0d 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java @@ -6,13 +6,25 @@ import io.sentry.DateUtils; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; +import io.sentry.MonitorConfig; import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; import io.sentry.protocol.SentryId; import io.sentry.time.Stopwatch; +import io.sentry.util.MonitorConfigUtils; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.zip.CRC32; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.jetbrains.annotations.ApiStatus; @@ -20,7 +32,11 @@ import org.jetbrains.annotations.Nullable; import org.springframework.aop.support.AopUtils; import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.annotation.Schedules; +import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringValueResolver; @@ -31,16 +47,44 @@ @ApiStatus.Internal @Open public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueResolverAware { + // Spring before 5.3 parses crons with CronSequenceGenerator + private static final boolean LEGACY_CRON_PARSER = + !ClassUtils.isPresent( + "org.springframework.scheduling.support.CronExpression", + SentryCheckInAdvice.class.getClassLoader()); + + private static final @NotNull Pattern WORD_BOUNDARY = + Pattern.compile("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])"); + private static final int MAX_SLUG_LENGTH = 50; + private final @NotNull IScopes scopes; + private final boolean monitorScheduledTasks; + private @Nullable StringValueResolver resolver; + private final @NotNull Map monitorConfigs = + new ConcurrentHashMap<>(); + + private final @NotNull Map derivedSlugOwners = new ConcurrentHashMap<>(); + + private final @NotNull Set collidingSlugs = ConcurrentHashMap.newKeySet(); + public SentryCheckInAdvice() { this(ScopesAdapter.getInstance()); } public SentryCheckInAdvice(final @NotNull IScopes scopes) { + this(scopes, false); + } + + /** + * @param monitorScheduledTasks whether to also monitor {@code @Scheduled} methods without {@link + * SentryCheckIn}, using a slug derived from the class and method name + */ + public SentryCheckInAdvice(final @NotNull IScopes scopes, final boolean monitorScheduledTasks) { this.scopes = Objects.requireNonNull(scopes, "scopes are required"); + this.monitorScheduledTasks = monitorScheduledTasks; } @Override @@ -54,6 +98,25 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) { SentryCheckIn checkInAnnotation = AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); if (checkInAnnotation == null) { + if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { + final @NotNull Class targetClass = + target != null ? target.getClass() : mostSpecificMethod.getDeclaringClass(); + final @NotNull String taskName = scheduledTaskName(targetClass, mostSpecificMethod); + final @Nullable MonitorConfig monitorConfig = monitorConfig(mostSpecificMethod); + if (monitorConfig == null) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not monitoring %s because Sentry Crons can't express its @Scheduled schedule.", + taskName); + return invocation.proceed(); + } + final @NotNull String monitorSlug = scheduledTaskSlug(taskName); + warnOnSlugCollision(monitorSlug, targetClass, mostSpecificMethod); + return captureCheckIns(invocation, monitorSlug, false, monitorConfig); + } return invocation.proceed(); } @@ -89,6 +152,19 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) { return invocation.proceed(); } + final @Nullable MonitorConfig monitorConfig = + !isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig() + ? monitorConfig(mostSpecificMethod) + : null; + return captureCheckIns(invocation, monitorSlug, isHeartbeatOnly, monitorConfig); + } + + private @Nullable Object captureCheckIns( + final @NotNull MethodInvocation invocation, + final @NotNull String monitorSlug, + final boolean isHeartbeatOnly, + final @Nullable MonitorConfig monitorConfig) + throws Throwable { try (final @NotNull ISentryLifecycleToken ignored = scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) { TracingUtils.startNewTrace(scopes); @@ -100,7 +176,9 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) { try { if (!isHeartbeatOnly) { - checkInId = scopes.captureCheckIn(new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS)); + final @NotNull CheckIn inProgress = new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS); + inProgress.setMonitorConfig(monitorConfig); + checkInId = scopes.captureCheckIn(inProgress); } return invocation.proceed(); } catch (Throwable e) { @@ -115,6 +193,144 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) { } } + private void warnOnSlugCollision( + final @NotNull String monitorSlug, + final @NotNull Class targetClass, + final @NotNull Method method) { + final @NotNull String identity = + ClassUtils.getUserClass(targetClass).getName() + "." + method.getName(); + final @Nullable String owner = derivedSlugOwners.putIfAbsent(monitorSlug, identity); + if (owner != null && !owner.equals(identity) && collidingSlugs.add(monitorSlug)) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them.", + monitorSlug, + owner, + identity); + } + } + + static boolean isScheduled(final @NotNull Method method) { + return !AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class) + .isEmpty(); + } + + /** {@code ClassName.methodName}, with nested classes as {@code Outer.Inner}. */ + static @NotNull String scheduledTaskName( + final @NotNull Class targetClass, final @NotNull Method method) { + return ClassUtils.getShortName(ClassUtils.getUserClass(targetClass)) + "." + method.getName(); + } + + static @NotNull String scheduledTaskSlug(final @NotNull String taskName) { + final @NotNull String slug = + WORD_BOUNDARY + .matcher(taskName) + .replaceAll("-") + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+|-+$", ""); + if (slug.length() <= MAX_SLUG_LENGTH) { + return slug; + } + // keep truncated slugs unique + final @NotNull CRC32 crc = new CRC32(); + crc.update(slug.getBytes(StandardCharsets.UTF_8)); + final @NotNull String hash = String.format(Locale.ROOT, "%08x", crc.getValue()); + return slug.substring(0, MAX_SLUG_LENGTH - hash.length() - 1).replaceAll("-+$", "") + + "-" + + hash; + } + + private @Nullable MonitorConfig monitorConfig(final @NotNull Method method) { + return monitorConfigs.computeIfAbsent( + method, key -> new CachedMonitorConfig(monitorConfigFromScheduled(key))) + .config; + } + + private @Nullable MonitorConfig monitorConfigFromScheduled(final @NotNull Method method) { + try { + final @NotNull Set schedules = + AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class); + if (schedules.size() != 1) { + return null; + } + final @NotNull Scheduled scheduled = schedules.iterator().next(); + // timeUnit only exists from Spring 5.3.10, so read it as an attribute + final @Nullable Object timeUnitAttribute = + AnnotationUtils.getAnnotationAttributes(scheduled).get("timeUnit"); + final @NotNull TimeUnit timeUnit = + timeUnitAttribute instanceof TimeUnit unit ? unit : TimeUnit.MILLISECONDS; + final @Nullable String cron = resolve(scheduled.cron()); + // Spring only reads the zone for a cron + final @Nullable String zone = + cron == null || cron.isEmpty() ? null : resolve(scheduled.zone()); + return MonitorConfigUtils.fromSpringScheduled( + cron, + zone, + periodMillis(scheduled.fixedRate(), scheduled.fixedRateString(), timeUnit), + periodMillis(scheduled.fixedDelay(), scheduled.fixedDelayString(), timeUnit), + LEGACY_CRON_PARSER); + } catch (RuntimeException e) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.WARNING, "Could not derive a monitor config from @Scheduled.", e); + return null; + } + } + + private @Nullable Long periodMillis( + final long value, final @NotNull String valueString, final @NotNull TimeUnit timeUnit) { + if (value >= 0) { + return timeUnit.toMillis(value); + } + final @Nullable String resolved = resolve(valueString); + if (resolved == null || resolved.isEmpty()) { + return null; + } + final @NotNull String trimmed = resolved.trim(); + @Nullable Long millis = null; + if (trimmed.startsWith("P") || trimmed.startsWith("p")) { + try { + millis = Duration.parse(trimmed).toMillis(); + } catch (DateTimeParseException | ArithmeticException e) { + // logged below + } + } else { + millis = MonitorConfigUtils.parsePeriodMillis(trimmed, timeUnit); + } + if (millis == null) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not sending a monitor config because the @Scheduled period '%s' could not be parsed.", + trimmed); + } + return millis; + } + + private @Nullable String resolve(final @NotNull String value) { + if (resolver == null || value.isEmpty()) { + return value; + } + return resolver.resolveStringValue(value); + } + + private static final class CachedMonitorConfig { + private final @Nullable MonitorConfig config; + + private CachedMonitorConfig(final @Nullable MonitorConfig config) { + this.config = config; + } + } + @Override public void setEmbeddedValueResolver(StringValueResolver resolver) { this.resolver = resolver; diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryScheduledCheckInPointcut.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryScheduledCheckInPointcut.java new file mode 100644 index 00000000000..2fc1f11da17 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryScheduledCheckInPointcut.java @@ -0,0 +1,66 @@ +package io.sentry.spring7.checkin; + +import io.sentry.FilterString; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.aop.support.AopUtils; +import org.springframework.aop.support.StaticMethodMatcherPointcut; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.ClassUtils; + +/** + * Matches {@code @Scheduled} methods without {@link SentryCheckIn}, except excluded ones. Excludes + * match the task name ({@code ClassName.methodName}) exactly or as a regex. + */ +@ApiStatus.Internal +public final class SentryScheduledCheckInPointcut extends StaticMethodMatcherPointcut { + private final @NotNull List excludedScheduledTasks = new ArrayList<>(); + + public SentryScheduledCheckInPointcut(final @Nullable List excludedScheduledTasks) { + if (excludedScheduledTasks != null) { + for (final @NotNull String task : excludedScheduledTasks) { + if (!task.trim().isEmpty()) { + this.excludedScheduledTasks.add(new FilterString(task.trim())); + } + } + } + } + + @Override + public boolean matches(final @NotNull Method method, final @NotNull Class targetClass) { + final @NotNull Class userClass = ClassUtils.getUserClass(targetClass); + // CGLIB can't intercept these, and Spring calls private and static methods on the target + final int modifiers = method.getModifiers(); + if (Modifier.isFinal(userClass.getModifiers()) + || Modifier.isFinal(modifiers) + || Modifier.isPrivate(modifiers) + || Modifier.isStatic(modifiers)) { + return false; + } + final @NotNull Method specificMethod = AopUtils.getMostSpecificMethod(method, userClass); + if (!SentryCheckInAdvice.isScheduled(specificMethod) + || AnnotationUtils.findAnnotation(specificMethod, SentryCheckIn.class) != null) { + return false; + } + // Spring calls reactive and suspend methods once and subscribes to the result on each run + if (specificMethod.getParameterCount() > 0 + || ReactiveAdapterRegistry.getSharedInstance().getAdapter(specificMethod.getReturnType()) + != null) { + return false; + } + final @NotNull String taskName = + SentryCheckInAdvice.scheduledTaskName(userClass, specificMethod); + for (final @NotNull FilterString excluded : excludedScheduledTasks) { + if (excluded.getFilterString().equals(taskName) || excluded.matches(taskName)) { + return false; + } + } + return true; + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt index e15affdbdd5..22e02b0b297 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt @@ -4,16 +4,21 @@ import io.sentry.CheckIn import io.sentry.CheckInStatus import io.sentry.IScopes import io.sentry.ISentryLifecycleToken +import io.sentry.MonitorConfig import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.protocol.SentryId import io.sentry.spring7.checkin.SentryCheckIn import io.sentry.spring7.checkin.SentryCheckInAdviceConfiguration import io.sentry.spring7.checkin.SentryCheckInPointcutConfiguration +import java.util.TimeZone +import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame import org.junit.jupiter.api.assertThrows import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -32,6 +37,8 @@ import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.EnableAspectJAutoProxy import org.springframework.context.annotation.Import import org.springframework.context.support.PropertySourcesPlaceholderConfigurer +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.scheduling.annotation.Schedules import org.springframework.test.context.TestPropertySource import org.springframework.test.context.junit.jupiter.SpringJUnitConfig import org.springframework.test.context.junit4.SpringRunner @@ -39,7 +46,15 @@ import org.springframework.util.StringValueResolver @RunWith(SpringRunner::class) @SpringJUnitConfig(SentryCheckInAdviceTest.Config::class) -@TestPropertySource(properties = ["my.cron.slug = mypropertycronslug"]) +@TestPropertySource( + properties = + [ + "my.cron.slug = mypropertycronslug", + "my.cron.schedule = 0 30 2 * * *", + "my.cron.zone = America/New_York", + "my.cron.empty.zone = ", + ] +) class SentryCheckInAdviceTest { @Autowired lateinit var sampleService: SampleService @@ -50,6 +65,10 @@ class SentryCheckInAdviceTest { @Autowired lateinit var sampleServiceSpringProperties: SampleServiceSpringProperties + @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled + + @Autowired lateinit var sampleServiceInheritedSchedule: SampleServiceInheritedSchedule + @Autowired lateinit var scopes: IScopes val lifecycleToken = mock() @@ -74,6 +93,7 @@ class SentryCheckInAdviceTest { val inProgressCheckIn = checkInCaptor.firstValue assertEquals("monitor_slug_1", inProgressCheckIn.monitorSlug) assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status) + assertNull(inProgressCheckIn.monitorConfig) val doneCheckIn = checkInCaptor.lastValue assertEquals("monitor_slug_1", doneCheckIn.monitorSlug) @@ -228,6 +248,160 @@ class SentryCheckInAdviceTest { order.verify(lifecycleToken).close() } + @Test + fun `cron with zone is sent as crontab monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZone() } + assertNotNull(config) + assertEquals("crontab", config.schedule.type) + assertEquals("15 10 * * 1-5", config.schedule.value) + assertNull(config.schedule.unit) + assertEquals("Europe/Vienna", config.timezone) + } + + @Test + fun `cron without zone is sent in the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + val config = inProgressMonitorConfig { sampleServiceScheduled.cron() } + assertEquals("0 2 * * *", config?.schedule?.value) + assertEquals("Asia/Tokyo", config?.timezone) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `cron and zone placeholders are resolved`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronFromProperties() } + assertEquals("30 2 * * *", config?.schedule?.value) + assertEquals("America/New_York", config?.timezone) + } + + @Test + fun `fixed rate with time unit is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + assertNotNull(config) + assertEquals("interval", config.schedule.type) + assertEquals("2", config.schedule.value) + assertEquals("hour", config.schedule.unit) + } + + @Test + fun `ISO-8601 fixed rate string is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateIso() } + assertEquals("10", config?.schedule?.value) + assertEquals("minute", config?.schedule?.unit) + } + + @Test + fun `simple duration fixed rate string is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateSimpleDuration() } + assertEquals("interval", config?.schedule?.type) + assertEquals("5", config?.schedule?.value) + assertEquals("minute", config?.schedule?.unit) + } + + @Test + fun `fixed delay sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelay() }) + assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelayIso() }) + } + + @Test + fun `disabled cron sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.disabledCron() }) + } + + @Test + fun `zone placeholder resolving to empty uses the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithEmptyZone() } + assertEquals("0 3 * * *", config?.schedule?.value) + assertEquals("Asia/Tokyo", config?.timezone) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `cron zone overrides the default timezone from options`() { + val options = + SentryOptions().apply { + cron = SentryOptions.Cron().apply { defaultTimezone = "Europe/Berlin" } + } + whenever(scopes.options).thenReturn(options) + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZoneAndDefaults() } + assertEquals("Europe/Vienna", config?.timezone) + } + + @Test + fun `unresolvable cron placeholder sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.unresolvableCron() }) + } + + @Test + fun `exception while deriving monitor config sends no monitor config and runs the job`() { + var result = 0 + assertNull(inProgressMonitorConfig { result = sampleServiceScheduled.derivationThrows() }) + assertEquals(1, result) + } + + @Test + fun `monitor config is derived once per method`() { + val first = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + val second = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + assertNotNull(first) + assertSame(first, second) + } + + @Test + fun `multiple schedules send no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.multipleSchedules() }) + } + + @Test + fun `schedules container sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.schedulesContainer() }) + } + + @Test + fun `upsertMonitorConfig false sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDisabled() }) + } + + @Test + fun `upsertMonitorConfig defaults to false`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDefault() }) + } + + @Test + fun `@Scheduled on an overridden parent method sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceInheritedSchedule.job() }) + } + + @Test + fun `heartbeat with @Scheduled sends a single check-in without monitor config`() { + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + sampleServiceScheduled.heartbeat() + assertEquals(1, checkInCaptor.allValues.size) + assertEquals(CheckInStatus.OK.apiName(), checkInCaptor.firstValue.status) + assertNull(checkInCaptor.firstValue.monitorConfig) + } + + private fun inProgressMonitorConfig(block: () -> Unit): MonitorConfig? { + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + block() + assertEquals(2, checkInCaptor.allValues.size) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkInCaptor.firstValue.status) + assertNull(checkInCaptor.lastValue.monitorConfig) + return checkInCaptor.firstValue.monitorConfig + } + @Configuration @EnableAspectJAutoProxy(proxyTargetClass = true) @Import(SentryCheckInAdviceConfiguration::class, SentryCheckInPointcutConfiguration::class) @@ -241,6 +415,10 @@ class SentryCheckInAdviceTest { @Bean open fun sampleServiceSpringProperties() = SampleServiceSpringProperties() + @Bean open fun sampleServiceScheduled() = SampleServiceScheduled() + + @Bean open fun sampleServiceInheritedSchedule() = SampleServiceInheritedSchedule() + @Bean open fun scopes(): IScopes { val scopes = mock() @@ -291,6 +469,88 @@ class SentryCheckInAdviceTest { open fun helloExceptionProperty() = 1 } + open class SampleServiceScheduled { + + @SentryCheckIn("cron_zone", upsertMonitorConfig = true) + @Scheduled(cron = "0 15 10 * * MON-FRI", zone = "Europe/Vienna") + open fun cronWithZone() {} + + @SentryCheckIn("cron", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 2 * * *") + open fun cron() {} + + @SentryCheckIn("cron_properties", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}") + open fun cronFromProperties() {} + + @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true) + @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS) + open fun fixedRateHours() {} + + @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true) + @Scheduled(fixedRateString = "PT10M") + open fun fixedRateIso() {} + + @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true) + @Scheduled(fixedRateString = "5m") + open fun fixedRateSimpleDuration() {} + + @SentryCheckIn("fixed_delay", upsertMonitorConfig = true) + @Scheduled(fixedDelay = 300_000) + open fun fixedDelay() {} + + @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true) + @Scheduled(fixedDelayString = "PT10M") + open fun fixedDelayIso() {} + + @SentryCheckIn("disabled_cron", upsertMonitorConfig = true) + @Scheduled(cron = "-") + open fun disabledCron() {} + + @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}") + open fun cronWithEmptyZone() {} + + @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna") + open fun cronWithZoneAndDefaults() {} + + @SentryCheckIn("schedules_container", upsertMonitorConfig = true) + @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *")) + open fun schedulesContainer() {} + + @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.missing}") + open fun unresolvableCron() {} + + @SentryCheckIn("derivation_throws", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.exception.property}") + open fun derivationThrows() = 1 + + @SentryCheckIn("multiple", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + @Scheduled(cron = "0 0 13 * * *") + open fun multipleSchedules() {} + + @SentryCheckIn("upsert_disabled", upsertMonitorConfig = false) + @Scheduled(cron = "0 0 1 * * *") + open fun upsertDisabled() {} + + @SentryCheckIn("upsert_default") @Scheduled(cron = "0 0 1 * * *") open fun upsertDefault() {} + + @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + open fun heartbeat() {} + } + + open class ScheduledParent { + @Scheduled(cron = "0 0 1 * * *") open fun job() {} + } + + open class SampleServiceInheritedSchedule : ScheduledParent() { + @SentryCheckIn("inherited", upsertMonitorConfig = true) override fun job() {} + } + class MyPropertyPlaceholderConfigurer : PropertySourcesPlaceholderConfigurer() { override fun doProcessProperties( diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/checkin/SentryScheduledCheckInTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/checkin/SentryScheduledCheckInTest.kt new file mode 100644 index 00000000000..2e60dbcc556 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/checkin/SentryScheduledCheckInTest.kt @@ -0,0 +1,365 @@ +package io.sentry.spring7.checkin + +import io.sentry.CheckIn +import io.sentry.CheckInStatus +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +import java.lang.reflect.Modifier +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.jupiter.api.assertThrows +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeast +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.springframework.aop.Advisor +import org.springframework.aop.Pointcut +import org.springframework.aop.framework.ProxyFactory +import org.springframework.aop.support.AopUtils +import org.springframework.aop.support.DefaultPointcutAdvisor +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.context.annotation.Import +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import org.springframework.test.context.junit4.SpringRunner +import reactor.core.publisher.Mono + +@RunWith(SpringRunner::class) +@SpringJUnitConfig(SentryScheduledCheckInTest.Config::class) +@TestPropertySource(properties = ["my.cron.schedule = 0 30 2 * * *"]) +class SentryScheduledCheckInTest { + private val SLUG = "sentry-scheduled-check-in-test-jobs-sync-users" + private val COLLISION_WARNING = + "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them." + + @Autowired lateinit var jobs: Jobs + + @Autowired lateinit var excludedJobs: ExcludedJobs + + @Autowired lateinit var finalJobs: FinalJobs + + @Autowired lateinit var overridingJobs: OverridingJobs + + @Autowired lateinit var scopes: IScopes + + @BeforeTest + fun setup() { + reset(scopes) + whenever(scopes.options).thenReturn(SentryOptions()) + whenever(scopes.forkedScopes(any())).thenReturn(scopes) + whenever(scopes.makeCurrent()).thenReturn(mock()) + whenever(scopes.captureCheckIn(any())).thenReturn(SentryId()) + } + + @Test + fun `scheduled method without @SentryCheckIn is monitored with a derived slug and config`() { + val checkIns = capture { jobs.syncUsers() } + assertEquals(2, checkIns.size) + assertEquals(SLUG, checkIns[0].monitorSlug) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkIns[0].status) + val config = assertNotNull(checkIns[0].monitorConfig) + assertEquals("0 2 * * *", config.schedule.value) + assertEquals("Europe/Vienna", config.timezone) + assertEquals(SLUG, checkIns[1].monitorSlug) + assertEquals(CheckInStatus.OK.apiName(), checkIns[1].status) + } + + @Test + fun `cron placeholder is resolved`() { + val checkIns = capture { jobs.fromProperty() } + assertEquals("30 2 * * *", checkIns[0].monitorConfig!!.schedule.value) + } + + @Test + fun `tasks with unsupported schedules are not monitored`() { + jobs.fixedDelay() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `@SentryCheckIn with an unsupported schedule checks in without config`() { + val checkIns = capture { jobs.explicitFixedDelay() } + assertEquals(listOf("explicit-delay", "explicit-delay"), checkIns.map { it.monitorSlug }) + assertNull(checkIns[0].monitorConfig) + } + + @Test + fun `failing job sends an error check-in and rethrows`() { + val checkIns = capture { assertThrows { jobs.oops() } } + assertEquals(CheckInStatus.ERROR.apiName(), checkIns[1].status) + } + + @Test + fun `@SentryCheckIn keeps its slug and is only checked in once`() { + val checkIns = capture { jobs.explicit() } + assertEquals(listOf("explicit", "explicit"), checkIns.map { it.monitorSlug }) + assertNull(checkIns[0].monitorConfig) + } + + @Test + fun `methods without @Scheduled are not monitored`() { + jobs.notScheduled() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `overriding methods without @Scheduled are not monitored`() { + overridingJobs.run() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `reactive methods are not monitored`() { + jobs.reactive().block() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `excluded tasks are not monitored`() { + jobs.excluded() + excludedJobs.run() + verify(scopes, never()).captureCheckIn(any()) + assertFalse(AopUtils.isAopProxy(excludedJobs)) + } + + @Test + fun `final classes are not proxied`() { + finalJobs.run() + verify(scopes, never()).captureCheckIn(any()) + assertFalse(AopUtils.isAopProxy(finalJobs)) + assertTrue(AopUtils.isAopProxy(jobs)) + } + + @Test + fun `private and static methods are not matched`() { + val pointcut = SentryScheduledCheckInPointcut(null) + val privateMethod = HiddenJobs::class.java.getDeclaredMethod("hidden") + val staticMethod = HiddenJobs::class.java.getDeclaredMethod("static") + assertTrue(Modifier.isPrivate(privateMethod.modifiers)) + assertTrue(Modifier.isStatic(staticMethod.modifiers)) + assertFalse(pointcut.matches(privateMethod, HiddenJobs::class.java)) + assertFalse(pointcut.matches(staticMethod, HiddenJobs::class.java)) + assertTrue(pointcut.matches(Jobs::class.java.getMethod("syncUsers"), Jobs::class.java)) + } + + @Test + fun `slugs are kebab case and at most 50 characters`() { + assertEquals( + "report-jobs-send-daily-report", + SentryCheckInAdvice.scheduledTaskSlug("ReportJobs.sendDailyReport"), + ) + assertEquals( + "http-client-jobs-run", + SentryCheckInAdvice.scheduledTaskSlug("HTTPClientJobs.run"), + ) + assertEquals("outer-inner-run2-fa", SentryCheckInAdvice.scheduledTaskSlug("Outer.Inner.run2FA")) + val first = + SentryCheckInAdvice.scheduledTaskSlug( + "AVeryLongScheduledJobClassNameForReporting.firstMethod" + ) + val second = + SentryCheckInAdvice.scheduledTaskSlug( + "AVeryLongScheduledJobClassNameForReporting.secondMethod" + ) + assertTrue(first.length <= 50) + assertTrue(first.startsWith("a-very-long-scheduled-job-class-name-for-")) + assertTrue(first != second) + assertEquals( + first, + SentryCheckInAdvice.scheduledTaskSlug( + "AVeryLongScheduledJobClassNameForReporting.firstMethod" + ), + ) + } + + @Test + fun `task name uses the user class`() { + assertEquals( + "SentryScheduledCheckInTest.Jobs.syncUsers", + SentryCheckInAdvice.scheduledTaskName( + jobs.javaClass, + Jobs::class.java.getMethod("syncUsers"), + ), + ) + } + + @Test + fun `warns once when two methods derive the same slug`() { + val logger = mockLogger() + val advice = SentryCheckInAdvice(scopes, true) + val cleanupJob = proxy(CleanupJob(), advice) + val cleanup = proxy(Cleanup(), advice) + + cleanupJob.run() + cleanup.jobRun() + cleanup.jobRun() + cleanupJob.run() + + verify(logger, times(1)) + .log( + eq(SentryLevel.WARNING), + eq(COLLISION_WARNING), + eq("sentry-scheduled-check-in-test-cleanup-job-run"), + eq("${CleanupJob::class.java.name}.run"), + eq("${Cleanup::class.java.name}.jobRun"), + ) + } + + @Test + fun `does not warn when derived slugs differ or the same method runs again`() { + val logger = mockLogger() + val advice = SentryCheckInAdvice(scopes, true) + val cleanupJob = proxy(CleanupJob(), advice) + val otherJobs = proxy(Jobs(), advice) + + cleanupJob.run() + cleanupJob.run() + otherJobs.syncUsers() + + verify(logger, never()).log(eq(SentryLevel.WARNING), eq(COLLISION_WARNING), any(), any(), any()) + } + + private fun mockLogger(): ILogger { + val logger = mock() + val options = + SentryOptions().apply { + isDebug = true + setLogger(logger) + } + whenever(scopes.options).thenReturn(options) + return logger + } + + private fun proxy(target: T, advice: SentryCheckInAdvice): T { + val factory = ProxyFactory(target) + factory.isProxyTargetClass = true + factory.addAdvice(advice) + @Suppress("UNCHECKED_CAST") + return factory.proxy as T + } + + private fun capture(block: () -> Unit): List { + val captor = argumentCaptor() + block() + verify(scopes, atLeast(0)).captureCheckIn(captor.capture()) + return captor.allValues + } + + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(SentryCheckInPointcutConfiguration::class) + open class Config { + + @Bean open fun jobs() = Jobs() + + @Bean open fun excludedJobs() = ExcludedJobs() + + @Bean open fun finalJobs() = FinalJobs() + + @Bean open fun overridingJobs() = OverridingJobs() + + @Bean open fun scopes(): IScopes = mock() + + @Bean + open fun sentryCheckInAdvisor( + @Qualifier("sentryCheckInPointcut") pointcut: Pointcut, + scopes: IScopes, + ): Advisor = DefaultPointcutAdvisor(pointcut, SentryCheckInAdvice(scopes)) + + @Bean open fun sentryScheduledCheckInAdvice(scopes: IScopes) = SentryCheckInAdvice(scopes, true) + + @Bean + open fun sentryScheduledCheckInAdvisor( + @Qualifier("sentryScheduledCheckInAdvice") advice: SentryCheckInAdvice + ): Advisor = + DefaultPointcutAdvisor( + SentryScheduledCheckInPointcut( + listOf("SentryScheduledCheckInTest.Jobs.excluded", ".*\\.ExcludedJobs\\..*", " ") + ), + advice, + ) + + companion object { + @Bean + @JvmStatic + fun propertySourcesPlaceholderConfigurer() = PropertySourcesPlaceholderConfigurer() + } + } + + open class Jobs { + @Scheduled(cron = "0 0 2 * * *", zone = "Europe/Vienna") open fun syncUsers() {} + + @Scheduled(cron = "\${my.cron.schedule}") open fun fromProperty() {} + + @Scheduled(fixedDelay = 1000) open fun fixedDelay() {} + + @Scheduled(cron = "0 0 2 * * *") + open fun oops() { + throw IllegalStateException("thrown on purpose") + } + + @SentryCheckIn("explicit") @Scheduled(cron = "0 0 2 * * *") open fun explicit() {} + + @SentryCheckIn("explicit-delay") @Scheduled(fixedDelay = 1000) open fun explicitFixedDelay() {} + + @Scheduled(cron = "0 0 2 * * *") open fun excluded() {} + + open fun notScheduled() {} + + @Scheduled(cron = "0 0 2 * * *") open fun reactive(): Mono = Mono.empty() + } + + open class CleanupJob { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + open class Cleanup { + @Scheduled(cron = "0 0 2 * * *") open fun jobRun() {} + } + + open class HiddenJobs { + @Scheduled(cron = "0 0 2 * * *") private fun hidden() {} + + companion object { + @JvmStatic @Scheduled(cron = "0 0 2 * * *") fun static() {} + } + } + + open class ExcludedJobs { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + class FinalJobs { + @Scheduled(cron = "0 0 2 * * *") fun run() {} + } + + open class ParentJobs { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + open class OverridingJobs : ParentJobs() { + override fun run() {} + } +} diff --git a/sentry-spring-boot-4-starter/build.gradle.kts b/sentry-spring-boot-4-starter/build.gradle.kts index fca672dac9d..c3c5a4d7b0a 100644 --- a/sentry-spring-boot-4-starter/build.gradle.kts +++ b/sentry-spring-boot-4-starter/build.gradle.kts @@ -9,6 +9,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.springboot4) apply false + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-spring-boot-4/api/sentry-spring-boot-4.api b/sentry-spring-boot-4/api/sentry-spring-boot-4.api index cd41459c31a..247b12483aa 100644 --- a/sentry-spring-boot-4/api/sentry-spring-boot-4.api +++ b/sentry-spring-boot-4/api/sentry-spring-boot-4.api @@ -43,19 +43,23 @@ public class io/sentry/spring/boot4/SentryProperties : io/sentry/SentryOptions { public fun ()V public fun getClients ()Lio/sentry/spring/boot4/SentryProperties$Clients; public fun getExceptionResolverOrder ()I + public fun getExcludeScheduledTasks ()Ljava/util/List; public fun getGraphql ()Lio/sentry/spring/boot4/SentryProperties$Graphql; public fun getLogging ()Lio/sentry/spring/boot4/SentryProperties$Logging; public fun getReactive ()Lio/sentry/spring/boot4/SentryProperties$Reactive; public fun getUserFilterOrder ()Ljava/lang/Integer; public fun isEnableAotCompatibility ()Z public fun isKeepTransactionsOpenForAsyncResponses ()Z + public fun isMonitorScheduledTasks ()Z public fun isUseGitCommitIdAsRelease ()Z public fun setClients (Lio/sentry/spring/boot4/SentryProperties$Clients;)V public fun setEnableAotCompatibility (Z)V public fun setExceptionResolverOrder (I)V + public fun setExcludeScheduledTasks (Ljava/util/List;)V public fun setGraphql (Lio/sentry/spring/boot4/SentryProperties$Graphql;)V public fun setKeepTransactionsOpenForAsyncResponses (Z)V public fun setLogging (Lio/sentry/spring/boot4/SentryProperties$Logging;)V + public fun setMonitorScheduledTasks (Z)V public fun setReactive (Lio/sentry/spring/boot4/SentryProperties$Reactive;)V public fun setUseGitCommitIdAsRelease (Z)V public fun setUserFilterOrder (Ljava/lang/Integer;)V @@ -70,6 +74,8 @@ public class io/sentry/spring/boot4/SentryProperties$Clients { public class io/sentry/spring/boot4/SentryProperties$Graphql { public fun ()V public fun getIgnoredErrorTypes ()Ljava/util/List; + public fun isEnableSpecificationErrorTypeFiltering ()Z + public fun setEnableSpecificationErrorTypeFiltering (Z)V public fun setIgnoredErrorTypes (Ljava/util/List;)V } diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 26524b4f81e..5629fdd5aa4 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -13,6 +13,9 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) alias(libs.plugins.springboot4) apply false + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } @@ -83,7 +86,7 @@ dependencies { testImplementation(projects.sentryReactor) testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(Config.Libs.kotlinReflect) + testImplementation(libs.kotlin.reflect) testImplementation(platform(SpringBootPlugin.BOM_COORDINATES)) testImplementation(libs.apache.httpclient) testImplementation(libs.context.propagation) diff --git a/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/AgentApp.kt b/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/AgentApp.kt index 1ee54ed9795..24496ee290a 100644 --- a/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/AgentApp.kt +++ b/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/AgentApp.kt @@ -41,6 +41,8 @@ object AgentApp { "SENTRY_PROFILE_LIFECYCLE" to "TRACE", "SENTRY_PROFILING_TRACES_DIR_PATH" to "${System.getProperty("java.io.tmpdir")}/sentry-integration-test/profiling-traces", + // Tests wait for Sentry's span batches, which are 5 seconds apart by default. + "OTEL_BSP_SCHEDULE_DELAY" to "100ms", "OTEL_TRACES_EXPORTER" to "none", "OTEL_METRICS_EXPORTER" to "none", "OTEL_LOGS_EXPORTER" to "none", diff --git a/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/DistributedTracingIntegrationTest.kt index 353e6d62fd8..ad7025146b6 100644 --- a/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/DistributedTracingIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/DistributedTracingIntegrationTest.kt @@ -47,8 +47,8 @@ class DistributedTracingIntegrationTest : IntegrationTest() { ) assertThat(status).isEqualTo(500) - // Longer than the 5 seconds the app's spans can wait for their batch to be exported. - server.assertNoEnvelope(millis = 6_000) { isForRequest(it, id) } + // Ten times the app's batch delay, so the request's spans would have been exported. + server.assertNoEnvelope(millis = 1_000) { isForRequest(it, id) } } @Test diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java index 0cfcb5e52a7..4c14b0ea858 100644 --- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java @@ -26,9 +26,11 @@ import io.sentry.spring7.SpringProfilesEventProcessor; import io.sentry.spring7.SpringSecuritySentryUserProvider; import io.sentry.spring7.cache.SentryCacheBeanPostProcessor; +import io.sentry.spring7.checkin.SentryCheckInAdvice; import io.sentry.spring7.checkin.SentryCheckInAdviceConfiguration; import io.sentry.spring7.checkin.SentryCheckInPointcutConfiguration; import io.sentry.spring7.checkin.SentryQuartzConfiguration; +import io.sentry.spring7.checkin.SentryScheduledCheckInPointcut; import io.sentry.spring7.exception.SentryCaptureExceptionParameterPointcutConfiguration; import io.sentry.spring7.exception.SentryExceptionParameterAdviceConfiguration; import io.sentry.spring7.kafka.SentryKafkaConsumerBeanPostProcessor; @@ -48,11 +50,16 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; +import org.aopalliance.aop.Advice; import org.aspectj.lang.ProceedingJoinPoint; import org.jetbrains.annotations.NotNull; import org.quartz.core.QuartzScheduler; import org.slf4j.MDC; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.DefaultPointcutAdvisor; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -72,6 +79,7 @@ import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Role; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.env.Environment; @@ -306,6 +314,27 @@ static class SentryCheckInAspectsConfiguration { @Import(SentryCheckInPointcutConfiguration.class) @Open static class SentryCheckInPointcutAutoConfiguration {} + + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(name = "sentry.monitor-scheduled-tasks", havingValue = "true") + @Open + static class SentryScheduledCheckInConfiguration { + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advice sentryScheduledCheckInAdvice() { + return new SentryCheckInAdvice(ScopesAdapter.getInstance(), true); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advisor sentryScheduledCheckInAdvisor( + final @NotNull @Qualifier("sentryScheduledCheckInAdvice") Advice advice, + final @NotNull SentryProperties sentryProperties) { + return new DefaultPointcutAdvisor( + new SentryScheduledCheckInPointcut(sentryProperties.getExcludeScheduledTasks()), + advice); + } + } } /** Registers beans specific to Spring MVC. */ diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java index 6c502976b15..b3dffa198e5 100644 --- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java @@ -50,6 +50,15 @@ public class SentryProperties extends SentryOptions { /** Clients integration properties. */ private @NotNull Clients clients = new Clients(); + /** Monitor every {@code @Scheduled} method with Sentry Crons. */ + private boolean monitorScheduledTasks = false; + + /** + * {@code @Scheduled} methods not to monitor when {@code monitorScheduledTasks} is on, as {@code + * ClassName.methodName} or a regex matching it. + */ + private @NotNull List excludeScheduledTasks = new ArrayList<>(); + public boolean isUseGitCommitIdAsRelease() { return useGitCommitIdAsRelease; } @@ -135,6 +144,22 @@ public void setClients(@NotNull Clients clients) { this.clients = clients; } + public boolean isMonitorScheduledTasks() { + return monitorScheduledTasks; + } + + public void setMonitorScheduledTasks(boolean monitorScheduledTasks) { + this.monitorScheduledTasks = monitorScheduledTasks; + } + + public @NotNull List getExcludeScheduledTasks() { + return excludeScheduledTasks; + } + + public void setExcludeScheduledTasks(@NotNull List excludeScheduledTasks) { + this.excludeScheduledTasks = excludeScheduledTasks; + } + @Open public static class Logging { /** Enable/Disable logging auto-configuration. */ @@ -228,6 +253,9 @@ public static class Graphql { /** List of error types the Sentry Graphql integration should ignore. */ private @NotNull List ignoredErrorTypes = new ArrayList<>(); + /** Whether ignored error types should be matched against classification specifications. */ + private boolean enableSpecificationErrorTypeFiltering = false; + @NotNull public List getIgnoredErrorTypes() { return ignoredErrorTypes; @@ -236,6 +264,15 @@ public List getIgnoredErrorTypes() { public void setIgnoredErrorTypes(final @NotNull List ignoredErrorTypes) { this.ignoredErrorTypes = ignoredErrorTypes; } + + public boolean isEnableSpecificationErrorTypeFiltering() { + return enableSpecificationErrorTypeFiltering; + } + + public void setEnableSpecificationErrorTypeFiltering( + final boolean enableSpecificationErrorTypeFiltering) { + this.enableSpecificationErrorTypeFiltering = enableSpecificationErrorTypeFiltering; + } } @Open diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphql22AutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphql22AutoConfiguration.java index e83b6241847..f1a9c74ebb0 100644 --- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphql22AutoConfiguration.java +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphql22AutoConfiguration.java @@ -56,7 +56,8 @@ private SentryInstrumentation createInstrumentation( beforeSpanCallback.getIfAvailable(), new SentrySpringSubscriptionHandler(), captureRequestBody, - sentryProperties.getGraphql().getIgnoredErrorTypes()); + sentryProperties.getGraphql().getIgnoredErrorTypes(), + sentryProperties.getGraphql().isEnableSpecificationErrorTypeFiltering()); } @Bean diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphqlAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphqlAutoConfiguration.java index 7bcef3c7b4d..7565b48a25c 100644 --- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphqlAutoConfiguration.java +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphqlAutoConfiguration.java @@ -56,7 +56,8 @@ private SentryInstrumentation createInstrumentation( beforeSpanCallback.getIfAvailable(), new SentrySpringSubscriptionHandler(), captureRequestBody, - sentryProperties.getGraphql().getIgnoredErrorTypes()); + sentryProperties.getGraphql().getIgnoredErrorTypes(), + sentryProperties.getGraphql().isEnableSpecificationErrorTypeFiltering()); } @Bean diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt index 7f15669b104..7360e732da7 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt @@ -16,6 +16,7 @@ import io.sentry.NoOpProfileConverter import io.sentry.NoOpTransportFactory import io.sentry.SamplingContext import io.sentry.Sentry +import io.sentry.SentryEnvelope import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel @@ -45,6 +46,7 @@ import io.sentry.transport.ITransport import io.sentry.transport.ITransportGate import jakarta.servlet.Filter import java.lang.RuntimeException +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -52,9 +54,12 @@ import kotlin.test.assertTrue import org.aspectj.lang.ProceedingJoinPoint import org.assertj.core.api.Assertions.assertThat import org.mockito.internal.util.MockUtil.isMock +import org.mockito.kotlin.after import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock +import org.mockito.kotlin.timeout import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.quartz.JobExecutionContext @@ -65,6 +70,7 @@ import org.quartz.core.QuartzScheduler import org.slf4j.MDC import org.springframework.aop.support.NameMatchMethodPointcut import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration import org.springframework.boot.context.annotation.UserConfigurations import org.springframework.boot.info.GitProperties import org.springframework.boot.quartz.autoconfigure.QuartzAutoConfiguration @@ -79,6 +85,8 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.Ordered import org.springframework.core.annotation.Order +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.quartz.SchedulerFactoryBean import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.client.RestClient @@ -192,6 +200,33 @@ class SentryAutoConfigurationTest { } } + @Test + fun `GraphQL specification error type filtering is disabled by default`() { + dsnEnabledRunner.run { + assertThat( + it.getBean(SentryProperties::class.java).graphql.isEnableSpecificationErrorTypeFiltering + ) + .isFalse() + } + } + + @Test + fun `GraphQL specification error type filtering property is applied`() { + listOf(false, true).forEach { enabled -> + dsnEnabledRunner + .withPropertyValues("sentry.graphql.enable-specification-error-type-filtering=$enabled") + .run { + assertThat( + it + .getBean(SentryProperties::class.java) + .graphql + .isEnableSpecificationErrorTypeFiltering + ) + .isEqualTo(enabled) + } + } + } + @Test fun `properties are applied to SentryOptions`() { contextRunner @@ -1033,6 +1068,44 @@ class SentryAutoConfigurationTest { } } + @Test + fun `does not monitor @Scheduled methods by default`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).doesNotHaveBean("sentryScheduledCheckInAdvisor") + } + } + + @Test + fun `monitors @Scheduled methods when monitor-scheduled-tasks is enabled`() { + baseContextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.monitor-scheduled-tasks=true", + "sentry.exclude-scheduled-tasks[0]=SentryAutoConfigurationTest.ScheduledJobs.excluded", + ) + .withConfiguration(AutoConfigurations.of(AopAutoConfiguration::class.java)) + .withUserConfiguration( + MockTransportConfiguration::class.java, + ScheduledJobsConfiguration::class.java, + ) + .run { + assertThat(it).hasBean("sentryScheduledCheckInAdvisor") + val transport = it.getBean(ITransport::class.java) + val captor = argumentCaptor() + verify(transport, timeout(5000).times(2)).send(any(), anyOrNull()) + verify(transport, after(500).times(2)).send(captor.capture(), anyOrNull()) + val payloads = + captor.allValues.map { envelope -> String(envelope.items.first().data, Charsets.UTF_8) } + assertThat(payloads).allMatch { payload -> + payload.contains( + "\"monitor_slug\":\"sentry-auto-configuration-test-scheduled-jobs-sync\"" + ) + } + assertThat(payloads[0]).contains("\"value\":1,\"unit\":\"hour\"") + assertThat(payloads[1]).contains("\"status\":\"ok\"") + } + } + @Test fun `Sentry quartz job listener is added`() { contextRunner @@ -1279,6 +1352,18 @@ class SentryAutoConfigurationTest { @Bean open fun sentryTransport() = transport } + @Configuration(proxyBeanMethods = false) + @EnableScheduling + open class ScheduledJobsConfiguration { + @Bean open fun scheduledJobs() = ScheduledJobs() + } + + open class ScheduledJobs { + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun sync() {} + + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun excluded() {} + } + @Configuration(proxyBeanMethods = false) open class NoOpTransportConfiguration { diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt index b5ead41901d..8ff176c7755 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt @@ -89,8 +89,9 @@ class SentryLog4j2AppenderAutoConfigurationTest { private val originalLogManagerFactory = LogManager.getFactory() - private val loggerContext: LoggerContext - get() = LogManager.getContext(false) as LoggerContext + // Log4j2 holds LoggerContexts weakly, so a GC during context startup would replace this one + // (and drop appenders added by a test) unless the test keeps it reachable. + private lateinit var loggerContext: LoggerContext private val configuration get() = loggerContext.configuration @@ -102,6 +103,7 @@ class SentryLog4j2AppenderAutoConfigurationTest { fun `reset Log4j2 context`() { useLog4j2Core() resetLog4j2Context() + loggerContext = LogManager.getContext(false) as LoggerContext } @AfterTest diff --git a/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api b/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api index 88c87172928..c9505f9966c 100644 --- a/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api +++ b/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api @@ -43,19 +43,23 @@ public class io/sentry/spring/boot/jakarta/SentryProperties : io/sentry/SentryOp public fun ()V public fun getClients ()Lio/sentry/spring/boot/jakarta/SentryProperties$Clients; public fun getExceptionResolverOrder ()I + public fun getExcludeScheduledTasks ()Ljava/util/List; public fun getGraphql ()Lio/sentry/spring/boot/jakarta/SentryProperties$Graphql; public fun getLogging ()Lio/sentry/spring/boot/jakarta/SentryProperties$Logging; public fun getReactive ()Lio/sentry/spring/boot/jakarta/SentryProperties$Reactive; public fun getUserFilterOrder ()Ljava/lang/Integer; public fun isEnableAotCompatibility ()Z public fun isKeepTransactionsOpenForAsyncResponses ()Z + public fun isMonitorScheduledTasks ()Z public fun isUseGitCommitIdAsRelease ()Z public fun setClients (Lio/sentry/spring/boot/jakarta/SentryProperties$Clients;)V public fun setEnableAotCompatibility (Z)V public fun setExceptionResolverOrder (I)V + public fun setExcludeScheduledTasks (Ljava/util/List;)V public fun setGraphql (Lio/sentry/spring/boot/jakarta/SentryProperties$Graphql;)V public fun setKeepTransactionsOpenForAsyncResponses (Z)V public fun setLogging (Lio/sentry/spring/boot/jakarta/SentryProperties$Logging;)V + public fun setMonitorScheduledTasks (Z)V public fun setReactive (Lio/sentry/spring/boot/jakarta/SentryProperties$Reactive;)V public fun setUseGitCommitIdAsRelease (Z)V public fun setUserFilterOrder (Ljava/lang/Integer;)V @@ -70,6 +74,8 @@ public class io/sentry/spring/boot/jakarta/SentryProperties$Clients { public class io/sentry/spring/boot/jakarta/SentryProperties$Graphql { public fun ()V public fun getIgnoredErrorTypes ()Ljava/util/List; + public fun isEnableSpecificationErrorTypeFiltering ()Z + public fun setEnableSpecificationErrorTypeFiltering (Z)V public fun setIgnoredErrorTypes (Ljava/util/List;)V } diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 4fd584e8bb5..b678bbd709a 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -12,6 +12,9 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) alias(libs.plugins.springboot3) apply false + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt index c70f8df301a..6864d4d55c9 100644 --- a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt @@ -41,6 +41,8 @@ object AgentApp { "SENTRY_PROFILE_LIFECYCLE" to "TRACE", "SENTRY_PROFILING_TRACES_DIR_PATH" to "${System.getProperty("java.io.tmpdir")}/sentry-integration-test/profiling-traces", + // Tests wait for Sentry's span batches, which are 5 seconds apart by default. + "OTEL_BSP_SCHEDULE_DELAY" to "100ms", "OTEL_TRACES_EXPORTER" to "none", "OTEL_METRICS_EXPORTER" to "none", "OTEL_LOGS_EXPORTER" to "none", diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/DistributedTracingIntegrationTest.kt index e83dc383dc3..309fa9b820b 100644 --- a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/DistributedTracingIntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/DistributedTracingIntegrationTest.kt @@ -47,8 +47,8 @@ class DistributedTracingIntegrationTest : IntegrationTest() { ) assertThat(status).isEqualTo(500) - // Longer than the 5 seconds the app's spans can wait for their batch to be exported. - server.assertNoEnvelope(millis = 6_000) { isForRequest(it, id) } + // Ten times the app's batch delay, so the request's spans would have been exported. + server.assertNoEnvelope(millis = 1_000) { isForRequest(it, id) } } @Test diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java index 7a947a6ad55..ea1ed76c31d 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java @@ -26,9 +26,11 @@ import io.sentry.spring.jakarta.SpringProfilesEventProcessor; import io.sentry.spring.jakarta.SpringSecuritySentryUserProvider; import io.sentry.spring.jakarta.cache.SentryCacheBeanPostProcessor; +import io.sentry.spring.jakarta.checkin.SentryCheckInAdvice; import io.sentry.spring.jakarta.checkin.SentryCheckInAdviceConfiguration; import io.sentry.spring.jakarta.checkin.SentryCheckInPointcutConfiguration; import io.sentry.spring.jakarta.checkin.SentryQuartzConfiguration; +import io.sentry.spring.jakarta.checkin.SentryScheduledCheckInPointcut; import io.sentry.spring.jakarta.exception.SentryCaptureExceptionParameterPointcutConfiguration; import io.sentry.spring.jakarta.exception.SentryExceptionParameterAdviceConfiguration; import io.sentry.spring.jakarta.kafka.SentryKafkaConsumerBeanPostProcessor; @@ -48,11 +50,16 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; +import org.aopalliance.aop.Advice; import org.aspectj.lang.ProceedingJoinPoint; import org.jetbrains.annotations.NotNull; import org.quartz.core.QuartzScheduler; import org.slf4j.MDC; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.DefaultPointcutAdvisor; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -72,6 +79,7 @@ import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Role; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.env.Environment; @@ -308,6 +316,27 @@ static class SentryCheckInAspectsConfiguration { @Import(SentryCheckInPointcutConfiguration.class) @Open static class SentryCheckInPointcutAutoConfiguration {} + + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(name = "sentry.monitor-scheduled-tasks", havingValue = "true") + @Open + static class SentryScheduledCheckInConfiguration { + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advice sentryScheduledCheckInAdvice() { + return new SentryCheckInAdvice(ScopesAdapter.getInstance(), true); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advisor sentryScheduledCheckInAdvisor( + final @NotNull @Qualifier("sentryScheduledCheckInAdvice") Advice advice, + final @NotNull SentryProperties sentryProperties) { + return new DefaultPointcutAdvisor( + new SentryScheduledCheckInPointcut(sentryProperties.getExcludeScheduledTasks()), + advice); + } + } } /** Registers beans specific to Spring MVC. */ diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java index 53e9226f21f..b8cef42071a 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java @@ -50,6 +50,15 @@ public class SentryProperties extends SentryOptions { /** Clients integration properties. */ private @NotNull Clients clients = new Clients(); + /** Monitor every {@code @Scheduled} method with Sentry Crons. */ + private boolean monitorScheduledTasks = false; + + /** + * {@code @Scheduled} methods not to monitor when {@code monitorScheduledTasks} is on, as {@code + * ClassName.methodName} or a regex matching it. + */ + private @NotNull List excludeScheduledTasks = new ArrayList<>(); + public boolean isUseGitCommitIdAsRelease() { return useGitCommitIdAsRelease; } @@ -135,6 +144,22 @@ public void setClients(@NotNull Clients clients) { this.clients = clients; } + public boolean isMonitorScheduledTasks() { + return monitorScheduledTasks; + } + + public void setMonitorScheduledTasks(boolean monitorScheduledTasks) { + this.monitorScheduledTasks = monitorScheduledTasks; + } + + public @NotNull List getExcludeScheduledTasks() { + return excludeScheduledTasks; + } + + public void setExcludeScheduledTasks(@NotNull List excludeScheduledTasks) { + this.excludeScheduledTasks = excludeScheduledTasks; + } + @Open public static class Clients { /** Enable automatic instrumentation of Spring-managed OkHttp clients. Disabled by default. */ @@ -242,6 +267,9 @@ public static class Graphql { /** List of error types the Sentry Graphql integration should ignore. */ private @NotNull List ignoredErrorTypes = new ArrayList<>(); + /** Whether ignored error types should be matched against classification specifications. */ + private boolean enableSpecificationErrorTypeFiltering = false; + @NotNull public List getIgnoredErrorTypes() { return ignoredErrorTypes; @@ -250,5 +278,14 @@ public List getIgnoredErrorTypes() { public void setIgnoredErrorTypes(final @NotNull List ignoredErrorTypes) { this.ignoredErrorTypes = ignoredErrorTypes; } + + public boolean isEnableSpecificationErrorTypeFiltering() { + return enableSpecificationErrorTypeFiltering; + } + + public void setEnableSpecificationErrorTypeFiltering( + final boolean enableSpecificationErrorTypeFiltering) { + this.enableSpecificationErrorTypeFiltering = enableSpecificationErrorTypeFiltering; + } } } diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphql22AutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphql22AutoConfiguration.java index aa741892848..defbc59f5ae 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphql22AutoConfiguration.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphql22AutoConfiguration.java @@ -56,7 +56,8 @@ private SentryInstrumentation createInstrumentation( beforeSpanCallback.getIfAvailable(), new SentrySpringSubscriptionHandler(), captureRequestBody, - sentryProperties.getGraphql().getIgnoredErrorTypes()); + sentryProperties.getGraphql().getIgnoredErrorTypes(), + sentryProperties.getGraphql().isEnableSpecificationErrorTypeFiltering()); } @Bean diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphqlAutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphqlAutoConfiguration.java index e5c1da2df76..7c6c14f8d3f 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphqlAutoConfiguration.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphqlAutoConfiguration.java @@ -56,7 +56,8 @@ private SentryInstrumentation createInstrumentation( beforeSpanCallback.getIfAvailable(), new SentrySpringSubscriptionHandler(), captureRequestBody, - sentryProperties.getGraphql().getIgnoredErrorTypes()); + sentryProperties.getGraphql().getIgnoredErrorTypes(), + sentryProperties.getGraphql().isEnableSpecificationErrorTypeFiltering()); } @Bean diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt index 1b46809b3f3..fc2dd676ec3 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt @@ -18,6 +18,7 @@ import io.sentry.NoOpTransportFactory import io.sentry.ProfileLifecycle import io.sentry.SamplingContext import io.sentry.Sentry +import io.sentry.SentryEnvelope import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel @@ -48,6 +49,7 @@ import io.sentry.transport.ITransportGate import jakarta.servlet.Filter import java.io.File import java.lang.RuntimeException +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -55,9 +57,12 @@ import kotlin.test.assertTrue import org.aspectj.lang.ProceedingJoinPoint import org.assertj.core.api.Assertions.assertThat import org.mockito.internal.util.MockUtil.isMock +import org.mockito.kotlin.after import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock +import org.mockito.kotlin.timeout import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.quartz.JobExecutionContext @@ -68,6 +73,7 @@ import org.quartz.core.QuartzScheduler import org.slf4j.MDC import org.springframework.aop.support.NameMatchMethodPointcut import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration import org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration @@ -82,6 +88,8 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.Ordered import org.springframework.core.annotation.Order +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.quartz.SchedulerFactoryBean import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.client.RestClient @@ -195,6 +203,33 @@ class SentryAutoConfigurationTest { } } + @Test + fun `GraphQL specification error type filtering is disabled by default`() { + dsnEnabledRunner.run { + assertThat( + it.getBean(SentryProperties::class.java).graphql.isEnableSpecificationErrorTypeFiltering + ) + .isFalse() + } + } + + @Test + fun `GraphQL specification error type filtering property is applied`() { + listOf(false, true).forEach { enabled -> + dsnEnabledRunner + .withPropertyValues("sentry.graphql.enable-specification-error-type-filtering=$enabled") + .run { + assertThat( + it + .getBean(SentryProperties::class.java) + .graphql + .isEnableSpecificationErrorTypeFiltering + ) + .isEqualTo(enabled) + } + } + } + @Test fun `properties are applied to SentryOptions`() { contextRunner @@ -1052,6 +1087,44 @@ class SentryAutoConfigurationTest { } } + @Test + fun `does not monitor @Scheduled methods by default`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).doesNotHaveBean("sentryScheduledCheckInAdvisor") + } + } + + @Test + fun `monitors @Scheduled methods when monitor-scheduled-tasks is enabled`() { + baseContextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.monitor-scheduled-tasks=true", + "sentry.exclude-scheduled-tasks[0]=SentryAutoConfigurationTest.ScheduledJobs.excluded", + ) + .withConfiguration(AutoConfigurations.of(AopAutoConfiguration::class.java)) + .withUserConfiguration( + MockTransportConfiguration::class.java, + ScheduledJobsConfiguration::class.java, + ) + .run { + assertThat(it).hasBean("sentryScheduledCheckInAdvisor") + val transport = it.getBean(ITransport::class.java) + val captor = argumentCaptor() + verify(transport, timeout(5000).times(2)).send(any(), anyOrNull()) + verify(transport, after(500).times(2)).send(captor.capture(), anyOrNull()) + val payloads = + captor.allValues.map { envelope -> String(envelope.items.first().data, Charsets.UTF_8) } + assertThat(payloads).allMatch { payload -> + payload.contains( + "\"monitor_slug\":\"sentry-auto-configuration-test-scheduled-jobs-sync\"" + ) + } + assertThat(payloads[0]).contains("\"value\":1,\"unit\":\"hour\"") + assertThat(payloads[1]).contains("\"status\":\"ok\"") + } + } + @Test fun `Sentry quartz job listener is added`() { contextRunner @@ -1271,6 +1344,18 @@ class SentryAutoConfigurationTest { @Bean open fun sentryTransport() = transport } + @Configuration(proxyBeanMethods = false) + @EnableScheduling + open class ScheduledJobsConfiguration { + @Bean open fun scheduledJobs() = ScheduledJobs() + } + + open class ScheduledJobs { + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun sync() {} + + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun excluded() {} + } + @Configuration(proxyBeanMethods = false) open class NoOpTransportConfiguration { diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfigurationTest.kt index f9a0c1f24c0..4523b09e5c2 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfigurationTest.kt @@ -89,8 +89,9 @@ class SentryLog4j2AppenderAutoConfigurationTest { private val originalLogManagerFactory = LogManager.getFactory() - private val loggerContext: LoggerContext - get() = LogManager.getContext(false) as LoggerContext + // Log4j2 holds LoggerContexts weakly, so a GC during context startup would replace this one + // (and drop appenders added by a test) unless the test keeps it reachable. + private lateinit var loggerContext: LoggerContext private val configuration get() = loggerContext.configuration @@ -102,6 +103,7 @@ class SentryLog4j2AppenderAutoConfigurationTest { fun `reset Log4j2 context`() { useLog4j2Core() resetLog4j2Context() + loggerContext = LogManager.getContext(false) as LoggerContext } @AfterTest diff --git a/sentry-spring-boot-starter-jakarta/build.gradle.kts b/sentry-spring-boot-starter-jakarta/build.gradle.kts index 92cc97b240c..61291a611d4 100644 --- a/sentry-spring-boot-starter-jakarta/build.gradle.kts +++ b/sentry-spring-boot-starter-jakarta/build.gradle.kts @@ -9,6 +9,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.springboot3) apply false + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-spring-boot-starter/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts index c78113f363d..5d1228ab0b4 100644 --- a/sentry-spring-boot-starter/build.gradle.kts +++ b/sentry-spring-boot-starter/build.gradle.kts @@ -7,6 +7,9 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-spring-boot/api/sentry-spring-boot.api b/sentry-spring-boot/api/sentry-spring-boot.api index d4fdcf23e62..e85e0f5d3ff 100644 --- a/sentry-spring-boot/api/sentry-spring-boot.api +++ b/sentry-spring-boot/api/sentry-spring-boot.api @@ -32,16 +32,20 @@ public class io/sentry/spring/boot/SentryProperties : io/sentry/SentryOptions { public fun ()V public fun getClients ()Lio/sentry/spring/boot/SentryProperties$Clients; public fun getExceptionResolverOrder ()I + public fun getExcludeScheduledTasks ()Ljava/util/List; public fun getGraphql ()Lio/sentry/spring/boot/SentryProperties$Graphql; public fun getLogging ()Lio/sentry/spring/boot/SentryProperties$Logging; public fun getUserFilterOrder ()Ljava/lang/Integer; public fun isKeepTransactionsOpenForAsyncResponses ()Z + public fun isMonitorScheduledTasks ()Z public fun isUseGitCommitIdAsRelease ()Z public fun setClients (Lio/sentry/spring/boot/SentryProperties$Clients;)V public fun setExceptionResolverOrder (I)V + public fun setExcludeScheduledTasks (Ljava/util/List;)V public fun setGraphql (Lio/sentry/spring/boot/SentryProperties$Graphql;)V public fun setKeepTransactionsOpenForAsyncResponses (Z)V public fun setLogging (Lio/sentry/spring/boot/SentryProperties$Logging;)V + public fun setMonitorScheduledTasks (Z)V public fun setUseGitCommitIdAsRelease (Z)V public fun setUserFilterOrder (Ljava/lang/Integer;)V } @@ -55,6 +59,8 @@ public class io/sentry/spring/boot/SentryProperties$Clients { public class io/sentry/spring/boot/SentryProperties$Graphql { public fun ()V public fun getIgnoredErrorTypes ()Ljava/util/List; + public fun isEnableSpecificationErrorTypeFiltering ()Z + public fun setEnableSpecificationErrorTypeFiltering (Z)V public fun setIgnoredErrorTypes (Ljava/util/List;)V } diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index 02a7b508225..b05690fa905 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -11,6 +11,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/AgentApp.kt b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/AgentApp.kt index a6bd0bb35ff..32dd96a86d5 100644 --- a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/AgentApp.kt +++ b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/AgentApp.kt @@ -34,6 +34,8 @@ class AgentApp(args: List = emptyList()) : Closeable { "SENTRY_PROFILE_LIFECYCLE" to "TRACE", "SENTRY_PROFILING_TRACES_DIR_PATH" to "${System.getProperty("java.io.tmpdir")}/sentry-integration-test/profiling-traces", + // Tests wait for Sentry's span batches, which are 5 seconds apart by default. + "OTEL_BSP_SCHEDULE_DELAY" to "100ms", "OTEL_TRACES_EXPORTER" to "none", "OTEL_METRICS_EXPORTER" to "none", "OTEL_LOGS_EXPORTER" to "none", diff --git a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/DistributedTracingIntegrationTest.kt index ad67ae78981..9ce058b9c3f 100644 --- a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/DistributedTracingIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/DistributedTracingIntegrationTest.kt @@ -47,8 +47,8 @@ class DistributedTracingIntegrationTest : IntegrationTest() { ) assertThat(status).isEqualTo(500) - // Longer than the 5 seconds the app's spans can wait for their batch to be exported. - server.assertNoEnvelope(millis = 6_000) { isForRequest(it, id) } + // Ten times the app's batch delay, so the request's spans would have been exported. + server.assertNoEnvelope(millis = 1_000) { isForRequest(it, id) } } @Test diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java index 71c07604ef8..ec54ec292b0 100644 --- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java @@ -24,9 +24,11 @@ import io.sentry.spring.SpringSecuritySentryUserProvider; import io.sentry.spring.boot.graphql.SentryGraphqlAutoConfiguration; import io.sentry.spring.cache.SentryCacheBeanPostProcessor; +import io.sentry.spring.checkin.SentryCheckInAdvice; import io.sentry.spring.checkin.SentryCheckInAdviceConfiguration; import io.sentry.spring.checkin.SentryCheckInPointcutConfiguration; import io.sentry.spring.checkin.SentryQuartzConfiguration; +import io.sentry.spring.checkin.SentryScheduledCheckInPointcut; import io.sentry.spring.exception.SentryCaptureExceptionParameterPointcutConfiguration; import io.sentry.spring.exception.SentryExceptionParameterAdviceConfiguration; import io.sentry.spring.kafka.SentryKafkaConsumerBeanPostProcessor; @@ -46,11 +48,16 @@ import java.util.List; import java.util.Optional; import javax.servlet.http.HttpServletRequest; +import org.aopalliance.aop.Advice; import org.aspectj.lang.ProceedingJoinPoint; import org.jetbrains.annotations.NotNull; import org.quartz.core.QuartzScheduler; import org.slf4j.MDC; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.DefaultPointcutAdvisor; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -69,6 +76,7 @@ import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Role; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.env.Environment; @@ -291,6 +299,27 @@ static class SentryCheckInAspectsConfiguration { @Import(SentryCheckInPointcutConfiguration.class) @Open static class SentryCheckInPointcutAutoConfiguration {} + + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(name = "sentry.monitor-scheduled-tasks", havingValue = "true") + @Open + static class SentryScheduledCheckInConfiguration { + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advice sentryScheduledCheckInAdvice() { + return new SentryCheckInAdvice(ScopesAdapter.getInstance(), true); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advisor sentryScheduledCheckInAdvisor( + final @NotNull @Qualifier("sentryScheduledCheckInAdvice") Advice advice, + final @NotNull SentryProperties sentryProperties) { + return new DefaultPointcutAdvisor( + new SentryScheduledCheckInPointcut(sentryProperties.getExcludeScheduledTasks()), + advice); + } + } } /** Registers beans specific to Spring MVC. */ diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java index 7bc634d126c..d3b270e59e2 100644 --- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java @@ -40,6 +40,15 @@ public class SentryProperties extends SentryOptions { /** Clients integration properties. */ private @NotNull Clients clients = new Clients(); + /** Monitor every {@code @Scheduled} method with Sentry Crons. */ + private boolean monitorScheduledTasks = false; + + /** + * {@code @Scheduled} methods not to monitor when {@code monitorScheduledTasks} is on, as {@code + * ClassName.methodName} or a regex matching it. + */ + private @NotNull List excludeScheduledTasks = new ArrayList<>(); + public boolean isUseGitCommitIdAsRelease() { return useGitCommitIdAsRelease; } @@ -109,6 +118,22 @@ public void setClients(@NotNull Clients clients) { this.clients = clients; } + public boolean isMonitorScheduledTasks() { + return monitorScheduledTasks; + } + + public void setMonitorScheduledTasks(boolean monitorScheduledTasks) { + this.monitorScheduledTasks = monitorScheduledTasks; + } + + public @NotNull List getExcludeScheduledTasks() { + return excludeScheduledTasks; + } + + public void setExcludeScheduledTasks(@NotNull List excludeScheduledTasks) { + this.excludeScheduledTasks = excludeScheduledTasks; + } + @Open public static class Clients { /** Enable automatic instrumentation of Spring-managed OkHttp clients. Disabled by default. */ @@ -199,6 +224,9 @@ public static class Graphql { /** List of error types the Sentry Graphql integration should ignore. */ private @NotNull List ignoredErrorTypes = new ArrayList<>(); + /** Whether ignored error types should be matched against classification specifications. */ + private boolean enableSpecificationErrorTypeFiltering = false; + @NotNull public List getIgnoredErrorTypes() { return ignoredErrorTypes; @@ -207,5 +235,14 @@ public List getIgnoredErrorTypes() { public void setIgnoredErrorTypes(final @NotNull List ignoredErrorTypes) { this.ignoredErrorTypes = ignoredErrorTypes; } + + public boolean isEnableSpecificationErrorTypeFiltering() { + return enableSpecificationErrorTypeFiltering; + } + + public void setEnableSpecificationErrorTypeFiltering( + final boolean enableSpecificationErrorTypeFiltering) { + this.enableSpecificationErrorTypeFiltering = enableSpecificationErrorTypeFiltering; + } } } diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/graphql/SentryGraphqlAutoConfiguration.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/graphql/SentryGraphqlAutoConfiguration.java index ebebe1f1499..40f6fcb2eb7 100644 --- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/graphql/SentryGraphqlAutoConfiguration.java +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/graphql/SentryGraphqlAutoConfiguration.java @@ -56,7 +56,8 @@ private SentryInstrumentation createInstrumentation( beforeSpanCallback.getIfAvailable(), new SentrySpringSubscriptionHandler(), captureRequestBody, - sentryProperties.getGraphql().getIgnoredErrorTypes()); + sentryProperties.getGraphql().getIgnoredErrorTypes(), + sentryProperties.getGraphql().isEnableSpecificationErrorTypeFiltering()); } @Bean diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt index 9b9501246e5..28b190de232 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt @@ -18,6 +18,7 @@ import io.sentry.NoOpTransportFactory import io.sentry.ProfileLifecycle import io.sentry.SamplingContext import io.sentry.Sentry +import io.sentry.SentryEnvelope import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel @@ -47,6 +48,7 @@ import io.sentry.transport.ITransport import io.sentry.transport.ITransportGate import java.io.File import java.lang.RuntimeException +import java.util.concurrent.TimeUnit import javax.servlet.Filter import kotlin.test.Test import kotlin.test.assertEquals @@ -55,9 +57,12 @@ import kotlin.test.assertTrue import org.aspectj.lang.ProceedingJoinPoint import org.assertj.core.api.Assertions.assertThat import org.mockito.internal.util.MockUtil.isMock +import org.mockito.kotlin.after import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock +import org.mockito.kotlin.timeout import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.quartz.JobExecutionContext @@ -68,6 +73,7 @@ import org.quartz.core.QuartzScheduler import org.slf4j.MDC import org.springframework.aop.support.NameMatchMethodPointcut import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration import org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration @@ -82,6 +88,8 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.Ordered import org.springframework.core.annotation.Order +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.quartz.SchedulerFactoryBean import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.client.RestTemplate @@ -193,6 +201,33 @@ class SentryAutoConfigurationTest { } } + @Test + fun `GraphQL specification error type filtering is disabled by default`() { + dsnEnabledRunner.run { + assertThat( + it.getBean(SentryProperties::class.java).graphql.isEnableSpecificationErrorTypeFiltering + ) + .isFalse() + } + } + + @Test + fun `GraphQL specification error type filtering property is applied`() { + listOf(false, true).forEach { enabled -> + dsnEnabledRunner + .withPropertyValues("sentry.graphql.enable-specification-error-type-filtering=$enabled") + .run { + assertThat( + it + .getBean(SentryProperties::class.java) + .graphql + .isEnableSpecificationErrorTypeFiltering + ) + .isEqualTo(enabled) + } + } + } + @Test fun `properties are applied to SentryOptions`() { contextRunner @@ -1089,6 +1124,44 @@ class SentryAutoConfigurationTest { .run { assertThat(it).doesNotHaveBean(SchedulerFactoryBeanCustomizer::class.java) } } + @Test + fun `does not monitor @Scheduled methods by default`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).doesNotHaveBean("sentryScheduledCheckInAdvisor") + } + } + + @Test + fun `monitors @Scheduled methods when monitor-scheduled-tasks is enabled`() { + baseContextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.monitor-scheduled-tasks=true", + "sentry.exclude-scheduled-tasks[0]=SentryAutoConfigurationTest.ScheduledJobs.excluded", + ) + .withConfiguration(AutoConfigurations.of(AopAutoConfiguration::class.java)) + .withUserConfiguration( + MockTransportConfiguration::class.java, + ScheduledJobsConfiguration::class.java, + ) + .run { + assertThat(it).hasBean("sentryScheduledCheckInAdvisor") + val transport = it.getBean(ITransport::class.java) + val captor = argumentCaptor() + verify(transport, timeout(5000).times(2)).send(any(), anyOrNull()) + verify(transport, after(500).times(2)).send(captor.capture(), anyOrNull()) + val payloads = + captor.allValues.map { envelope -> String(envelope.items.first().data, Charsets.UTF_8) } + assertThat(payloads).allMatch { payload -> + payload.contains( + "\"monitor_slug\":\"sentry-auto-configuration-test-scheduled-jobs-sync\"" + ) + } + assertThat(payloads[0]).contains("\"value\":1,\"unit\":\"hour\"") + assertThat(payloads[1]).contains("\"status\":\"ok\"") + } + } + @Test fun `Sentry quartz job listener is added`() { contextRunner @@ -1204,6 +1277,18 @@ class SentryAutoConfigurationTest { @Bean open fun sentryTransport() = transport } + @Configuration(proxyBeanMethods = false) + @EnableScheduling + open class ScheduledJobsConfiguration { + @Bean open fun scheduledJobs() = ScheduledJobs() + } + + open class ScheduledJobs { + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun sync() {} + + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun excluded() {} + } + @Configuration(proxyBeanMethods = false) open class NoOpTransportConfiguration { diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index 3dccdccfe2c..fc3064789ef 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -141,12 +141,14 @@ public final class io/sentry/spring/jakarta/cache/SentryCacheWrapper : org/sprin public abstract interface annotation class io/sentry/spring/jakarta/checkin/SentryCheckIn : java/lang/annotation/Annotation { public abstract fun heartbeat ()Z public abstract fun monitorSlug ()Ljava/lang/String; + public abstract fun upsertMonitorConfig ()Z public abstract fun value ()Ljava/lang/String; } public class io/sentry/spring/jakarta/checkin/SentryCheckInAdvice : org/aopalliance/intercept/MethodInterceptor, org/springframework/context/EmbeddedValueResolverAware { public fun ()V public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Z)V public fun invoke (Lorg/aopalliance/intercept/MethodInvocation;)Ljava/lang/Object; public fun setEmbeddedValueResolver (Lorg/springframework/util/StringValueResolver;)V } @@ -167,6 +169,11 @@ public class io/sentry/spring/jakarta/checkin/SentryQuartzConfiguration { public fun schedulerFactoryBeanCustomizer ()Lorg/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer; } +public final class io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut : org/springframework/aop/support/StaticMethodMatcherPointcut { + public fun (Ljava/util/List;)V + public fun matches (Ljava/lang/reflect/Method;Ljava/lang/Class;)Z +} + public final class io/sentry/spring/jakarta/checkin/SentrySchedulerFactoryBeanCustomizer : org/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer { public fun ()V public fun customize (Lorg/springframework/scheduling/quartz/SchedulerFactoryBean;)V diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index ee9b057ec6e..444d219f244 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -11,6 +11,9 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) alias(libs.plugins.springboot3) apply false + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } @@ -34,7 +37,7 @@ dependencies { compileOnly(Config.Libs.springWeb) compileOnly(Config.Libs.springAop) compileOnly(Config.Libs.springSecurityWeb) - compileOnly(Config.Libs.aspectj) + compileOnly(libs.aspectjweaver) compileOnly(libs.context.propagation) compileOnly(libs.jetbrains.annotations) compileOnly(libs.nopen.annotations) diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java index bfb6b724b8f..89b04aa561a 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java @@ -29,6 +29,16 @@ */ boolean heartbeat() default false; + /** + * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so + * Sentry creates or updates the monitor. + * + *

Heartbeat check-ins never send a monitor config. + * + * @return true to send a monitor config, false by default + */ + boolean upsertMonitorConfig() default false; + /** * Monitor slug. If not set, no check-in will be sent. * diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java index 1e3697e781d..a544c401966 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java @@ -6,13 +6,25 @@ import io.sentry.DateUtils; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; +import io.sentry.MonitorConfig; import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; import io.sentry.protocol.SentryId; import io.sentry.time.Stopwatch; +import io.sentry.util.MonitorConfigUtils; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.zip.CRC32; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.jetbrains.annotations.ApiStatus; @@ -20,7 +32,11 @@ import org.jetbrains.annotations.Nullable; import org.springframework.aop.support.AopUtils; import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.annotation.Schedules; +import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringValueResolver; @@ -31,16 +47,44 @@ @ApiStatus.Internal @Open public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueResolverAware { + // Spring before 5.3 parses crons with CronSequenceGenerator + private static final boolean LEGACY_CRON_PARSER = + !ClassUtils.isPresent( + "org.springframework.scheduling.support.CronExpression", + SentryCheckInAdvice.class.getClassLoader()); + + private static final @NotNull Pattern WORD_BOUNDARY = + Pattern.compile("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])"); + private static final int MAX_SLUG_LENGTH = 50; + private final @NotNull IScopes scopes; + private final boolean monitorScheduledTasks; + private @Nullable StringValueResolver resolver; + private final @NotNull Map monitorConfigs = + new ConcurrentHashMap<>(); + + private final @NotNull Map derivedSlugOwners = new ConcurrentHashMap<>(); + + private final @NotNull Set collidingSlugs = ConcurrentHashMap.newKeySet(); + public SentryCheckInAdvice() { this(ScopesAdapter.getInstance()); } public SentryCheckInAdvice(final @NotNull IScopes scopes) { + this(scopes, false); + } + + /** + * @param monitorScheduledTasks whether to also monitor {@code @Scheduled} methods without {@link + * SentryCheckIn}, using a slug derived from the class and method name + */ + public SentryCheckInAdvice(final @NotNull IScopes scopes, final boolean monitorScheduledTasks) { this.scopes = Objects.requireNonNull(scopes, "scopes are required"); + this.monitorScheduledTasks = monitorScheduledTasks; } @Override @@ -52,6 +96,24 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl SentryCheckIn checkInAnnotation = AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); if (checkInAnnotation == null) { + if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { + final @NotNull Class targetClass = invocation.getThis().getClass(); + final @NotNull String taskName = scheduledTaskName(targetClass, mostSpecificMethod); + final @Nullable MonitorConfig monitorConfig = monitorConfig(mostSpecificMethod); + if (monitorConfig == null) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not monitoring %s because Sentry Crons can't express its @Scheduled schedule.", + taskName); + return invocation.proceed(); + } + final @NotNull String monitorSlug = scheduledTaskSlug(taskName); + warnOnSlugCollision(monitorSlug, targetClass, mostSpecificMethod); + return captureCheckIns(invocation, monitorSlug, false, monitorConfig); + } return invocation.proceed(); } @@ -87,6 +149,19 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl return invocation.proceed(); } + final @Nullable MonitorConfig monitorConfig = + !isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig() + ? monitorConfig(mostSpecificMethod) + : null; + return captureCheckIns(invocation, monitorSlug, isHeartbeatOnly, monitorConfig); + } + + private Object captureCheckIns( + final @NotNull MethodInvocation invocation, + final @NotNull String monitorSlug, + final boolean isHeartbeatOnly, + final @Nullable MonitorConfig monitorConfig) + throws Throwable { try (final @NotNull ISentryLifecycleToken ignored = scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) { TracingUtils.startNewTrace(scopes); @@ -98,7 +173,9 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl try { if (!isHeartbeatOnly) { - checkInId = scopes.captureCheckIn(new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS)); + final @NotNull CheckIn inProgress = new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS); + inProgress.setMonitorConfig(monitorConfig); + checkInId = scopes.captureCheckIn(inProgress); } return invocation.proceed(); } catch (Throwable e) { @@ -113,6 +190,144 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } } + private void warnOnSlugCollision( + final @NotNull String monitorSlug, + final @NotNull Class targetClass, + final @NotNull Method method) { + final @NotNull String identity = + ClassUtils.getUserClass(targetClass).getName() + "." + method.getName(); + final @Nullable String owner = derivedSlugOwners.putIfAbsent(monitorSlug, identity); + if (owner != null && !owner.equals(identity) && collidingSlugs.add(monitorSlug)) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them.", + monitorSlug, + owner, + identity); + } + } + + static boolean isScheduled(final @NotNull Method method) { + return !AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class) + .isEmpty(); + } + + /** {@code ClassName.methodName}, with nested classes as {@code Outer.Inner}. */ + static @NotNull String scheduledTaskName( + final @NotNull Class targetClass, final @NotNull Method method) { + return ClassUtils.getShortName(ClassUtils.getUserClass(targetClass)) + "." + method.getName(); + } + + static @NotNull String scheduledTaskSlug(final @NotNull String taskName) { + final @NotNull String slug = + WORD_BOUNDARY + .matcher(taskName) + .replaceAll("-") + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+|-+$", ""); + if (slug.length() <= MAX_SLUG_LENGTH) { + return slug; + } + // keep truncated slugs unique + final @NotNull CRC32 crc = new CRC32(); + crc.update(slug.getBytes(StandardCharsets.UTF_8)); + final @NotNull String hash = String.format(Locale.ROOT, "%08x", crc.getValue()); + return slug.substring(0, MAX_SLUG_LENGTH - hash.length() - 1).replaceAll("-+$", "") + + "-" + + hash; + } + + private @Nullable MonitorConfig monitorConfig(final @NotNull Method method) { + return monitorConfigs.computeIfAbsent( + method, key -> new CachedMonitorConfig(monitorConfigFromScheduled(key))) + .config; + } + + private @Nullable MonitorConfig monitorConfigFromScheduled(final @NotNull Method method) { + try { + final @NotNull Set schedules = + AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class); + if (schedules.size() != 1) { + return null; + } + final @NotNull Scheduled scheduled = schedules.iterator().next(); + // timeUnit only exists from Spring 5.3.10, so read it as an attribute + final @Nullable Object timeUnitAttribute = + AnnotationUtils.getAnnotationAttributes(scheduled).get("timeUnit"); + final @NotNull TimeUnit timeUnit = + timeUnitAttribute instanceof TimeUnit unit ? unit : TimeUnit.MILLISECONDS; + final @Nullable String cron = resolve(scheduled.cron()); + // Spring only reads the zone for a cron + final @Nullable String zone = + cron == null || cron.isEmpty() ? null : resolve(scheduled.zone()); + return MonitorConfigUtils.fromSpringScheduled( + cron, + zone, + periodMillis(scheduled.fixedRate(), scheduled.fixedRateString(), timeUnit), + periodMillis(scheduled.fixedDelay(), scheduled.fixedDelayString(), timeUnit), + LEGACY_CRON_PARSER); + } catch (RuntimeException e) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.WARNING, "Could not derive a monitor config from @Scheduled.", e); + return null; + } + } + + private @Nullable Long periodMillis( + final long value, final @NotNull String valueString, final @NotNull TimeUnit timeUnit) { + if (value >= 0) { + return timeUnit.toMillis(value); + } + final @Nullable String resolved = resolve(valueString); + if (resolved == null || resolved.isEmpty()) { + return null; + } + final @NotNull String trimmed = resolved.trim(); + @Nullable Long millis = null; + if (trimmed.startsWith("P") || trimmed.startsWith("p")) { + try { + millis = Duration.parse(trimmed).toMillis(); + } catch (DateTimeParseException | ArithmeticException e) { + // logged below + } + } else { + millis = MonitorConfigUtils.parsePeriodMillis(trimmed, timeUnit); + } + if (millis == null) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not sending a monitor config because the @Scheduled period '%s' could not be parsed.", + trimmed); + } + return millis; + } + + private @Nullable String resolve(final @NotNull String value) { + if (resolver == null || value.isEmpty()) { + return value; + } + return resolver.resolveStringValue(value); + } + + private static final class CachedMonitorConfig { + private final @Nullable MonitorConfig config; + + private CachedMonitorConfig(final @Nullable MonitorConfig config) { + this.config = config; + } + } + @Override public void setEmbeddedValueResolver(StringValueResolver resolver) { this.resolver = resolver; diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut.java new file mode 100644 index 00000000000..96b4098c2aa --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut.java @@ -0,0 +1,66 @@ +package io.sentry.spring.jakarta.checkin; + +import io.sentry.FilterString; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.aop.support.AopUtils; +import org.springframework.aop.support.StaticMethodMatcherPointcut; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.ClassUtils; + +/** + * Matches {@code @Scheduled} methods without {@link SentryCheckIn}, except excluded ones. Excludes + * match the task name ({@code ClassName.methodName}) exactly or as a regex. + */ +@ApiStatus.Internal +public final class SentryScheduledCheckInPointcut extends StaticMethodMatcherPointcut { + private final @NotNull List excludedScheduledTasks = new ArrayList<>(); + + public SentryScheduledCheckInPointcut(final @Nullable List excludedScheduledTasks) { + if (excludedScheduledTasks != null) { + for (final @NotNull String task : excludedScheduledTasks) { + if (!task.trim().isEmpty()) { + this.excludedScheduledTasks.add(new FilterString(task.trim())); + } + } + } + } + + @Override + public boolean matches(final @NotNull Method method, final @NotNull Class targetClass) { + final @NotNull Class userClass = ClassUtils.getUserClass(targetClass); + // CGLIB can't intercept these, and Spring calls private and static methods on the target + final int modifiers = method.getModifiers(); + if (Modifier.isFinal(userClass.getModifiers()) + || Modifier.isFinal(modifiers) + || Modifier.isPrivate(modifiers) + || Modifier.isStatic(modifiers)) { + return false; + } + final @NotNull Method specificMethod = AopUtils.getMostSpecificMethod(method, userClass); + if (!SentryCheckInAdvice.isScheduled(specificMethod) + || AnnotationUtils.findAnnotation(specificMethod, SentryCheckIn.class) != null) { + return false; + } + // Spring calls reactive and suspend methods once and subscribes to the result on each run + if (specificMethod.getParameterCount() > 0 + || ReactiveAdapterRegistry.getSharedInstance().getAdapter(specificMethod.getReturnType()) + != null) { + return false; + } + final @NotNull String taskName = + SentryCheckInAdvice.scheduledTaskName(userClass, specificMethod); + for (final @NotNull FilterString excluded : excludedScheduledTasks) { + if (excluded.getFilterString().equals(taskName) || excluded.matches(taskName)) { + return false; + } + } + return true; + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt index c6edd834530..84f6e2a8118 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt @@ -4,16 +4,21 @@ import io.sentry.CheckIn import io.sentry.CheckInStatus import io.sentry.IScopes import io.sentry.ISentryLifecycleToken +import io.sentry.MonitorConfig import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.protocol.SentryId import io.sentry.spring.jakarta.checkin.SentryCheckIn import io.sentry.spring.jakarta.checkin.SentryCheckInAdviceConfiguration import io.sentry.spring.jakarta.checkin.SentryCheckInPointcutConfiguration +import java.util.TimeZone +import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame import org.junit.jupiter.api.assertThrows import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -32,6 +37,8 @@ import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.EnableAspectJAutoProxy import org.springframework.context.annotation.Import import org.springframework.context.support.PropertySourcesPlaceholderConfigurer +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.scheduling.annotation.Schedules import org.springframework.test.context.TestPropertySource import org.springframework.test.context.junit.jupiter.SpringJUnitConfig import org.springframework.test.context.junit4.SpringRunner @@ -39,7 +46,15 @@ import org.springframework.util.StringValueResolver @RunWith(SpringRunner::class) @SpringJUnitConfig(SentryCheckInAdviceTest.Config::class) -@TestPropertySource(properties = ["my.cron.slug = mypropertycronslug"]) +@TestPropertySource( + properties = + [ + "my.cron.slug = mypropertycronslug", + "my.cron.schedule = 0 30 2 * * *", + "my.cron.zone = America/New_York", + "my.cron.empty.zone = ", + ] +) class SentryCheckInAdviceTest { @Autowired lateinit var sampleService: SampleService @@ -50,6 +65,10 @@ class SentryCheckInAdviceTest { @Autowired lateinit var sampleServiceSpringProperties: SampleServiceSpringProperties + @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled + + @Autowired lateinit var sampleServiceInheritedSchedule: SampleServiceInheritedSchedule + @Autowired lateinit var scopes: IScopes val lifecycleToken = mock() @@ -74,6 +93,7 @@ class SentryCheckInAdviceTest { val inProgressCheckIn = checkInCaptor.firstValue assertEquals("monitor_slug_1", inProgressCheckIn.monitorSlug) assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status) + assertNull(inProgressCheckIn.monitorConfig) val doneCheckIn = checkInCaptor.lastValue assertEquals("monitor_slug_1", doneCheckIn.monitorSlug) @@ -228,6 +248,160 @@ class SentryCheckInAdviceTest { order.verify(lifecycleToken).close() } + @Test + fun `cron with zone is sent as crontab monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZone() } + assertNotNull(config) + assertEquals("crontab", config.schedule.type) + assertEquals("15 10 * * 1-5", config.schedule.value) + assertNull(config.schedule.unit) + assertEquals("Europe/Vienna", config.timezone) + } + + @Test + fun `cron without zone is sent in the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + val config = inProgressMonitorConfig { sampleServiceScheduled.cron() } + assertEquals("0 2 * * *", config?.schedule?.value) + assertEquals("Asia/Tokyo", config?.timezone) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `cron and zone placeholders are resolved`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronFromProperties() } + assertEquals("30 2 * * *", config?.schedule?.value) + assertEquals("America/New_York", config?.timezone) + } + + @Test + fun `fixed rate with time unit is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + assertNotNull(config) + assertEquals("interval", config.schedule.type) + assertEquals("2", config.schedule.value) + assertEquals("hour", config.schedule.unit) + } + + @Test + fun `ISO-8601 fixed rate string is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateIso() } + assertEquals("10", config?.schedule?.value) + assertEquals("minute", config?.schedule?.unit) + } + + @Test + fun `simple duration fixed rate string is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateSimpleDuration() } + assertEquals("interval", config?.schedule?.type) + assertEquals("5", config?.schedule?.value) + assertEquals("minute", config?.schedule?.unit) + } + + @Test + fun `fixed delay sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelay() }) + assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelayIso() }) + } + + @Test + fun `disabled cron sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.disabledCron() }) + } + + @Test + fun `zone placeholder resolving to empty uses the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithEmptyZone() } + assertEquals("0 3 * * *", config?.schedule?.value) + assertEquals("Asia/Tokyo", config?.timezone) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `cron zone overrides the default timezone from options`() { + val options = + SentryOptions().apply { + cron = SentryOptions.Cron().apply { defaultTimezone = "Europe/Berlin" } + } + whenever(scopes.options).thenReturn(options) + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZoneAndDefaults() } + assertEquals("Europe/Vienna", config?.timezone) + } + + @Test + fun `unresolvable cron placeholder sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.unresolvableCron() }) + } + + @Test + fun `exception while deriving monitor config sends no monitor config and runs the job`() { + var result = 0 + assertNull(inProgressMonitorConfig { result = sampleServiceScheduled.derivationThrows() }) + assertEquals(1, result) + } + + @Test + fun `monitor config is derived once per method`() { + val first = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + val second = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + assertNotNull(first) + assertSame(first, second) + } + + @Test + fun `multiple schedules send no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.multipleSchedules() }) + } + + @Test + fun `schedules container sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.schedulesContainer() }) + } + + @Test + fun `upsertMonitorConfig false sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDisabled() }) + } + + @Test + fun `upsertMonitorConfig defaults to false`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDefault() }) + } + + @Test + fun `@Scheduled on an overridden parent method sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceInheritedSchedule.job() }) + } + + @Test + fun `heartbeat with @Scheduled sends a single check-in without monitor config`() { + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + sampleServiceScheduled.heartbeat() + assertEquals(1, checkInCaptor.allValues.size) + assertEquals(CheckInStatus.OK.apiName(), checkInCaptor.firstValue.status) + assertNull(checkInCaptor.firstValue.monitorConfig) + } + + private fun inProgressMonitorConfig(block: () -> Unit): MonitorConfig? { + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + block() + assertEquals(2, checkInCaptor.allValues.size) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkInCaptor.firstValue.status) + assertNull(checkInCaptor.lastValue.monitorConfig) + return checkInCaptor.firstValue.monitorConfig + } + @Configuration @EnableAspectJAutoProxy(proxyTargetClass = true) @Import(SentryCheckInAdviceConfiguration::class, SentryCheckInPointcutConfiguration::class) @@ -241,6 +415,10 @@ class SentryCheckInAdviceTest { @Bean open fun sampleServiceSpringProperties() = SampleServiceSpringProperties() + @Bean open fun sampleServiceScheduled() = SampleServiceScheduled() + + @Bean open fun sampleServiceInheritedSchedule() = SampleServiceInheritedSchedule() + @Bean open fun scopes(): IScopes { val scopes = mock() @@ -291,6 +469,88 @@ class SentryCheckInAdviceTest { open fun helloExceptionProperty() = 1 } + open class SampleServiceScheduled { + + @SentryCheckIn("cron_zone", upsertMonitorConfig = true) + @Scheduled(cron = "0 15 10 * * MON-FRI", zone = "Europe/Vienna") + open fun cronWithZone() {} + + @SentryCheckIn("cron", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 2 * * *") + open fun cron() {} + + @SentryCheckIn("cron_properties", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}") + open fun cronFromProperties() {} + + @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true) + @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS) + open fun fixedRateHours() {} + + @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true) + @Scheduled(fixedRateString = "PT10M") + open fun fixedRateIso() {} + + @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true) + @Scheduled(fixedRateString = "5m") + open fun fixedRateSimpleDuration() {} + + @SentryCheckIn("fixed_delay", upsertMonitorConfig = true) + @Scheduled(fixedDelay = 300_000) + open fun fixedDelay() {} + + @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true) + @Scheduled(fixedDelayString = "PT10M") + open fun fixedDelayIso() {} + + @SentryCheckIn("disabled_cron", upsertMonitorConfig = true) + @Scheduled(cron = "-") + open fun disabledCron() {} + + @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}") + open fun cronWithEmptyZone() {} + + @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna") + open fun cronWithZoneAndDefaults() {} + + @SentryCheckIn("schedules_container", upsertMonitorConfig = true) + @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *")) + open fun schedulesContainer() {} + + @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.missing}") + open fun unresolvableCron() {} + + @SentryCheckIn("derivation_throws", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.exception.property}") + open fun derivationThrows() = 1 + + @SentryCheckIn("multiple", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + @Scheduled(cron = "0 0 13 * * *") + open fun multipleSchedules() {} + + @SentryCheckIn("upsert_disabled", upsertMonitorConfig = false) + @Scheduled(cron = "0 0 1 * * *") + open fun upsertDisabled() {} + + @SentryCheckIn("upsert_default") @Scheduled(cron = "0 0 1 * * *") open fun upsertDefault() {} + + @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + open fun heartbeat() {} + } + + open class ScheduledParent { + @Scheduled(cron = "0 0 1 * * *") open fun job() {} + } + + open class SampleServiceInheritedSchedule : ScheduledParent() { + @SentryCheckIn("inherited", upsertMonitorConfig = true) override fun job() {} + } + class MyPropertyPlaceholderConfigurer : PropertySourcesPlaceholderConfigurer() { override fun doProcessProperties( diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInTest.kt new file mode 100644 index 00000000000..00a2864b3f3 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInTest.kt @@ -0,0 +1,365 @@ +package io.sentry.spring.jakarta.checkin + +import io.sentry.CheckIn +import io.sentry.CheckInStatus +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +import java.lang.reflect.Modifier +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.jupiter.api.assertThrows +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeast +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.springframework.aop.Advisor +import org.springframework.aop.Pointcut +import org.springframework.aop.framework.ProxyFactory +import org.springframework.aop.support.AopUtils +import org.springframework.aop.support.DefaultPointcutAdvisor +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.context.annotation.Import +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import org.springframework.test.context.junit4.SpringRunner +import reactor.core.publisher.Mono + +@RunWith(SpringRunner::class) +@SpringJUnitConfig(SentryScheduledCheckInTest.Config::class) +@TestPropertySource(properties = ["my.cron.schedule = 0 30 2 * * *"]) +class SentryScheduledCheckInTest { + private val SLUG = "sentry-scheduled-check-in-test-jobs-sync-users" + private val COLLISION_WARNING = + "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them." + + @Autowired lateinit var jobs: Jobs + + @Autowired lateinit var excludedJobs: ExcludedJobs + + @Autowired lateinit var finalJobs: FinalJobs + + @Autowired lateinit var overridingJobs: OverridingJobs + + @Autowired lateinit var scopes: IScopes + + @BeforeTest + fun setup() { + reset(scopes) + whenever(scopes.options).thenReturn(SentryOptions()) + whenever(scopes.forkedScopes(any())).thenReturn(scopes) + whenever(scopes.makeCurrent()).thenReturn(mock()) + whenever(scopes.captureCheckIn(any())).thenReturn(SentryId()) + } + + @Test + fun `scheduled method without @SentryCheckIn is monitored with a derived slug and config`() { + val checkIns = capture { jobs.syncUsers() } + assertEquals(2, checkIns.size) + assertEquals(SLUG, checkIns[0].monitorSlug) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkIns[0].status) + val config = assertNotNull(checkIns[0].monitorConfig) + assertEquals("0 2 * * *", config.schedule.value) + assertEquals("Europe/Vienna", config.timezone) + assertEquals(SLUG, checkIns[1].monitorSlug) + assertEquals(CheckInStatus.OK.apiName(), checkIns[1].status) + } + + @Test + fun `cron placeholder is resolved`() { + val checkIns = capture { jobs.fromProperty() } + assertEquals("30 2 * * *", checkIns[0].monitorConfig!!.schedule.value) + } + + @Test + fun `tasks with unsupported schedules are not monitored`() { + jobs.fixedDelay() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `@SentryCheckIn with an unsupported schedule checks in without config`() { + val checkIns = capture { jobs.explicitFixedDelay() } + assertEquals(listOf("explicit-delay", "explicit-delay"), checkIns.map { it.monitorSlug }) + assertNull(checkIns[0].monitorConfig) + } + + @Test + fun `failing job sends an error check-in and rethrows`() { + val checkIns = capture { assertThrows { jobs.oops() } } + assertEquals(CheckInStatus.ERROR.apiName(), checkIns[1].status) + } + + @Test + fun `@SentryCheckIn keeps its slug and is only checked in once`() { + val checkIns = capture { jobs.explicit() } + assertEquals(listOf("explicit", "explicit"), checkIns.map { it.monitorSlug }) + assertNull(checkIns[0].monitorConfig) + } + + @Test + fun `methods without @Scheduled are not monitored`() { + jobs.notScheduled() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `overriding methods without @Scheduled are not monitored`() { + overridingJobs.run() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `reactive methods are not monitored`() { + jobs.reactive().block() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `excluded tasks are not monitored`() { + jobs.excluded() + excludedJobs.run() + verify(scopes, never()).captureCheckIn(any()) + assertFalse(AopUtils.isAopProxy(excludedJobs)) + } + + @Test + fun `final classes are not proxied`() { + finalJobs.run() + verify(scopes, never()).captureCheckIn(any()) + assertFalse(AopUtils.isAopProxy(finalJobs)) + assertTrue(AopUtils.isAopProxy(jobs)) + } + + @Test + fun `private and static methods are not matched`() { + val pointcut = SentryScheduledCheckInPointcut(null) + val privateMethod = HiddenJobs::class.java.getDeclaredMethod("hidden") + val staticMethod = HiddenJobs::class.java.getDeclaredMethod("static") + assertTrue(Modifier.isPrivate(privateMethod.modifiers)) + assertTrue(Modifier.isStatic(staticMethod.modifiers)) + assertFalse(pointcut.matches(privateMethod, HiddenJobs::class.java)) + assertFalse(pointcut.matches(staticMethod, HiddenJobs::class.java)) + assertTrue(pointcut.matches(Jobs::class.java.getMethod("syncUsers"), Jobs::class.java)) + } + + @Test + fun `slugs are kebab case and at most 50 characters`() { + assertEquals( + "report-jobs-send-daily-report", + SentryCheckInAdvice.scheduledTaskSlug("ReportJobs.sendDailyReport"), + ) + assertEquals( + "http-client-jobs-run", + SentryCheckInAdvice.scheduledTaskSlug("HTTPClientJobs.run"), + ) + assertEquals("outer-inner-run2-fa", SentryCheckInAdvice.scheduledTaskSlug("Outer.Inner.run2FA")) + val first = + SentryCheckInAdvice.scheduledTaskSlug( + "AVeryLongScheduledJobClassNameForReporting.firstMethod" + ) + val second = + SentryCheckInAdvice.scheduledTaskSlug( + "AVeryLongScheduledJobClassNameForReporting.secondMethod" + ) + assertTrue(first.length <= 50) + assertTrue(first.startsWith("a-very-long-scheduled-job-class-name-for-")) + assertTrue(first != second) + assertEquals( + first, + SentryCheckInAdvice.scheduledTaskSlug( + "AVeryLongScheduledJobClassNameForReporting.firstMethod" + ), + ) + } + + @Test + fun `task name uses the user class`() { + assertEquals( + "SentryScheduledCheckInTest.Jobs.syncUsers", + SentryCheckInAdvice.scheduledTaskName( + jobs.javaClass, + Jobs::class.java.getMethod("syncUsers"), + ), + ) + } + + @Test + fun `warns once when two methods derive the same slug`() { + val logger = mockLogger() + val advice = SentryCheckInAdvice(scopes, true) + val cleanupJob = proxy(CleanupJob(), advice) + val cleanup = proxy(Cleanup(), advice) + + cleanupJob.run() + cleanup.jobRun() + cleanup.jobRun() + cleanupJob.run() + + verify(logger, times(1)) + .log( + eq(SentryLevel.WARNING), + eq(COLLISION_WARNING), + eq("sentry-scheduled-check-in-test-cleanup-job-run"), + eq("${CleanupJob::class.java.name}.run"), + eq("${Cleanup::class.java.name}.jobRun"), + ) + } + + @Test + fun `does not warn when derived slugs differ or the same method runs again`() { + val logger = mockLogger() + val advice = SentryCheckInAdvice(scopes, true) + val cleanupJob = proxy(CleanupJob(), advice) + val otherJobs = proxy(Jobs(), advice) + + cleanupJob.run() + cleanupJob.run() + otherJobs.syncUsers() + + verify(logger, never()).log(eq(SentryLevel.WARNING), eq(COLLISION_WARNING), any(), any(), any()) + } + + private fun mockLogger(): ILogger { + val logger = mock() + val options = + SentryOptions().apply { + isDebug = true + setLogger(logger) + } + whenever(scopes.options).thenReturn(options) + return logger + } + + private fun proxy(target: T, advice: SentryCheckInAdvice): T { + val factory = ProxyFactory(target) + factory.isProxyTargetClass = true + factory.addAdvice(advice) + @Suppress("UNCHECKED_CAST") + return factory.proxy as T + } + + private fun capture(block: () -> Unit): List { + val captor = argumentCaptor() + block() + verify(scopes, atLeast(0)).captureCheckIn(captor.capture()) + return captor.allValues + } + + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(SentryCheckInPointcutConfiguration::class) + open class Config { + + @Bean open fun jobs() = Jobs() + + @Bean open fun excludedJobs() = ExcludedJobs() + + @Bean open fun finalJobs() = FinalJobs() + + @Bean open fun overridingJobs() = OverridingJobs() + + @Bean open fun scopes(): IScopes = mock() + + @Bean + open fun sentryCheckInAdvisor( + @Qualifier("sentryCheckInPointcut") pointcut: Pointcut, + scopes: IScopes, + ): Advisor = DefaultPointcutAdvisor(pointcut, SentryCheckInAdvice(scopes)) + + @Bean open fun sentryScheduledCheckInAdvice(scopes: IScopes) = SentryCheckInAdvice(scopes, true) + + @Bean + open fun sentryScheduledCheckInAdvisor( + @Qualifier("sentryScheduledCheckInAdvice") advice: SentryCheckInAdvice + ): Advisor = + DefaultPointcutAdvisor( + SentryScheduledCheckInPointcut( + listOf("SentryScheduledCheckInTest.Jobs.excluded", ".*\\.ExcludedJobs\\..*", " ") + ), + advice, + ) + + companion object { + @Bean + @JvmStatic + fun propertySourcesPlaceholderConfigurer() = PropertySourcesPlaceholderConfigurer() + } + } + + open class Jobs { + @Scheduled(cron = "0 0 2 * * *", zone = "Europe/Vienna") open fun syncUsers() {} + + @Scheduled(cron = "\${my.cron.schedule}") open fun fromProperty() {} + + @Scheduled(fixedDelay = 1000) open fun fixedDelay() {} + + @Scheduled(cron = "0 0 2 * * *") + open fun oops() { + throw IllegalStateException("thrown on purpose") + } + + @SentryCheckIn("explicit") @Scheduled(cron = "0 0 2 * * *") open fun explicit() {} + + @SentryCheckIn("explicit-delay") @Scheduled(fixedDelay = 1000) open fun explicitFixedDelay() {} + + @Scheduled(cron = "0 0 2 * * *") open fun excluded() {} + + open fun notScheduled() {} + + @Scheduled(cron = "0 0 2 * * *") open fun reactive(): Mono = Mono.empty() + } + + open class CleanupJob { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + open class Cleanup { + @Scheduled(cron = "0 0 2 * * *") open fun jobRun() {} + } + + open class HiddenJobs { + @Scheduled(cron = "0 0 2 * * *") private fun hidden() {} + + companion object { + @JvmStatic @Scheduled(cron = "0 0 2 * * *") fun static() {} + } + } + + open class ExcludedJobs { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + class FinalJobs { + @Scheduled(cron = "0 0 2 * * *") fun run() {} + } + + open class ParentJobs { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + open class OverridingJobs : ParentJobs() { + override fun run() {} + } +} diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index 9073d10e594..33f383a4025 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -139,12 +139,14 @@ public final class io/sentry/spring/cache/SentryCacheWrapper : org/springframewo public abstract interface annotation class io/sentry/spring/checkin/SentryCheckIn : java/lang/annotation/Annotation { public abstract fun heartbeat ()Z public abstract fun monitorSlug ()Ljava/lang/String; + public abstract fun upsertMonitorConfig ()Z public abstract fun value ()Ljava/lang/String; } public class io/sentry/spring/checkin/SentryCheckInAdvice : org/aopalliance/intercept/MethodInterceptor, org/springframework/context/EmbeddedValueResolverAware { public fun ()V public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Z)V public fun invoke (Lorg/aopalliance/intercept/MethodInvocation;)Ljava/lang/Object; public fun setEmbeddedValueResolver (Lorg/springframework/util/StringValueResolver;)V } @@ -165,6 +167,11 @@ public class io/sentry/spring/checkin/SentryQuartzConfiguration { public fun schedulerFactoryBeanCustomizer ()Lorg/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer; } +public final class io/sentry/spring/checkin/SentryScheduledCheckInPointcut : org/springframework/aop/support/StaticMethodMatcherPointcut { + public fun (Ljava/util/List;)V + public fun matches (Ljava/lang/reflect/Method;Ljava/lang/Class;)Z +} + public final class io/sentry/spring/checkin/SentrySchedulerFactoryBeanCustomizer : org/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer { public fun ()V public fun customize (Lorg/springframework/scheduling/quartz/SchedulerFactoryBean;)V diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index 52ed26cce17..6c6c98b6a7d 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -10,6 +10,9 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } @@ -26,7 +29,7 @@ dependencies { compileOnly(Config.Libs.springWeb) compileOnly(Config.Libs.springAop) compileOnly(Config.Libs.springSecurityWeb) - compileOnly(Config.Libs.aspectj) + compileOnly(libs.aspectjweaver) compileOnly(Config.Libs.springWebflux) compileOnly(projects.sentryGraphql) compileOnly(projects.sentryKafka) diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java index 805f4bd5464..55fedd93c2e 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java @@ -29,6 +29,16 @@ */ boolean heartbeat() default false; + /** + * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so + * Sentry creates or updates the monitor. + * + *

Heartbeat check-ins never send a monitor config. + * + * @return true to send a monitor config, false by default + */ + boolean upsertMonitorConfig() default false; + /** * Monitor slug. If not set, no check-in will be sent. * diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java index 2abd2cfb994..f6923c77e6e 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java @@ -6,13 +6,25 @@ import io.sentry.DateUtils; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; +import io.sentry.MonitorConfig; import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; import io.sentry.protocol.SentryId; import io.sentry.time.Stopwatch; +import io.sentry.util.MonitorConfigUtils; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.zip.CRC32; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.jetbrains.annotations.ApiStatus; @@ -20,7 +32,11 @@ import org.jetbrains.annotations.Nullable; import org.springframework.aop.support.AopUtils; import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.annotation.Schedules; +import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringValueResolver; @@ -31,16 +47,44 @@ @ApiStatus.Internal @Open public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueResolverAware { + // Spring before 5.3 parses crons with CronSequenceGenerator + private static final boolean LEGACY_CRON_PARSER = + !ClassUtils.isPresent( + "org.springframework.scheduling.support.CronExpression", + SentryCheckInAdvice.class.getClassLoader()); + + private static final @NotNull Pattern WORD_BOUNDARY = + Pattern.compile("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])"); + private static final int MAX_SLUG_LENGTH = 50; + private final @NotNull IScopes scopes; + private final boolean monitorScheduledTasks; + private @Nullable StringValueResolver resolver; + private final @NotNull Map monitorConfigs = + new ConcurrentHashMap<>(); + + private final @NotNull Map derivedSlugOwners = new ConcurrentHashMap<>(); + + private final @NotNull Set collidingSlugs = ConcurrentHashMap.newKeySet(); + public SentryCheckInAdvice() { this(ScopesAdapter.getInstance()); } public SentryCheckInAdvice(final @NotNull IScopes scopes) { + this(scopes, false); + } + + /** + * @param monitorScheduledTasks whether to also monitor {@code @Scheduled} methods without {@link + * SentryCheckIn}, using a slug derived from the class and method name + */ + public SentryCheckInAdvice(final @NotNull IScopes scopes, final boolean monitorScheduledTasks) { this.scopes = Objects.requireNonNull(scopes, "scopes are required"); + this.monitorScheduledTasks = monitorScheduledTasks; } @Override @@ -52,6 +96,24 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl SentryCheckIn checkInAnnotation = AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); if (checkInAnnotation == null) { + if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { + final @NotNull Class targetClass = invocation.getThis().getClass(); + final @NotNull String taskName = scheduledTaskName(targetClass, mostSpecificMethod); + final @Nullable MonitorConfig monitorConfig = monitorConfig(mostSpecificMethod); + if (monitorConfig == null) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not monitoring %s because Sentry Crons can't express its @Scheduled schedule.", + taskName); + return invocation.proceed(); + } + final @NotNull String monitorSlug = scheduledTaskSlug(taskName); + warnOnSlugCollision(monitorSlug, targetClass, mostSpecificMethod); + return captureCheckIns(invocation, monitorSlug, false, monitorConfig); + } return invocation.proceed(); } @@ -90,6 +152,19 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl return invocation.proceed(); } + final @Nullable MonitorConfig monitorConfig = + !isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig() + ? monitorConfig(mostSpecificMethod) + : null; + return captureCheckIns(invocation, monitorSlug, isHeartbeatOnly, monitorConfig); + } + + private Object captureCheckIns( + final @NotNull MethodInvocation invocation, + final @NotNull String monitorSlug, + final boolean isHeartbeatOnly, + final @Nullable MonitorConfig monitorConfig) + throws Throwable { try (final @NotNull ISentryLifecycleToken ignored = scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) { TracingUtils.startNewTrace(scopes); @@ -101,7 +176,9 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl try { if (!isHeartbeatOnly) { - checkInId = scopes.captureCheckIn(new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS)); + final @NotNull CheckIn inProgress = new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS); + inProgress.setMonitorConfig(monitorConfig); + checkInId = scopes.captureCheckIn(inProgress); } return invocation.proceed(); } catch (Throwable e) { @@ -116,6 +193,146 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } } + private void warnOnSlugCollision( + final @NotNull String monitorSlug, + final @NotNull Class targetClass, + final @NotNull Method method) { + final @NotNull String identity = + ClassUtils.getUserClass(targetClass).getName() + "." + method.getName(); + final @Nullable String owner = derivedSlugOwners.putIfAbsent(monitorSlug, identity); + if (owner != null && !owner.equals(identity) && collidingSlugs.add(monitorSlug)) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them.", + monitorSlug, + owner, + identity); + } + } + + static boolean isScheduled(final @NotNull Method method) { + return !AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class) + .isEmpty(); + } + + /** {@code ClassName.methodName}, with nested classes as {@code Outer.Inner}. */ + static @NotNull String scheduledTaskName( + final @NotNull Class targetClass, final @NotNull Method method) { + return ClassUtils.getShortName(ClassUtils.getUserClass(targetClass)) + "." + method.getName(); + } + + static @NotNull String scheduledTaskSlug(final @NotNull String taskName) { + final @NotNull String slug = + WORD_BOUNDARY + .matcher(taskName) + .replaceAll("-") + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+|-+$", ""); + if (slug.length() <= MAX_SLUG_LENGTH) { + return slug; + } + // keep truncated slugs unique + final @NotNull CRC32 crc = new CRC32(); + crc.update(slug.getBytes(StandardCharsets.UTF_8)); + final @NotNull String hash = String.format(Locale.ROOT, "%08x", crc.getValue()); + return slug.substring(0, MAX_SLUG_LENGTH - hash.length() - 1).replaceAll("-+$", "") + + "-" + + hash; + } + + private @Nullable MonitorConfig monitorConfig(final @NotNull Method method) { + return monitorConfigs.computeIfAbsent( + method, key -> new CachedMonitorConfig(monitorConfigFromScheduled(key))) + .config; + } + + private @Nullable MonitorConfig monitorConfigFromScheduled(final @NotNull Method method) { + try { + final @NotNull Set schedules = + AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class); + if (schedules.size() != 1) { + return null; + } + final @NotNull Scheduled scheduled = schedules.iterator().next(); + // timeUnit only exists from Spring 5.3.10, so read it as an attribute + final @Nullable Object timeUnitAttribute = + AnnotationUtils.getAnnotationAttributes(scheduled).get("timeUnit"); + final @NotNull TimeUnit timeUnit = + timeUnitAttribute instanceof TimeUnit + ? (TimeUnit) timeUnitAttribute + : TimeUnit.MILLISECONDS; + final @Nullable String cron = resolve(scheduled.cron()); + // Spring only reads the zone for a cron + final @Nullable String zone = + cron == null || cron.isEmpty() ? null : resolve(scheduled.zone()); + return MonitorConfigUtils.fromSpringScheduled( + cron, + zone, + periodMillis(scheduled.fixedRate(), scheduled.fixedRateString(), timeUnit), + periodMillis(scheduled.fixedDelay(), scheduled.fixedDelayString(), timeUnit), + LEGACY_CRON_PARSER); + } catch (RuntimeException e) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.WARNING, "Could not derive a monitor config from @Scheduled.", e); + return null; + } + } + + private @Nullable Long periodMillis( + final long value, final @NotNull String valueString, final @NotNull TimeUnit timeUnit) { + if (value >= 0) { + return timeUnit.toMillis(value); + } + final @Nullable String resolved = resolve(valueString); + if (resolved == null || resolved.isEmpty()) { + return null; + } + final @NotNull String trimmed = resolved.trim(); + @Nullable Long millis = null; + if (trimmed.startsWith("P") || trimmed.startsWith("p")) { + try { + millis = Duration.parse(trimmed).toMillis(); + } catch (DateTimeParseException | ArithmeticException e) { + // logged below + } + } else { + millis = MonitorConfigUtils.parsePeriodMillis(trimmed, timeUnit); + } + if (millis == null) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not sending a monitor config because the @Scheduled period '%s' could not be parsed.", + trimmed); + } + return millis; + } + + private @Nullable String resolve(final @NotNull String value) { + if (resolver == null || value.isEmpty()) { + return value; + } + return resolver.resolveStringValue(value); + } + + private static final class CachedMonitorConfig { + private final @Nullable MonitorConfig config; + + private CachedMonitorConfig(final @Nullable MonitorConfig config) { + this.config = config; + } + } + @Override public void setEmbeddedValueResolver(StringValueResolver resolver) { this.resolver = resolver; diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryScheduledCheckInPointcut.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryScheduledCheckInPointcut.java new file mode 100644 index 00000000000..16412849f61 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryScheduledCheckInPointcut.java @@ -0,0 +1,66 @@ +package io.sentry.spring.checkin; + +import io.sentry.FilterString; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.aop.support.AopUtils; +import org.springframework.aop.support.StaticMethodMatcherPointcut; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.ClassUtils; + +/** + * Matches {@code @Scheduled} methods without {@link SentryCheckIn}, except excluded ones. Excludes + * match the task name ({@code ClassName.methodName}) exactly or as a regex. + */ +@ApiStatus.Internal +public final class SentryScheduledCheckInPointcut extends StaticMethodMatcherPointcut { + private final @NotNull List excludedScheduledTasks = new ArrayList<>(); + + public SentryScheduledCheckInPointcut(final @Nullable List excludedScheduledTasks) { + if (excludedScheduledTasks != null) { + for (final @NotNull String task : excludedScheduledTasks) { + if (!task.trim().isEmpty()) { + this.excludedScheduledTasks.add(new FilterString(task.trim())); + } + } + } + } + + @Override + public boolean matches(final @NotNull Method method, final @NotNull Class targetClass) { + final @NotNull Class userClass = ClassUtils.getUserClass(targetClass); + // CGLIB can't intercept these, and Spring calls private and static methods on the target + final int modifiers = method.getModifiers(); + if (Modifier.isFinal(userClass.getModifiers()) + || Modifier.isFinal(modifiers) + || Modifier.isPrivate(modifiers) + || Modifier.isStatic(modifiers)) { + return false; + } + final @NotNull Method specificMethod = AopUtils.getMostSpecificMethod(method, userClass); + if (!SentryCheckInAdvice.isScheduled(specificMethod) + || AnnotationUtils.findAnnotation(specificMethod, SentryCheckIn.class) != null) { + return false; + } + // Spring calls reactive and suspend methods once and subscribes to the result on each run + if (specificMethod.getParameterCount() > 0 + || ReactiveAdapterRegistry.getSharedInstance().getAdapter(specificMethod.getReturnType()) + != null) { + return false; + } + final @NotNull String taskName = + SentryCheckInAdvice.scheduledTaskName(userClass, specificMethod); + for (final @NotNull FilterString excluded : excludedScheduledTasks) { + if (excluded.getFilterString().equals(taskName) || excluded.matches(taskName)) { + return false; + } + } + return true; + } +} diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt index 2c5e7d0cd06..0bac0df528c 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt @@ -4,17 +4,22 @@ import io.sentry.CheckIn import io.sentry.CheckInStatus import io.sentry.IScopes import io.sentry.ISentryLifecycleToken +import io.sentry.MonitorConfig import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.protocol.SentryId import io.sentry.spring.checkin.SentryCheckIn import io.sentry.spring.checkin.SentryCheckInAdviceConfiguration import io.sentry.spring.checkin.SentryCheckInPointcutConfiguration +import java.util.TimeZone +import java.util.concurrent.TimeUnit import kotlin.RuntimeException import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame import org.junit.jupiter.api.assertThrows import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -33,6 +38,8 @@ import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.EnableAspectJAutoProxy import org.springframework.context.annotation.Import import org.springframework.context.support.PropertySourcesPlaceholderConfigurer +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.scheduling.annotation.Schedules import org.springframework.test.context.TestPropertySource import org.springframework.test.context.junit.jupiter.SpringJUnitConfig import org.springframework.test.context.junit4.SpringRunner @@ -40,7 +47,15 @@ import org.springframework.util.StringValueResolver @RunWith(SpringRunner::class) @SpringJUnitConfig(SentryCheckInAdviceTest.Config::class) -@TestPropertySource(properties = ["my.cron.slug = mypropertycronslug"]) +@TestPropertySource( + properties = + [ + "my.cron.slug = mypropertycronslug", + "my.cron.schedule = 0 30 2 * * *", + "my.cron.zone = America/New_York", + "my.cron.empty.zone = ", + ] +) class SentryCheckInAdviceTest { @Autowired lateinit var sampleService: SampleService @@ -51,6 +66,10 @@ class SentryCheckInAdviceTest { @Autowired lateinit var sampleServiceSpringProperties: SampleServiceSpringProperties + @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled + + @Autowired lateinit var sampleServiceInheritedSchedule: SampleServiceInheritedSchedule + @Autowired lateinit var scopes: IScopes val lifecycleToken = mock() @@ -75,6 +94,7 @@ class SentryCheckInAdviceTest { val inProgressCheckIn = checkInCaptor.firstValue assertEquals("monitor_slug_1", inProgressCheckIn.monitorSlug) assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status) + assertNull(inProgressCheckIn.monitorConfig) val doneCheckIn = checkInCaptor.lastValue assertEquals("monitor_slug_1", doneCheckIn.monitorSlug) @@ -229,6 +249,160 @@ class SentryCheckInAdviceTest { order.verify(lifecycleToken).close() } + @Test + fun `cron with zone is sent as crontab monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZone() } + assertNotNull(config) + assertEquals("crontab", config.schedule.type) + assertEquals("15 10 * * 1-5", config.schedule.value) + assertNull(config.schedule.unit) + assertEquals("Europe/Vienna", config.timezone) + } + + @Test + fun `cron without zone is sent in the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + val config = inProgressMonitorConfig { sampleServiceScheduled.cron() } + assertEquals("0 2 * * *", config?.schedule?.value) + assertEquals("Asia/Tokyo", config?.timezone) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `cron and zone placeholders are resolved`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronFromProperties() } + assertEquals("30 2 * * *", config?.schedule?.value) + assertEquals("America/New_York", config?.timezone) + } + + @Test + fun `fixed rate with time unit is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + assertNotNull(config) + assertEquals("interval", config.schedule.type) + assertEquals("2", config.schedule.value) + assertEquals("hour", config.schedule.unit) + } + + @Test + fun `ISO-8601 fixed rate string is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateIso() } + assertEquals("10", config?.schedule?.value) + assertEquals("minute", config?.schedule?.unit) + } + + @Test + fun `simple duration fixed rate string is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateSimpleDuration() } + assertEquals("interval", config?.schedule?.type) + assertEquals("5", config?.schedule?.value) + assertEquals("minute", config?.schedule?.unit) + } + + @Test + fun `fixed delay sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelay() }) + assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelayIso() }) + } + + @Test + fun `disabled cron sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.disabledCron() }) + } + + @Test + fun `zone placeholder resolving to empty uses the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithEmptyZone() } + assertEquals("0 3 * * *", config?.schedule?.value) + assertEquals("Asia/Tokyo", config?.timezone) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `cron zone overrides the default timezone from options`() { + val options = + SentryOptions().apply { + cron = SentryOptions.Cron().apply { defaultTimezone = "Europe/Berlin" } + } + whenever(scopes.options).thenReturn(options) + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZoneAndDefaults() } + assertEquals("Europe/Vienna", config?.timezone) + } + + @Test + fun `unresolvable cron placeholder sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.unresolvableCron() }) + } + + @Test + fun `exception while deriving monitor config sends no monitor config and runs the job`() { + var result = 0 + assertNull(inProgressMonitorConfig { result = sampleServiceScheduled.derivationThrows() }) + assertEquals(1, result) + } + + @Test + fun `monitor config is derived once per method`() { + val first = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + val second = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + assertNotNull(first) + assertSame(first, second) + } + + @Test + fun `multiple schedules send no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.multipleSchedules() }) + } + + @Test + fun `schedules container sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.schedulesContainer() }) + } + + @Test + fun `upsertMonitorConfig false sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDisabled() }) + } + + @Test + fun `upsertMonitorConfig defaults to false`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDefault() }) + } + + @Test + fun `@Scheduled on an overridden parent method sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceInheritedSchedule.job() }) + } + + @Test + fun `heartbeat with @Scheduled sends a single check-in without monitor config`() { + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + sampleServiceScheduled.heartbeat() + assertEquals(1, checkInCaptor.allValues.size) + assertEquals(CheckInStatus.OK.apiName(), checkInCaptor.firstValue.status) + assertNull(checkInCaptor.firstValue.monitorConfig) + } + + private fun inProgressMonitorConfig(block: () -> Unit): MonitorConfig? { + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + block() + assertEquals(2, checkInCaptor.allValues.size) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkInCaptor.firstValue.status) + assertNull(checkInCaptor.lastValue.monitorConfig) + return checkInCaptor.firstValue.monitorConfig + } + @Configuration @EnableAspectJAutoProxy(proxyTargetClass = true) @Import(SentryCheckInAdviceConfiguration::class, SentryCheckInPointcutConfiguration::class) @@ -242,6 +416,10 @@ class SentryCheckInAdviceTest { @Bean open fun sampleServiceSpringProperties() = SampleServiceSpringProperties() + @Bean open fun sampleServiceScheduled() = SampleServiceScheduled() + + @Bean open fun sampleServiceInheritedSchedule() = SampleServiceInheritedSchedule() + @Bean open fun scopes(): IScopes { val scopes = mock() @@ -292,6 +470,88 @@ class SentryCheckInAdviceTest { open fun helloExceptionProperty() = 1 } + open class SampleServiceScheduled { + + @SentryCheckIn("cron_zone", upsertMonitorConfig = true) + @Scheduled(cron = "0 15 10 * * MON-FRI", zone = "Europe/Vienna") + open fun cronWithZone() {} + + @SentryCheckIn("cron", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 2 * * *") + open fun cron() {} + + @SentryCheckIn("cron_properties", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}") + open fun cronFromProperties() {} + + @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true) + @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS) + open fun fixedRateHours() {} + + @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true) + @Scheduled(fixedRateString = "PT10M") + open fun fixedRateIso() {} + + @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true) + @Scheduled(fixedRateString = "5m") + open fun fixedRateSimpleDuration() {} + + @SentryCheckIn("fixed_delay", upsertMonitorConfig = true) + @Scheduled(fixedDelay = 300_000) + open fun fixedDelay() {} + + @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true) + @Scheduled(fixedDelayString = "PT10M") + open fun fixedDelayIso() {} + + @SentryCheckIn("disabled_cron", upsertMonitorConfig = true) + @Scheduled(cron = "-") + open fun disabledCron() {} + + @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}") + open fun cronWithEmptyZone() {} + + @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna") + open fun cronWithZoneAndDefaults() {} + + @SentryCheckIn("schedules_container", upsertMonitorConfig = true) + @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *")) + open fun schedulesContainer() {} + + @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.missing}") + open fun unresolvableCron() {} + + @SentryCheckIn("derivation_throws", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.exception.property}") + open fun derivationThrows() = 1 + + @SentryCheckIn("multiple", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + @Scheduled(cron = "0 0 13 * * *") + open fun multipleSchedules() {} + + @SentryCheckIn("upsert_disabled", upsertMonitorConfig = false) + @Scheduled(cron = "0 0 1 * * *") + open fun upsertDisabled() {} + + @SentryCheckIn("upsert_default") @Scheduled(cron = "0 0 1 * * *") open fun upsertDefault() {} + + @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + open fun heartbeat() {} + } + + open class ScheduledParent { + @Scheduled(cron = "0 0 1 * * *") open fun job() {} + } + + open class SampleServiceInheritedSchedule : ScheduledParent() { + @SentryCheckIn("inherited", upsertMonitorConfig = true) override fun job() {} + } + class MyPropertyPlaceholderConfigurer : PropertySourcesPlaceholderConfigurer() { override fun doProcessProperties( diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt new file mode 100644 index 00000000000..1af18fe95f0 --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt @@ -0,0 +1,365 @@ +package io.sentry.spring.checkin + +import io.sentry.CheckIn +import io.sentry.CheckInStatus +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +import java.lang.reflect.Modifier +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.jupiter.api.assertThrows +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeast +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.springframework.aop.Advisor +import org.springframework.aop.Pointcut +import org.springframework.aop.framework.ProxyFactory +import org.springframework.aop.support.AopUtils +import org.springframework.aop.support.DefaultPointcutAdvisor +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.context.annotation.Import +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import org.springframework.test.context.junit4.SpringRunner +import reactor.core.publisher.Mono + +@RunWith(SpringRunner::class) +@SpringJUnitConfig(SentryScheduledCheckInTest.Config::class) +@TestPropertySource(properties = ["my.cron.schedule = 0 30 2 * * *"]) +class SentryScheduledCheckInTest { + private val SLUG = "sentry-scheduled-check-in-test-jobs-sync-users" + private val COLLISION_WARNING = + "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them." + + @Autowired lateinit var jobs: Jobs + + @Autowired lateinit var excludedJobs: ExcludedJobs + + @Autowired lateinit var finalJobs: FinalJobs + + @Autowired lateinit var overridingJobs: OverridingJobs + + @Autowired lateinit var scopes: IScopes + + @BeforeTest + fun setup() { + reset(scopes) + whenever(scopes.options).thenReturn(SentryOptions()) + whenever(scopes.forkedScopes(any())).thenReturn(scopes) + whenever(scopes.makeCurrent()).thenReturn(mock()) + whenever(scopes.captureCheckIn(any())).thenReturn(SentryId()) + } + + @Test + fun `scheduled method without @SentryCheckIn is monitored with a derived slug and config`() { + val checkIns = capture { jobs.syncUsers() } + assertEquals(2, checkIns.size) + assertEquals(SLUG, checkIns[0].monitorSlug) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkIns[0].status) + val config = assertNotNull(checkIns[0].monitorConfig) + assertEquals("0 2 * * *", config.schedule.value) + assertEquals("Europe/Vienna", config.timezone) + assertEquals(SLUG, checkIns[1].monitorSlug) + assertEquals(CheckInStatus.OK.apiName(), checkIns[1].status) + } + + @Test + fun `cron placeholder is resolved`() { + val checkIns = capture { jobs.fromProperty() } + assertEquals("30 2 * * *", checkIns[0].monitorConfig!!.schedule.value) + } + + @Test + fun `tasks with unsupported schedules are not monitored`() { + jobs.fixedDelay() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `@SentryCheckIn with an unsupported schedule checks in without config`() { + val checkIns = capture { jobs.explicitFixedDelay() } + assertEquals(listOf("explicit-delay", "explicit-delay"), checkIns.map { it.monitorSlug }) + assertNull(checkIns[0].monitorConfig) + } + + @Test + fun `failing job sends an error check-in and rethrows`() { + val checkIns = capture { assertThrows { jobs.oops() } } + assertEquals(CheckInStatus.ERROR.apiName(), checkIns[1].status) + } + + @Test + fun `@SentryCheckIn keeps its slug and is only checked in once`() { + val checkIns = capture { jobs.explicit() } + assertEquals(listOf("explicit", "explicit"), checkIns.map { it.monitorSlug }) + assertNull(checkIns[0].monitorConfig) + } + + @Test + fun `methods without @Scheduled are not monitored`() { + jobs.notScheduled() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `overriding methods without @Scheduled are not monitored`() { + overridingJobs.run() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `reactive methods are not monitored`() { + jobs.reactive().block() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `excluded tasks are not monitored`() { + jobs.excluded() + excludedJobs.run() + verify(scopes, never()).captureCheckIn(any()) + assertFalse(AopUtils.isAopProxy(excludedJobs)) + } + + @Test + fun `final classes are not proxied`() { + finalJobs.run() + verify(scopes, never()).captureCheckIn(any()) + assertFalse(AopUtils.isAopProxy(finalJobs)) + assertTrue(AopUtils.isAopProxy(jobs)) + } + + @Test + fun `private and static methods are not matched`() { + val pointcut = SentryScheduledCheckInPointcut(null) + val privateMethod = HiddenJobs::class.java.getDeclaredMethod("hidden") + val staticMethod = HiddenJobs::class.java.getDeclaredMethod("static") + assertTrue(Modifier.isPrivate(privateMethod.modifiers)) + assertTrue(Modifier.isStatic(staticMethod.modifiers)) + assertFalse(pointcut.matches(privateMethod, HiddenJobs::class.java)) + assertFalse(pointcut.matches(staticMethod, HiddenJobs::class.java)) + assertTrue(pointcut.matches(Jobs::class.java.getMethod("syncUsers"), Jobs::class.java)) + } + + @Test + fun `slugs are kebab case and at most 50 characters`() { + assertEquals( + "report-jobs-send-daily-report", + SentryCheckInAdvice.scheduledTaskSlug("ReportJobs.sendDailyReport"), + ) + assertEquals( + "http-client-jobs-run", + SentryCheckInAdvice.scheduledTaskSlug("HTTPClientJobs.run"), + ) + assertEquals("outer-inner-run2-fa", SentryCheckInAdvice.scheduledTaskSlug("Outer.Inner.run2FA")) + val first = + SentryCheckInAdvice.scheduledTaskSlug( + "AVeryLongScheduledJobClassNameForReporting.firstMethod" + ) + val second = + SentryCheckInAdvice.scheduledTaskSlug( + "AVeryLongScheduledJobClassNameForReporting.secondMethod" + ) + assertTrue(first.length <= 50) + assertTrue(first.startsWith("a-very-long-scheduled-job-class-name-for-")) + assertTrue(first != second) + assertEquals( + first, + SentryCheckInAdvice.scheduledTaskSlug( + "AVeryLongScheduledJobClassNameForReporting.firstMethod" + ), + ) + } + + @Test + fun `task name uses the user class`() { + assertEquals( + "SentryScheduledCheckInTest.Jobs.syncUsers", + SentryCheckInAdvice.scheduledTaskName( + jobs.javaClass, + Jobs::class.java.getMethod("syncUsers"), + ), + ) + } + + @Test + fun `warns once when two methods derive the same slug`() { + val logger = mockLogger() + val advice = SentryCheckInAdvice(scopes, true) + val cleanupJob = proxy(CleanupJob(), advice) + val cleanup = proxy(Cleanup(), advice) + + cleanupJob.run() + cleanup.jobRun() + cleanup.jobRun() + cleanupJob.run() + + verify(logger, times(1)) + .log( + eq(SentryLevel.WARNING), + eq(COLLISION_WARNING), + eq("sentry-scheduled-check-in-test-cleanup-job-run"), + eq("${CleanupJob::class.java.name}.run"), + eq("${Cleanup::class.java.name}.jobRun"), + ) + } + + @Test + fun `does not warn when derived slugs differ or the same method runs again`() { + val logger = mockLogger() + val advice = SentryCheckInAdvice(scopes, true) + val cleanupJob = proxy(CleanupJob(), advice) + val otherJobs = proxy(Jobs(), advice) + + cleanupJob.run() + cleanupJob.run() + otherJobs.syncUsers() + + verify(logger, never()).log(eq(SentryLevel.WARNING), eq(COLLISION_WARNING), any(), any(), any()) + } + + private fun mockLogger(): ILogger { + val logger = mock() + val options = + SentryOptions().apply { + isDebug = true + setLogger(logger) + } + whenever(scopes.options).thenReturn(options) + return logger + } + + private fun proxy(target: T, advice: SentryCheckInAdvice): T { + val factory = ProxyFactory(target) + factory.isProxyTargetClass = true + factory.addAdvice(advice) + @Suppress("UNCHECKED_CAST") + return factory.proxy as T + } + + private fun capture(block: () -> Unit): List { + val captor = argumentCaptor() + block() + verify(scopes, atLeast(0)).captureCheckIn(captor.capture()) + return captor.allValues + } + + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(SentryCheckInPointcutConfiguration::class) + open class Config { + + @Bean open fun jobs() = Jobs() + + @Bean open fun excludedJobs() = ExcludedJobs() + + @Bean open fun finalJobs() = FinalJobs() + + @Bean open fun overridingJobs() = OverridingJobs() + + @Bean open fun scopes(): IScopes = mock() + + @Bean + open fun sentryCheckInAdvisor( + @Qualifier("sentryCheckInPointcut") pointcut: Pointcut, + scopes: IScopes, + ): Advisor = DefaultPointcutAdvisor(pointcut, SentryCheckInAdvice(scopes)) + + @Bean open fun sentryScheduledCheckInAdvice(scopes: IScopes) = SentryCheckInAdvice(scopes, true) + + @Bean + open fun sentryScheduledCheckInAdvisor( + @Qualifier("sentryScheduledCheckInAdvice") advice: SentryCheckInAdvice + ): Advisor = + DefaultPointcutAdvisor( + SentryScheduledCheckInPointcut( + listOf("SentryScheduledCheckInTest.Jobs.excluded", ".*\\.ExcludedJobs\\..*", " ") + ), + advice, + ) + + companion object { + @Bean + @JvmStatic + fun propertySourcesPlaceholderConfigurer() = PropertySourcesPlaceholderConfigurer() + } + } + + open class Jobs { + @Scheduled(cron = "0 0 2 * * *", zone = "Europe/Vienna") open fun syncUsers() {} + + @Scheduled(cron = "\${my.cron.schedule}") open fun fromProperty() {} + + @Scheduled(fixedDelay = 1000) open fun fixedDelay() {} + + @Scheduled(cron = "0 0 2 * * *") + open fun oops() { + throw IllegalStateException("thrown on purpose") + } + + @SentryCheckIn("explicit") @Scheduled(cron = "0 0 2 * * *") open fun explicit() {} + + @SentryCheckIn("explicit-delay") @Scheduled(fixedDelay = 1000) open fun explicitFixedDelay() {} + + @Scheduled(cron = "0 0 2 * * *") open fun excluded() {} + + open fun notScheduled() {} + + @Scheduled(cron = "0 0 2 * * *") open fun reactive(): Mono = Mono.empty() + } + + open class CleanupJob { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + open class Cleanup { + @Scheduled(cron = "0 0 2 * * *") open fun jobRun() {} + } + + open class HiddenJobs { + @Scheduled(cron = "0 0 2 * * *") private fun hidden() {} + + companion object { + @JvmStatic @Scheduled(cron = "0 0 2 * * *") fun static() {} + } + } + + open class ExcludedJobs { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + class FinalJobs { + @Scheduled(cron = "0 0 2 * * *") fun run() {} + } + + open class ParentJobs { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + open class OverridingJobs : ParentJobs() { + override fun run() {} + } +} diff --git a/sentry-test-support/build.gradle.kts b/sentry-test-support/build.gradle.kts index 2539beccb5f..b332f035958 100644 --- a/sentry-test-support/build.gradle.kts +++ b/sentry-test-support/build.gradle.kts @@ -4,6 +4,8 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.spotless") + id("io.sentry.jdk-target") } tasks.withType().configureEach { diff --git a/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt index e799d7520e6..31944ec22cb 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt @@ -8,6 +8,9 @@ import java.util.concurrent.TimeUnit * Advancing by an amount *and a unit* is the point: a stubbed `thenReturn(1001)` against a * nanosecond ticker is off by a factor of a million and still compiles, whereas `advance(1001, * MILLISECONDS)` cannot be. + * + * The tick is volatile so that a test thread can advance the ticker while the code under test reads + * it from another thread. */ class TestMonotonicTicker(@Volatile private var nanos: Long = 0) : MonotonicTicker { override fun tickNanos(): Long = nanos diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index f5dba14a31a..b3c312169a3 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7816,6 +7816,12 @@ public final class io/sentry/util/MapObjectWriter : io/sentry/ObjectWriter { public fun value (Z)Lio/sentry/util/MapObjectWriter; } +public final class io/sentry/util/MonitorConfigUtils { + public static fun fromSchedule (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;)Lio/sentry/MonitorConfig; + public static fun fromSpringScheduled (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Z)Lio/sentry/MonitorConfig; + public static fun parsePeriodMillis (Ljava/lang/String;Ljava/util/concurrent/TimeUnit;)Ljava/lang/Long; +} + public final class io/sentry/util/Objects { public static fun equals (Ljava/lang/Object;Ljava/lang/Object;)Z public static fun hash ([Ljava/lang/Object;)I @@ -7984,11 +7990,12 @@ public final class io/sentry/util/network/NetworkBody$NetworkBodyWarning : java/ public final class io/sentry/util/network/NetworkBodyParser { public static fun fromBytes ([BLjava/lang/String;Ljava/lang/String;ILio/sentry/ILogger;)Lio/sentry/util/network/NetworkBody; + public static fun fromBytes ([BLjava/lang/String;Ljava/lang/String;IZLio/sentry/ILogger;)Lio/sentry/util/network/NetworkBody; } public final class io/sentry/util/network/NetworkDetailCaptureUtils { public static fun createRequest (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Ljava/util/List;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse; - public static fun createResponse (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Ljava/util/List;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse; + public static fun createResponse (Ljava/lang/Object;Ljava/lang/Integer;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Ljava/util/List;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse; public static fun initializeForUrl (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)Lio/sentry/util/network/NetworkRequestData; } @@ -8009,15 +8016,17 @@ public final class io/sentry/util/network/NetworkRequestData { public fun getResponseBodySize ()Ljava/lang/Long; public fun getStatusCode ()Ljava/lang/Integer; public fun setRequestDetails (Lio/sentry/util/network/ReplayNetworkRequestOrResponse;)V - public fun setResponseDetails (ILio/sentry/util/network/ReplayNetworkRequestOrResponse;)V + public fun setResponseDetails (Lio/sentry/util/network/ReplayNetworkRequestOrResponse;)V public fun toString ()Ljava/lang/String; } public final class io/sentry/util/network/ReplayNetworkRequestOrResponse { + public fun (Ljava/lang/Integer;Ljava/lang/Long;Lio/sentry/util/network/NetworkBody;Ljava/util/Map;)V public fun (Ljava/lang/Long;Lio/sentry/util/network/NetworkBody;Ljava/util/Map;)V public fun getBody ()Lio/sentry/util/network/NetworkBody; public fun getHeaders ()Ljava/util/Map; public fun getSize ()Ljava/lang/Long; + public fun getStatusCode ()Ljava/lang/Integer; public fun toString ()Ljava/lang/String; } diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 5428fa1722f..6b07d9d746d 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -9,6 +9,9 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.spotless") + id("io.sentry.jdk-target") + id("io.sentry.publish") id("io.sentry.api-validation") } diff --git a/sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java b/sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java index b5b000f2805..fab92fd1b9a 100644 --- a/sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java +++ b/sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java @@ -9,6 +9,11 @@ public final class JavaMonotonicTicker implements MonotonicTicker { private static final JavaMonotonicTicker instance = new JavaMonotonicTicker(); + /** + * Prefer {@link io.sentry.SentryOptions#getMonotonicTicker()}, which picks the platform's ticker. + * On Android this one stops counting during device suspend, so intervals spanning a deep sleep + * come out short. + */ public static @NotNull MonotonicTicker getInstance() { return instance; } diff --git a/sentry/src/main/java/io/sentry/time/SystemEpochClock.java b/sentry/src/main/java/io/sentry/time/SystemEpochClock.java index 2cb037c0e09..b11eb18fb72 100644 --- a/sentry/src/main/java/io/sentry/time/SystemEpochClock.java +++ b/sentry/src/main/java/io/sentry/time/SystemEpochClock.java @@ -24,6 +24,10 @@ public final class SystemEpochClock implements EpochClock { private static final SystemEpochClock instance = new SystemEpochClock(); + /** + * Prefer {@link io.sentry.SentryOptions#getEpochClock()}, which is where a platform would + * substitute its own wall clock, the way Android already substitutes the ticker. + */ public static @NotNull EpochClock getInstance() { return instance; } diff --git a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java new file mode 100644 index 00000000000..4058529b12f --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java @@ -0,0 +1,507 @@ +package io.sentry.util; + +import io.sentry.MonitorConfig; +import io.sentry.MonitorSchedule; +import io.sentry.MonitorScheduleUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Converts scheduler definitions into a {@link MonitorConfig}. */ +@ApiStatus.Internal +public final class MonitorConfigUtils { + private static final @NotNull String CRON_DISABLED = "-"; + private static final long MINUTE_MILLIS = TimeUnit.MINUTES.toMillis(1); + private static final long MIN_CHECKIN_MARGIN_WITH_SECONDS = 2; + private static final @NotNull Map CRON_MACROS; + private static final @NotNull Pattern SIMPLE_DURATION = + Pattern.compile("^([+-]?\\d+)([a-zA-Z]{0,2})$"); + private static final @NotNull Pattern FIXED_OFFSET = + Pattern.compile("^(?:GMT|UTC)?([+-])(\\d{1,2})(?::?(\\d{2}))?$"); + private static final @NotNull Pattern DIGITS = Pattern.compile("\\d+"); + + private static final int DAY_OF_MONTH = 2; + private static final int MONTH = 3; + private static final int DAY_OF_WEEK = 4; + private static final int[] FIELD_MIN = {0, 0, 1, 1, 0}; + private static final int[] FIELD_MAX = {59, 23, 31, 12, 7}; + private static final int LAST = -1; + private static final int[] DAYS_IN_MONTH = {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + private static final @NotNull List MONTH_NAMES = + Arrays.asList( + "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"); + private static final @NotNull List DAY_NAMES = + Arrays.asList("SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"); + + /** IANA zones without a slash; Java also knows non-IANA short IDs like PST. */ + private static final @NotNull Set TOP_LEVEL_ZONES = + new HashSet<>( + Arrays.asList( + ("CET CST6CDT Cuba EET EST EST5EDT Egypt Eire GB GB-Eire GMT GMT+0 GMT-0 GMT0 " + + "Greenwich HST Hongkong Iceland Iran Israel Jamaica Japan Kwajalein Libya " + + "MET MST MST7MDT NZ NZ-CHAT Navajo PRC PST8PDT Poland Portugal ROC ROK " + + "Singapore Turkey UCT UTC Universal W-SU WET Zulu") + .split(" "))); + + private static volatile @Nullable Set availableZoneIds; + + static { + final @NotNull Map macros = new HashMap<>(); + macros.put("@yearly", "0 0 1 1 *"); + macros.put("@annually", "0 0 1 1 *"); + macros.put("@monthly", "0 0 1 * *"); + macros.put("@weekly", "0 0 * * 0"); + macros.put("@daily", "0 0 * * *"); + macros.put("@midnight", "0 0 * * *"); + macros.put("@hourly", "0 * * * *"); + CRON_MACROS = Collections.unmodifiableMap(macros); + } + + private MonitorConfigUtils() {} + + /** + * Builds a monitor config from exactly one of a cron or a fixed interval. + * + * @param cron 6 field cron (seconds first) or macro, null or empty if unset + * @param zone cron time zone, ignored for intervals + * @param intervalMillis fixed rate, null if unset + * @return null if the schedule can't be expressed as a Sentry monitor schedule + */ + public static @Nullable MonitorConfig fromSchedule( + final @Nullable String cron, + final @Nullable String zone, + final @Nullable Long intervalMillis) { + final boolean hasCron = cron != null && !cron.isEmpty(); + + if (hasCron && cron != null && intervalMillis == null) { + final @Nullable String crontab = toCrontab(cron); + if (crontab == null) { + return null; + } + final @NotNull MonitorConfig config = new MonitorConfig(MonitorSchedule.crontab(crontab)); + // Runs check in up to 59s after the minute Sentry expects, so a 1 minute margin is too tight + if (hasNonZeroSeconds(cron)) { + final @Nullable Long margin = config.getCheckinMargin(); + if (margin == null || margin < MIN_CHECKIN_MARGIN_WITH_SECONDS) { + config.setCheckinMargin(MIN_CHECKIN_MARGIN_WITH_SECONDS); + } + } + if (zone != null && !zone.isEmpty()) { + final @Nullable String timezone = toTimezone(zone); + if (timezone == null) { + return null; + } + config.setTimezone(timezone); + } + return config; + } + + if (!hasCron && intervalMillis != null) { + final long millis = intervalMillis; + if (millis < MINUTE_MILLIS || millis % MINUTE_MILLIS != 0) { + return null; + } + long value = millis / MINUTE_MILLIS; + @NotNull MonitorScheduleUnit unit = MonitorScheduleUnit.MINUTE; + if (value % (24 * 60) == 0) { + value /= 24 * 60; + unit = MonitorScheduleUnit.DAY; + } else if (value % 60 == 0) { + value /= 60; + unit = MonitorScheduleUnit.HOUR; + } + if (value > Integer.MAX_VALUE) { + return null; + } + return new MonitorConfig(MonitorSchedule.interval((int) value, unit)); + } + return null; + } + + private static boolean hasNonZeroSeconds(final @NotNull String cron) { + final @NotNull String seconds = cron.trim().split("\\s+", -1)[0]; + return DIGITS.matcher(seconds).matches() && Integer.parseInt(seconds) > 0; + } + + /** + * Builds a monitor config from Spring {@code @Scheduled} values with placeholders resolved. Reads + * the cron and a cron without zone the way Spring does. + * + * @param cron Spring cron, null or empty if unset + * @param zone cron time zone, null or empty for the JVM default zone + * @param fixedRateMillis null if unset + * @param fixedDelayMillis null if unset; a fixed delay never gets a config + * @param legacyCronParser true for Spring before 5.3, which parses crons with {@code + * CronSequenceGenerator} + * @return null if the schedule can't be expressed as a Sentry monitor schedule + */ + public static @Nullable MonitorConfig fromSpringScheduled( + final @Nullable String cron, + final @Nullable String zone, + final @Nullable Long fixedRateMillis, + final @Nullable Long fixedDelayMillis, + final boolean legacyCronParser) { + // runs start a delay after the previous run ends, so they drift from any interval + if (fixedDelayMillis != null) { + return null; + } + final @Nullable String crontabCron = + legacyCronParser ? toCrontabLegacy(cron) : toCrontabDaysOfWeek(cron); + @Nullable String cronZone = null; + if (crontabCron != null && !crontabCron.isEmpty()) { + cronZone = zone == null || zone.isEmpty() ? TimeZone.getDefault().getID() : zone; + } + return fromSchedule(crontabCron, cronZone, fixedRateMillis); + } + + /** + * Spring before 5.3 steps day of month {@code *}/n from 0, so it runs on n, 2n, ... Days of week + * already match crontab. Null if both day fields are set, as it then sometimes runs at midnight. + */ + static @Nullable String toCrontabLegacy(final @Nullable String cron) { + if (cron == null) { + return null; + } + final @NotNull String[] fields = cron.trim().split("\\s+", -1); + if (fields.length != 6) { + return cron; + } + if (!isAnyDay(fields[3]) && !isAnyDay(fields[5])) { + return null; + } + final @NotNull StringBuilder daysOfMonth = new StringBuilder(); + for (@NotNull String item : fields[3].split(",", -1)) { + if (item.startsWith("*/")) { + item = item.substring(2) + "-31" + item.substring(1); + } + if (daysOfMonth.length() > 0) { + daysOfMonth.append(','); + } + daysOfMonth.append(item); + } + fields[3] = daysOfMonth.toString(); + return join(fields); + } + + private static boolean isAnyDay(final @NotNull String field) { + return "*".equals(field) || "?".equals(field); + } + + /** + * Spring numbers days of week from Monday with 0 or 7 for Sunday, and starts {@code *} on Monday. + * Rewrites them so crontab reads them the same way. Null for {@code #5}, which Spring 5.3 also + * runs in months without a fifth weekday. + */ + static @Nullable String toCrontabDaysOfWeek(final @Nullable String cron) { + if (cron == null) { + return null; + } + final @NotNull String[] fields = cron.trim().split("\\s+", -1); + if (fields.length != 6) { + return cron; + } + final @NotNull StringBuilder daysOfWeek = new StringBuilder(); + for (@NotNull String item : fields[5].toUpperCase(Locale.ROOT).split(",", -1)) { + for (int i = 0; i < DAY_NAMES.size(); i++) { + item = item.replace(DAY_NAMES.get(i), String.valueOf(i == 0 ? 7 : i)); + } + if (item.matches(".*#0*5")) { + return null; + } + if (item.startsWith("*/")) { + item = "1-7" + item.substring(1); + } else if (item.startsWith("7-7")) { + // Sunday only; 0-7 would be every day + item = "7" + item.substring(3); + } else if (item.startsWith("7-")) { + // Sunday starting a range is 0 in Spring + item = "0" + item.substring(1); + } + if (daysOfWeek.length() > 0) { + daysOfWeek.append(','); + } + daysOfWeek.append(item); + } + fields[5] = daysOfWeek.toString(); + return join(fields); + } + + private static @NotNull String join(final @NotNull String[] fields) { + final @NotNull StringBuilder result = new StringBuilder(); + for (int i = 0; i < fields.length; i++) { + if (i > 0) { + result.append(' '); + } + result.append(fields[i]); + } + return result.toString(); + } + + /** + * Parses a plain number in {@code defaultUnit} or a Spring 6.1 simple duration like {@code 30s}. + * ISO-8601 is not supported. + * + * @return milliseconds, or null if unparseable + */ + public static @Nullable Long parsePeriodMillis( + final @Nullable String value, final @NotNull TimeUnit defaultUnit) { + if (value == null) { + return null; + } + final @NotNull Matcher matcher = SIMPLE_DURATION.matcher(value.trim()); + if (!matcher.matches()) { + return null; + } + final long amount; + try { + amount = Long.parseLong(matcher.group(1)); + } catch (NumberFormatException e) { + return null; + } + final @Nullable TimeUnit unit = timeUnit(matcher.group(2), defaultUnit); + return unit == null ? null : unit.toMillis(amount); + } + + private static @Nullable TimeUnit timeUnit( + final @NotNull String suffix, final @NotNull TimeUnit defaultUnit) { + switch (suffix.toLowerCase(Locale.ROOT)) { + case "": + return defaultUnit; + case "ns": + return TimeUnit.NANOSECONDS; + case "us": + return TimeUnit.MICROSECONDS; + case "ms": + return TimeUnit.MILLISECONDS; + case "s": + return TimeUnit.SECONDS; + case "m": + return TimeUnit.MINUTES; + case "h": + return TimeUnit.HOURS; + case "d": + return TimeUnit.DAYS; + default: + return null; + } + } + + /** + * Returns a zone ID Sentry accepts: IANA region IDs as is, whole-hour fixed offsets like {@code + * GMT+2} as {@code Etc/GMT-2}, null otherwise. + */ + static @Nullable String toTimezone(final @NotNull String zone) { + final @NotNull Matcher offset = FIXED_OFFSET.matcher(zone); + if (offset.matches()) { + final @Nullable String minutes = offset.group(3); + if (minutes != null && !"00".equals(minutes)) { + return null; + } + final int hours = Integer.parseInt(offset.group(2)); + if (hours == 0) { + return "Etc/GMT"; + } + final boolean east = "+".equals(offset.group(1)); + if (hours > (east ? 14 : 12)) { + return null; + } + // Etc/GMT signs are inverted + return "Etc/GMT" + (east ? "-" : "+") + hours; + } + if (TOP_LEVEL_ZONES.contains(zone)) { + return zone; + } + if (zone.indexOf('/') > 0 && !zone.startsWith("SystemV/") && availableZones().contains(zone)) { + return zone; + } + return null; + } + + private static @NotNull Set availableZones() { + @Nullable Set zones = availableZoneIds; + if (zones == null) { + zones = new HashSet<>(Arrays.asList(TimeZone.getAvailableIDs())); + availableZoneIds = zones; + } + return zones; + } + + /** + * Drops a fixed seconds field. Returns null if Sentry would reject the result or read it + * differently, as crontab has no seconds and ORs day of month and day of week. + */ + static @Nullable String toCrontab(final @NotNull String cron) { + final @NotNull String trimmed = cron.trim(); + if (CRON_DISABLED.equals(trimmed)) { + return null; + } + if (trimmed.startsWith("@")) { + return CRON_MACROS.get(trimmed.toLowerCase(Locale.ROOT)); + } + final @NotNull String[] fields = trimmed.split("\\s+", -1); + if (fields.length != 6 || !fields[0].matches("\\d{1,2}") || Integer.parseInt(fields[0]) > 59) { + return null; + } + final @NotNull String[] crontab = new String[5]; + for (int i = 0; i < crontab.length; i++) { + final @NotNull String field = fields[i + 1]; + // '?' means '*' in Spring and Quartz cron + final boolean dayField = i == DAY_OF_MONTH || i == DAY_OF_WEEK; + crontab[i] = dayField && "?".equals(field) ? "*" : field; + } + // crontab ORs day of month and day of week unless one starts with '*', Spring and Quartz AND + if (!crontab[DAY_OF_MONTH].startsWith("*") && !crontab[DAY_OF_WEEK].startsWith("*")) { + return null; + } + final @NotNull List> values = new ArrayList<>(); + for (int i = 0; i < crontab.length; i++) { + final @Nullable Set parsed = parseField(crontab[i].toUpperCase(Locale.ROOT), i); + if (parsed == null) { + return null; + } + values.add(parsed); + } + if (!dayExistsInMonths(values.get(DAY_OF_MONTH), values.get(MONTH))) { + return null; + } + final @NotNull StringBuilder result = new StringBuilder(); + for (int i = 0; i < crontab.length; i++) { + if (i > 0) { + result.append(' '); + } + result.append(crontab[i]); + } + return result.toString(); + } + + // Mirrors the parser Sentry uses (cronsim). Returns null for anything it rejects or that we + // can't send as is. Day of week L and # entries are not expanded, as only the count matters. + private static @Nullable Set parseField(final @NotNull String field, final int index) { + final @NotNull Set result = new HashSet<>(); + for (final @NotNull String term : field.split(",", -1)) { + final @Nullable Set parsed = parseTerm(term, index); + if (parsed == null) { + return null; + } + result.addAll(parsed); + } + return result; + } + + private static @Nullable Set parseTerm(final @NotNull String term, final int index) { + if ("*".equals(term)) { + return range(FIELD_MIN[index], FIELD_MAX[index], 1); + } + if (index == DAY_OF_WEEK && term.indexOf('L') >= 0) { + final @NotNull String day = term.substring(0, term.length() - 1); + if (!term.endsWith("L") || !DIGITS.matcher(day).matches() || value(day, index) == null) { + return null; + } + return Collections.singleton(LAST); + } + if (index == DAY_OF_WEEK && term.indexOf('#') >= 0) { + final int hash = term.indexOf('#'); + final @Nullable Integer nth = number(term.substring(hash + 1)); + if (nth == null || nth < 1 || nth > 5 || value(term.substring(0, hash), index) == null) { + return null; + } + return Collections.singleton(LAST); + } + if (term.indexOf('/') >= 0) { + final int slash = term.indexOf('/'); + final @Nullable Integer step = number(term.substring(slash + 1)); + final @NotNull String base = term.substring(0, slash); + if (step == null || step == 0 || "L".equals(base) || "LW".equals(base)) { + return null; + } + final @Nullable Set items = parseTerm(base, index); + if (items == null) { + return null; + } + if (items.size() == 1) { + // cronsim steps a single value range like 10-10/2 to the field max + if (base.indexOf('-') >= 0) { + return null; + } + return range(items.iterator().next(), FIELD_MAX[index], step); + } + final @NotNull List sorted = new ArrayList<>(items); + Collections.sort(sorted); + final @NotNull Set result = new HashSet<>(); + for (int i = 0; i < sorted.size(); i += step) { + result.add(sorted.get(i)); + } + return result; + } + if (term.indexOf('-') >= 0) { + final int dash = term.indexOf('-'); + final @Nullable Integer start = value(term.substring(0, dash), index); + final @Nullable Integer end = value(term.substring(dash + 1), index); + if (start == null || end == null || end < start) { + return null; + } + return range(start, end, 1); + } + if (index == DAY_OF_MONTH && ("L".equals(term) || "LW".equals(term))) { + return Collections.singleton(LAST); + } + final @Nullable Integer value = value(term, index); + return value == null ? null : Collections.singleton(value); + } + + private static @Nullable Integer value(final @NotNull String value, final int index) { + if (index == MONTH && MONTH_NAMES.contains(value)) { + return MONTH_NAMES.indexOf(value) + 1; + } + if (index == DAY_OF_WEEK && DAY_NAMES.contains(value)) { + return DAY_NAMES.indexOf(value); + } + final @Nullable Integer number = number(value); + if (number == null || number < FIELD_MIN[index] || number > FIELD_MAX[index]) { + return null; + } + return number; + } + + private static @Nullable Integer number(final @NotNull String value) { + if (value.isEmpty() || value.length() > 9 || !DIGITS.matcher(value).matches()) { + return null; + } + return Integer.parseInt(value); + } + + private static @NotNull Set range(final int start, final int end, final int step) { + final @NotNull Set result = new HashSet<>(); + for (int i = start; i <= end; i += step) { + result.add(i); + } + return result; + } + + private static boolean dayExistsInMonths( + final @NotNull Set days, final @NotNull Set months) { + final int firstDay = Collections.min(days); + if (firstDay <= 29) { + return true; + } + for (final int month : months) { + if (firstDay <= DAYS_IN_MONTH[month - 1]) { + return true; + } + } + return false; + } +} diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java b/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java index a7195c0462d..f96fcf2e1a6 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java @@ -34,6 +34,21 @@ private NetworkBodyParser() {} * @param logger Optional logger * @return A NetworkBody or null if bytes are null/empty */ + public static @Nullable NetworkBody fromBytes( + @Nullable final byte[] bytes, + @Nullable final String contentType, + @Nullable final String charset, + final int maxSizeBytes, + @NotNull final ILogger logger) { + return fromBytes(bytes, contentType, charset, maxSizeBytes, false, logger); + } + + /** + * Like {@link #fromBytes(byte[], String, String, int, ILogger)}, for bytes that may be only a + * prefix of the body, e.g. because the stream broke or was closed before it ended. + * + * @param isPartial whether the bytes are known to be a prefix of the body + */ // temp fix for JDK 21: The charset is a name from the Content-Type header; unsupported names are // handled below. @SuppressWarnings("JdkObsolete") @@ -42,13 +57,14 @@ private NetworkBodyParser() {} @Nullable final String contentType, @Nullable final String charset, final int maxSizeBytes, + final boolean isPartial, @NotNull final ILogger logger) { if (bytes == null || bytes.length == 0) { return null; } - final boolean isTruncated = bytes.length > maxSizeBytes; + final boolean isTruncated = isPartial || bytes.length > maxSizeBytes; final long originalByteCount = bytes.length; if (contentType != null && isBinaryContentType(contentType)) { diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java index 251844308bf..5a1f4864013 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java @@ -59,6 +59,7 @@ public interface NetworkHeaderExtractor { return createRequestOrResponseInternal( httpObject, + null, bodySize, networkCaptureBodies, bodyExtractor, @@ -68,6 +69,7 @@ public interface NetworkHeaderExtractor { public static @NotNull ReplayNetworkRequestOrResponse createResponse( @NotNull final T httpObject, + @Nullable final Integer statusCode, @Nullable final Long bodySize, final boolean networkCaptureBodies, @NotNull final NetworkBodyExtractor bodyExtractor, @@ -76,6 +78,7 @@ public interface NetworkHeaderExtractor { return createRequestOrResponseInternal( httpObject, + statusCode, bodySize, networkCaptureBodies, bodyExtractor, @@ -150,6 +153,7 @@ private static boolean shouldCaptureUrl( private static @NotNull ReplayNetworkRequestOrResponse createRequestOrResponseInternal( @NotNull final T httpObject, + @Nullable final Integer statusCode, @Nullable final Long bodySize, final boolean networkCaptureBodies, @NotNull final NetworkBodyExtractor bodyExtractor, @@ -171,6 +175,6 @@ private static boolean shouldCaptureUrl( Map headers = getCaptureHeaders(headerExtractor.extract(httpObject), allowedHeaders); - return new ReplayNetworkRequestOrResponse(effectiveBodySize, body, headers); + return new ReplayNetworkRequestOrResponse(statusCode, effectiveBodySize, body, headers); } } diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkRequestData.java b/sentry/src/main/java/io/sentry/util/network/NetworkRequestData.java index 997e001a35d..d95d1163f1e 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkRequestData.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkRequestData.java @@ -13,11 +13,11 @@ @ApiStatus.Internal public final class NetworkRequestData { private @Nullable final String method; - private @Nullable Integer statusCode; - private @Nullable Long requestBodySize; - private @Nullable Long responseBodySize; - private @Nullable ReplayNetworkRequestOrResponse request; - private @Nullable ReplayNetworkRequestOrResponse response; + + // volatile: both sides are filled in by the thread running the call and reading the body, and + // read by the replay thread. + private volatile @Nullable ReplayNetworkRequestOrResponse request; + private volatile @Nullable ReplayNetworkRequestOrResponse response; public NetworkRequestData(@Nullable final String method) { this.method = method; @@ -28,15 +28,18 @@ public NetworkRequestData(@Nullable final String method) { } public @Nullable Integer getStatusCode() { - return statusCode; + final ReplayNetworkRequestOrResponse responseData = response; + return responseData == null ? null : responseData.getStatusCode(); } public @Nullable Long getRequestBodySize() { - return requestBodySize; + final ReplayNetworkRequestOrResponse requestData = request; + return requestData == null ? null : requestData.getSize(); } public @Nullable Long getResponseBodySize() { - return responseBodySize; + final ReplayNetworkRequestOrResponse responseData = response; + return responseData == null ? null : responseData.getSize(); } public @Nullable ReplayNetworkRequestOrResponse getRequest() { @@ -53,18 +56,19 @@ public NetworkRequestData(@Nullable final String method) { */ public void setRequestDetails(@NotNull final ReplayNetworkRequestOrResponse requestData) { this.request = requestData; - this.requestBodySize = requestData.getSize(); } /** - * Populates this instance with request details obtained via {@link - * NetworkDetailCaptureUtils#createResponse} + * Populates this instance with response details obtained via {@link + * NetworkDetailCaptureUtils#createResponse}. + * + *

May be called from another thread than the one that created this instance, and after the + * instance was handed to the scope. A later call replaces the details of an earlier one, so an + * integration can record the status code and the headers as soon as the response arrives and add + * the body once it has been consumed. */ - public void setResponseDetails( - final int statusCode, @NotNull final ReplayNetworkRequestOrResponse responseData) { - this.statusCode = statusCode; + public void setResponseDetails(@NotNull final ReplayNetworkRequestOrResponse responseData) { this.response = responseData; - this.responseBodySize = responseData.getSize(); } @Override @@ -73,12 +77,6 @@ public String toString() { + "method='" + method + '\'' - + ", statusCode=" - + statusCode - + ", requestBodySize=" - + requestBodySize - + ", responseBodySize=" - + responseBodySize + ", request=" + request + ", response=" diff --git a/sentry/src/main/java/io/sentry/util/network/ReplayNetworkRequestOrResponse.java b/sentry/src/main/java/io/sentry/util/network/ReplayNetworkRequestOrResponse.java index f68d780e66c..c4f10ed0d5b 100644 --- a/sentry/src/main/java/io/sentry/util/network/ReplayNetworkRequestOrResponse.java +++ b/sentry/src/main/java/io/sentry/util/network/ReplayNetworkRequestOrResponse.java @@ -12,6 +12,7 @@ */ @ApiStatus.Internal public final class ReplayNetworkRequestOrResponse { + private final @Nullable Integer statusCode; private final @Nullable Long size; private final @Nullable NetworkBody body; private final @NotNull Map headers; @@ -20,11 +21,24 @@ public ReplayNetworkRequestOrResponse( @Nullable final Long size, @Nullable final NetworkBody body, @NotNull final Map headers) { + this(null, size, body, headers); + } + + public ReplayNetworkRequestOrResponse( + @Nullable final Integer statusCode, + @Nullable final Long size, + @Nullable final NetworkBody body, + @NotNull final Map headers) { + this.statusCode = statusCode; this.size = size; this.body = body; this.headers = headers; } + public @Nullable Integer getStatusCode() { + return statusCode; + } + public @Nullable Long getSize() { return size; } @@ -40,7 +54,9 @@ public ReplayNetworkRequestOrResponse( @Override public String toString() { return "ReplayNetworkRequestOrResponse{" - + "size=" + + "statusCode=" + + statusCode + + ", size=" + size + ", body=" + body diff --git a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt new file mode 100644 index 00000000000..e882b3ed070 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt @@ -0,0 +1,497 @@ +package io.sentry.util + +import com.google.common.truth.Truth.assertThat +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryOptions +import java.util.TimeZone +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class MonitorConfigUtilsTest { + + @Test + fun `cron with fixed seconds drops the seconds field`() { + val config = MonitorConfigUtils.fromSchedule("0 15 10 * * MON-FRI", null, null) + + assertThat(config).isNotNull() + assertThat(config!!.schedule.type).isEqualTo("crontab") + assertThat(config.schedule.value).isEqualTo("15 10 * * MON-FRI") + assertThat(config.schedule.unit).isNull() + assertThat(config.timezone).isNull() + } + + @Test + fun `cron at second 0 keeps the default check-in margin`() { + val config = MonitorConfigUtils.fromSchedule("0 * * * * *", null, null) + + assertThat(config!!.checkinMargin).isNull() + } + + @Test + fun `cron with non-zero seconds gets a 2 minute check-in margin`() { + for (cron in listOf("1 * * * * *", "59 * * * * *", "30 15 10 * * *")) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)!!.checkinMargin).isEqualTo(2L) + } + } + + @Test + fun `cron with non-zero seconds raises a smaller default check-in margin`() { + withDefaultCheckinMargin(1L) { + assertThat(MonitorConfigUtils.fromSchedule("59 * * * * *", null, null)!!.checkinMargin) + .isEqualTo(2L) + } + } + + @Test + fun `cron with non-zero seconds keeps a larger default check-in margin`() { + withDefaultCheckinMargin(5L) { + assertThat(MonitorConfigUtils.fromSchedule("59 * * * * *", null, null)!!.checkinMargin) + .isEqualTo(5L) + } + } + + private fun withDefaultCheckinMargin(margin: Long, block: () -> Unit) { + Mockito.mockStatic(Sentry::class.java).use { sentry -> + val scopes = mock() + sentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + whenever(scopes.options) + .thenReturn( + SentryOptions().apply { + cron = SentryOptions.Cron().apply { defaultCheckinMargin = margin } + } + ) + block() + } + } + + @Test + fun `cron whitespace is normalized`() { + val config = MonitorConfigUtils.fromSchedule(" 30 0 2 * * * ", null, null) + + assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") + } + + @Test + fun `cron question mark is converted to asterisk`() { + val config = MonitorConfigUtils.fromSchedule("0 0 9 ? * MON", null, null) + + assertThat(config?.schedule?.value).isEqualTo("0 9 * * MON") + } + + @Test + fun `cron macros are expanded`() { + val expected = + mapOf( + "@yearly" to "0 0 1 1 *", + "@annually" to "0 0 1 1 *", + "@monthly" to "0 0 1 * *", + "@weekly" to "0 0 * * 0", + "@daily" to "0 0 * * *", + "@midnight" to "0 0 * * *", + "@hourly" to "0 * * * *", + "@HOURLY" to "0 * * * *", + ) + for ((macro, crontab) in expected) { + assertThat(MonitorConfigUtils.fromSchedule(macro, null, null)?.schedule?.value) + .isEqualTo(crontab) + } + } + + @Test + fun `unknown cron macro returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("@reboot", null, null)).isNull() + } + + @Test + fun `cron with variable seconds returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("*/30 * * * * *", null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0,30 * * * * *", null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0-10 * * * * *", null, null)).isNull() + } + + @Test + fun `cron without 6 fields returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("0 * * * *", null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0 0 * * * * 2030", null, null)).isNull() + } + + @Test + fun `cron with both day of month and day of week returns null`() { + for (cron in listOf("0 0 9 1-7 * MON", "0 0 9 15 * 1-5", "0 0 9 L * 5L", "0 0 9 1 * 1/2")) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)).isNull() + } + } + + @Test + fun `cron with a day field starting with asterisk is kept`() { + val expected = + mapOf( + "0 0 9 */2 * MON" to "0 9 */2 * MON", + "0 0 9 15 * */2" to "0 9 15 * */2", + "0 0 9 1 * ?" to "0 9 1 * *", + ) + for ((cron, crontab) in expected) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)?.schedule?.value) + .isEqualTo(crontab) + } + } + + @Test + fun `cron syntax Sentry accepts is kept`() { + val expected = + listOf( + "0 */15 * * * *", + "0 5/15 * * * *", + "0 0,30 8-18/2 * * *", + "0 0 0 1,15 JAN-MAR,dec *", + "0 0 0 L * *", + "0 0 0 lw * *", + "0 0 0 31 1-12 *", + "0 0 0 * * 0,7", + "0 0 0 * * sat", + "0 0 0 * * MON-FRI/2", + "0 0 0 * * 5L", + "0 0 0 * * 5#3", + "0 0 0 * * MON#2", + "0 0 0 * * 1-7/2", + ) + for (cron in expected) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)?.schedule?.value) + .isEqualTo(cron.substringAfter(' ')) + } + } + + @Test + fun `cron syntax Sentry rejects returns null`() { + val rejected = + listOf( + "60 * * * * *", + "0 60 * * * *", + "0 0 24 * * *", + "0 0 22-2 * * *", + "0 */0 * * * *", + "0 0,,30 * * * *", + "0 ? * * * *", + "0 0 0 0 * *", + "0 0 0 32 * *", + "0 0 0 15W * *", + "0 0 0 LW/2 * *", + "0 0 0 L-3 * *", + "0 0 0 5L * *", + "0 0 0 1#2 * *", + "0 0 0 30 2 *", + "0 0 0 31 4,6 *", + "0 0 0 * 0 *", + "0 0 0 * 13 *", + "0 0 0 * FOO *", + "0 0 0 * * 8", + "0 0 0 * * SAT-SUN", + "0 0 0 * * L", + "0 0 0 * * FRIL", + "0 0 0 * * L5", + "0 0 0 * * 5#6", + "0 0 0 * * 5#0", + ) + for (cron in rejected) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)).isNull() + } + } + + @Test + fun `stepped single value range returns null`() { + // Spring and Quartz run only at the value, cronsim steps to the field max + for (cron in + listOf("0 10-10/2 * * * *", "0 0 0-0/3 * * *", "0 0 0 ? * 4-4/3", "0 0 0 * 6-6/1 *")) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)).isNull() + } + } + + @Test + fun `disabled cron returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("-", null, null)).isNull() + } + + @Test + fun `zone is set for cron`() { + val config = MonitorConfigUtils.fromSchedule("0 0 2 * * *", "Europe/Vienna", null) + + assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") + assertThat(config?.timezone).isEqualTo("Europe/Vienna") + } + + @Test + fun `IANA zones are kept`() { + for (zone in + listOf("UTC", "GMT", "Etc/GMT+5", "US/Eastern", "America/Argentina/Buenos_Aires")) { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null)?.timezone) + .isEqualTo(zone) + } + } + + @Test + fun `whole hour fixed offsets are converted to Etc zones`() { + val expected = + mapOf( + "GMT+2" to "Etc/GMT-2", + "GMT+02:00" to "Etc/GMT-2", + "UTC-5" to "Etc/GMT+5", + "+03:00" to "Etc/GMT-3", + "-1200" to "Etc/GMT+12", + "GMT+14" to "Etc/GMT-14", + "GMT-00:00" to "Etc/GMT", + ) + for ((zone, timezone) in expected) { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null)?.timezone) + .isEqualTo(timezone) + } + } + + @Test + fun `zones Sentry rejects return null`() { + for (zone in + listOf("GMT+05:30", "GMT+15", "UTC-13", "PST", "IST", "Nowhere/Land", "SystemV/EST5")) { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null)).isNull() + } + } + + @Test + fun `empty zone is not set`() { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", "", null)?.timezone).isNull() + } + + @Test + fun `fixed rate in whole minutes is an interval`() { + val config = MonitorConfigUtils.fromSchedule(null, null, TimeUnit.MINUTES.toMillis(5)) + + assertThat(config).isNotNull() + assertThat(config!!.schedule.type).isEqualTo("interval") + assertThat(config.schedule.value).isEqualTo("5") + assertThat(config.schedule.unit).isEqualTo("minute") + } + + @Test + fun `period in whole hours or days uses the largest unit`() { + val expected = + mapOf( + TimeUnit.MINUTES.toMillis(60) to ("1" to "hour"), + TimeUnit.HOURS.toMillis(36) to ("36" to "hour"), + TimeUnit.HOURS.toMillis(24) to ("1" to "day"), + TimeUnit.DAYS.toMillis(14) to ("14" to "day"), + ) + for ((millis, interval) in expected) { + val config = MonitorConfigUtils.fromSchedule(null, null, millis) + assertThat(config?.schedule?.value).isEqualTo(interval.first) + assertThat(config?.schedule?.unit).isEqualTo(interval.second) + } + } + + @Test + fun `zone is ignored for intervals`() { + val config = + MonitorConfigUtils.fromSchedule(null, "Europe/Vienna", TimeUnit.MINUTES.toMillis(1)) + + assertThat(config?.schedule?.value).isEqualTo("1") + assertThat(config?.timezone).isNull() + } + + @Test + fun `sub-minute period returns null`() { + assertThat(MonitorConfigUtils.fromSchedule(null, null, 30_000L)).isNull() + assertThat(MonitorConfigUtils.fromSchedule(null, null, 0L)).isNull() + } + + @Test + fun `period not in whole minutes returns null`() { + assertThat(MonitorConfigUtils.fromSchedule(null, null, 90_000L)).isNull() + } + + @Test + fun `period too large for an int returns null`() { + assertThat( + MonitorConfigUtils.fromSchedule( + null, + null, + TimeUnit.MINUTES.toMillis(Int.MAX_VALUE.toLong() + 1), + ) + ) + .isNull() + } + + @Test + fun `more than one schedule kind returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", null, 60_000L)).isNull() + } + + @Test + fun `no schedule returns null`() { + assertThat(MonitorConfigUtils.fromSchedule(null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("", null, null)).isNull() + } + + @Test + fun `Spring days of week are rewritten to how crontab reads them`() { + val expected = + mapOf( + "0 15 10 * * MON-FRI" to "15 10 * * 1-5", + "0 0 9 * * */2" to "0 9 * * 1-7/2", + "0 0 9 ? * SUN/2" to "0 9 * * 7/2", + "0 0 9 ? * SUN-TUE,fri-sun" to "0 9 * * 0-2,5-7", + "0 0 9 ? * 7-2" to "0 9 * * 0-2", + "0 0 9 ? * SUN-SUN" to "0 9 * * 7", + "0 0 9 ? * 7-7/2" to "0 9 * * 7/2", + "0 0 9 ? * 0,7" to "0 9 * * 0,7", + "0 0 9 ? * FRIL" to "0 9 * * 5L", + "0 0 9 ? * MON#2" to "0 9 * * 1#2", + "0 0 9 * * *" to "0 9 * * *", + ) + for ((cron, crontab) in expected) { + assertThat( + MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null, false)?.schedule?.value + ) + .isEqualTo(crontab) + } + } + + @Test + fun `Spring cron without zone uses the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + assertThat( + MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", null, null, null, false)?.timezone + ) + .isEqualTo("Asia/Tokyo") + assertThat( + MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "", null, null, false)?.timezone + ) + .isEqualTo("Asia/Tokyo") + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `Spring cron zone is converted`() { + val config = MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "GMT+2", null, null, false) + + assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") + assertThat(config?.timezone).isEqualTo("Etc/GMT-2") + } + + @Test + fun `Spring schedules Sentry can't express return null`() { + for (cron in listOf("0 0 9 1-7 * MON", "-", "\${my.cron.missing}", "*/30 * * * * *")) { + assertThat(MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null, false)).isNull() + } + assertThat( + MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "GMT+05:30", null, null, false) + ) + .isNull() + assertThat(MonitorConfigUtils.fromSpringScheduled(null, null, 30_000L, null, false)).isNull() + } + + @Test + fun `Spring periods are intervals without zone`() { + val rate = + MonitorConfigUtils.fromSpringScheduled("", null, TimeUnit.MINUTES.toMillis(5), null, false) + assertThat(rate?.schedule?.type).isEqualTo("interval") + assertThat(rate?.schedule?.value).isEqualTo("5") + assertThat(rate?.schedule?.unit).isEqualTo("minute") + assertThat(rate?.timezone).isNull() + } + + @Test + fun `Spring fixed delay returns null`() { + for (legacy in listOf(false, true)) { + assertThat(MonitorConfigUtils.fromSpringScheduled(null, null, null, 300_000L, legacy)) + .isNull() + assertThat(MonitorConfigUtils.fromSpringScheduled("", null, 300_000L, 300_000L, legacy)) + .isNull() + } + } + + @Test + fun `Spring fifth weekday returns null`() { + assertThat(MonitorConfigUtils.fromSpringScheduled("0 0 9 ? * MON#5", "UTC", null, null, false)) + .isNull() + } + + @Test + fun `legacy Spring cron is rewritten to how crontab reads it`() { + val expected = + mapOf( + "0 0 9 */2 * *" to "0 9 2-31/2 * *", + "0 0 9 */1,15 * ?" to "0 9 1-31/1,15 * *", + "0 0 9 ? * */2" to "0 9 * * */2", + "0 0 9 ? * SUN/2" to "0 9 * * SUN/2", + "0 0 9 ? * 5-7" to "0 9 * * 5-7", + "0 0 9 1-15/3 * *" to "0 9 1-15/3 * *", + ) + for ((cron, crontab) in expected) { + assertThat( + MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null, true)?.schedule?.value + ) + .isEqualTo(crontab) + } + } + + @Test + fun `legacy Spring cron with both day fields returns null`() { + // CronSequenceGenerator then sometimes runs at midnight + for (cron in listOf("0 0 9 1 * */2", "0 0 9 */2 * */3", "0 0 9 */2 * MON")) { + assertThat(MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null, true)).isNull() + } + } + + @Test + fun `legacy Spring cron without zone uses the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + assertThat( + MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", null, null, null, true)?.timezone + ) + .isEqualTo("Asia/Tokyo") + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `parsePeriodMillis parses plain numbers in the default unit`() { + assertThat(MonitorConfigUtils.parsePeriodMillis("300000", TimeUnit.MILLISECONDS)) + .isEqualTo(300_000L) + assertThat(MonitorConfigUtils.parsePeriodMillis(" 5 ", TimeUnit.MINUTES)).isEqualTo(300_000L) + } + + @Test + fun `parsePeriodMillis parses the simple duration style`() { + val expected = + mapOf( + "2000000ns" to 2L, + "3000us" to 3L, + "500ms" to 500L, + "30s" to 30_000L, + "5m" to 300_000L, + "1h" to 3_600_000L, + "1d" to 86_400_000L, + "5M" to 300_000L, + "+5m" to 300_000L, + ) + for ((value, millis) in expected) { + assertThat(MonitorConfigUtils.parsePeriodMillis(value, TimeUnit.SECONDS)).isEqualTo(millis) + } + } + + @Test + fun `parsePeriodMillis returns null for unparseable values`() { + for (value in listOf("", "abc", "5x", "5 m", "1.5m", "PT5M", "99999999999999999999")) { + assertThat(MonitorConfigUtils.parsePeriodMillis(value, TimeUnit.MILLISECONDS)).isNull() + } + assertThat(MonitorConfigUtils.parsePeriodMillis(null, TimeUnit.MILLISECONDS)).isNull() + } +} diff --git a/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt b/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt index 04a19d47712..38b205e541b 100644 --- a/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt +++ b/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt @@ -18,6 +18,18 @@ class NetworkBodyParserTest { assertNull(NetworkBodyParser.fromBytes(ByteArray(0), "application/json", null, 512, logger)) } + @Test + fun `a partial json body that fits the limit is reported as truncated`() { + val logger = mock() + val bytes = """{"items":[1,2,""".toByteArray() + + val partial = NetworkBodyParser.fromBytes(bytes, "application/json", null, 512, true, logger) + val whole = NetworkBodyParser.fromBytes(bytes, "application/json", null, 512, logger) + + assertEquals(listOf(NetworkBody.NetworkBodyWarning.JSON_TRUNCATED), partial?.warnings) + assertEquals(listOf(NetworkBody.NetworkBodyWarning.INVALID_JSON), whole?.warnings) + } + @Test fun `null json body gets detected correctly`() { val logger = mock() diff --git a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt index 25b142af7e9..d7f5249e6f9 100644 --- a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt @@ -18,6 +18,7 @@ class NetworkDetailCaptureUtilsTest { val result = NetworkDetailCaptureUtils.createResponse( jsonBytes, + 200, -1L, true, { bytes -> @@ -38,6 +39,7 @@ class NetworkDetailCaptureUtilsTest { val result = NetworkDetailCaptureUtils.createResponse( jsonBytes, + 200, 42L, true, { bytes -> @@ -55,6 +57,7 @@ class NetworkDetailCaptureUtilsTest { val result = NetworkDetailCaptureUtils.createResponse( "unused", + 200, null, false, { null }, diff --git a/sentry/src/test/java/io/sentry/util/network/NetworkRequestDataTest.kt b/sentry/src/test/java/io/sentry/util/network/NetworkRequestDataTest.kt new file mode 100644 index 00000000000..d66551bd3cb --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/network/NetworkRequestDataTest.kt @@ -0,0 +1,80 @@ +package io.sentry.util.network + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class NetworkRequestDataTest { + + private fun responseDetails(statusCode: Int, size: Long): ReplayNetworkRequestOrResponse = + ReplayNetworkRequestOrResponse(statusCode, size, null, emptyMap()) + + @Test + fun `response details are empty until they are set`() { + val data = NetworkRequestData("GET") + + assertNull(data.getStatusCode()) + assertNull(data.getResponse()) + assertNull(data.getResponseBodySize()) + } + + @Test + fun `response details are all readable once set`() { + val data = NetworkRequestData("GET") + + data.setResponseDetails(responseDetails(200, 42L)) + + assertEquals(200, data.getStatusCode()) + assertEquals(42L, data.getResponseBodySize()) + assertEquals(42L, data.getResponse()?.getSize()) + } + + @Test + fun `response details can be filled in after the instance was published`() { + // A body of unknown length is only known once it has been consumed, which can be after the + // breadcrumb + // holding this instance already reached the scope, so the values must stay consistent for a + // reader that arrives late. + val data = NetworkRequestData("GET") + assertNull(data.getStatusCode()) + assertNull(data.getResponse()) + + data.setResponseDetails(responseDetails(500, 7L)) + + assertEquals(500, data.getStatusCode()) + assertEquals(7L, data.getResponseBodySize()) + assertEquals(7L, data.getResponse()?.getSize()) + } + + @Test + fun `the response keeps its status code and size together`() { + val data = NetworkRequestData("GET") + assertNull(data.response) + + data.setResponseDetails(responseDetails(200, 42L)) + val first = assertNotNull(data.response) + + data.setResponseDetails(responseDetails(500, 7L)) + + assertEquals( + 200, + first.statusCode, + "a reader that took the response is unaffected by a later call", + ) + assertEquals(42L, first.size) + assertEquals(500, data.response?.statusCode) + assertEquals(7L, data.response?.size) + } + + @Test + fun `request details are unaffected`() { + val data = NetworkRequestData("GET") + val request = ReplayNetworkRequestOrResponse(11L, null, mapOf("Accept" to "application/json")) + + data.setRequestDetails(request) + + assertEquals(11L, data.getRequestBodySize()) + assertEquals(request, data.getRequest()) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 870fc170efc..b57f28a2654 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,19 +2,22 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") pluginManagement { repositories { - // Prefer local SAGP artifact if one exists; otherwise fall back to libs.versions.toml. - if (providers.gradleProperty("useSagp").isPresent) { - mavenLocal { - content { - includeGroup("io.sentry") - includeGroup("io.sentry.android.gradle") - } - } - } mavenCentral() gradlePluginPortal() google() } + + // Every build in a composite must use the same AGP, so each build's settings applies the AGP + // matrix job's override. + System.getenv("VERSION_AGP")?.let { agpVersion -> + resolutionStrategy { + eachPlugin { + if (requested.id.id.startsWith("com.android")) { + useVersion(agpVersion) + } + } + } + } } plugins { @@ -98,38 +101,5 @@ include( "sentry-launchdarkly-android", "sentry-reactor", "sentry-async-profiler", - "sentry-ktor-client", - "sentry-samples:sentry-samples-android", - "sentry-samples:sentry-samples-console", - "sentry-samples:sentry-samples-console-otlp", - "sentry-samples:sentry-samples-console-opentelemetry-noagent", - "sentry-samples:sentry-samples-jul", - "sentry-samples:sentry-samples-ktor-client", - "sentry-samples:sentry-samples-log4j2", - "sentry-samples:sentry-samples-logback", - "sentry-samples:sentry-samples-servlet", - "sentry-samples:sentry-samples-spring", - "sentry-samples:sentry-samples-spring-jakarta", - "sentry-samples:sentry-samples-spring-7", - "sentry-samples:sentry-samples-spring-boot", - "sentry-samples:sentry-samples-spring-boot-opentelemetry", - "sentry-samples:sentry-samples-spring-boot-opentelemetry-noagent", - "sentry-samples:sentry-samples-spring-boot-jakarta", - "sentry-samples:sentry-samples-spring-boot-jakarta-log4j2", - "sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry", - "sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent", - "sentry-samples:sentry-samples-spring-boot-webflux", - "sentry-samples:sentry-samples-spring-boot-webflux-jakarta", - "sentry-samples:sentry-samples-spring-boot-4", - "sentry-samples:sentry-samples-spring-boot-4-log4j2", - "sentry-samples:sentry-samples-spring-boot-4-opentelemetry", - "sentry-samples:sentry-samples-spring-boot-4-opentelemetry-noagent", - "sentry-samples:sentry-samples-spring-boot-4-otlp", - "sentry-samples:sentry-samples-spring-boot-4-webflux", - "sentry-samples:sentry-samples-netflix-dgs", - "sentry-android-integration-tests:sentry-uitest-android-critical", - "sentry-android-integration-tests:sentry-uitest-android-macrobenchmark", - "sentry-android-integration-tests:sentry-uitest-android", - "sentry-android-integration-tests:test-app-size", - "sentry-samples:sentry-samples-openfeign" + "sentry-ktor-client" )