diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..d467a22f99 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +# Used by builds whose context is the repository root (e2e/java-plugin/Dockerfile). +.git +**/node_modules +**/build +**/.gradle +rrweb diff --git a/.github/actions/publish-java-sdk/action.yml b/.github/actions/publish-java-sdk/action.yml new file mode 100644 index 0000000000..f3084d8f04 --- /dev/null +++ b/.github/actions/publish-java-sdk/action.yml @@ -0,0 +1,88 @@ +name: 'Publish Java SDK' +description: 'Build and publish the Java server observability plugin to Maven Central' + +inputs: + workspace-path: + description: 'Path to the Java SDK workspace' + required: true + default: 'sdk/@launchdarkly/observability-java' + java_version: + description: 'The Java version to use.' + required: false + default: '17' + java_distribution: + description: 'The Java distribution to use.' + required: false + default: 'temurin' + aws-role-arn: + description: 'AWS role ARN for accessing secrets' + required: true + dry-run: + description: 'Whether to run the publish in dry-run mode' + required: false + default: 'false' + prerelease: + description: 'Whether to publish a prerelease version' + required: false + default: 'false' + +outputs: + package-hashes: + description: 'Base64 encoded SHA256 hashes of published packages' + value: ${{ steps.package-hashes.outputs.package-hashes }} + +runs: + using: 'composite' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: ${{ inputs.java_distribution }} + java-version: ${{ inputs.java_version }} + + # Same Sonatype Central account and code-signing key as the Android + # plugin; both publish under the com.launchdarkly namespace. + - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.2.0 + name: Get secrets + with: + aws_assume_role: ${{ inputs.aws-role-arn }} + ssm_parameter_pairs: | + /production/common/releasing/sonatype/central/username = SONATYPE_USER_NAME, + /production/common/releasing/sonatype/central/password = SONATYPE_PASSWORD, + /production/common/releasing/android_code_signing/private_key_id = SIGNING_KEY_ID, + /production/common/releasing/android_code_signing/private_key_passphrase = SIGNING_KEY_PASSPHRASE + s3_path_pairs: 'launchdarkly-releaser/android/code-signing-keyring.gpg = code-signing-keyring.gpg' + + - name: Publish Library + shell: bash + if: ${{ inputs.dry-run != 'true' }} + working-directory: ${{ inputs.workspace-path }} + env: + LD_RELEASE_IS_PRERELEASE: ${{ inputs.prerelease }} + SIGNING_KEY_ID: ${{ env.SIGNING_KEY_ID }} + SIGNING_KEY_PASSPHRASE: ${{ env.SIGNING_KEY_PASSPHRASE }} + SIGNING_SECRET_KEY_RING_FILE: ${{ github.workspace }}/code-signing-keyring.gpg + SONATYPE_USER_NAME: ${{ env.SONATYPE_USER_NAME }} + SONATYPE_PASSWORD: ${{ env.SONATYPE_PASSWORD }} + run: source $GITHUB_ACTION_PATH/publish.sh + + # A dry run still builds and verifies the artifact (unit tests, the + # runtime-classpath guard and the POM guard) so a broken release is + # caught before anyone flips dry-run off. + - name: Dry Run Publish Library + shell: bash + if: ${{ inputs.dry-run == 'true' }} + working-directory: ${{ inputs.workspace-path }} + run: | + ./gradlew :lib:build --no-daemon --console=plain + echo "Dry run. Not publishing." + + - name: Hash build files for provenance + id: package-hashes + shell: bash + working-directory: ${{ inputs.workspace-path }}/lib/build/libs + run: | + echo "package-hashes=$(sha256sum launchdarkly-java-server-sdk-observability-*.jar | base64 -w0)" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/publish-java-sdk/publish.sh b/.github/actions/publish-java-sdk/publish.sh new file mode 100755 index 0000000000..679fa33957 --- /dev/null +++ b/.github/actions/publish-java-sdk/publish.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +set -ue + +GRADLE_ARGS=( + --no-daemon + --console=plain + -Psigning.keyId="${SIGNING_KEY_ID}" + -Psigning.password="${SIGNING_KEY_PASSPHRASE}" + -Psigning.secretKeyRingFile="${SIGNING_SECRET_KEY_RING_FILE}" + -PsonatypeUsername="${SONATYPE_USER_NAME}" + -PsonatypePassword="${SONATYPE_PASSWORD}" +) + +# `check` runs the unit tests plus the guards on what gets published: no +# OkHttp/Okio/Kotlin on the runtime classpath, and opentelemetry-api at +# compile scope in the POM. Nothing is uploaded if any of them fail. +echo "Verifying the artifact" +./gradlew :lib:build "${GRADLE_ARGS[@]}" || { + echo "Gradle build/verification failed" >&2 + exit 1 +} + +echo "Publishing to Sonatype" +if [ "${LD_RELEASE_IS_PRERELEASE}" == "true" ]; then + echo "PRERELEASE" + ./gradlew publishToSonatype "${GRADLE_ARGS[@]}" || { + echo "Gradle publish/release failed" >&2 + exit 1 + } +else + echo "RELEASE" + ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository "${GRADLE_ARGS[@]}" || { + echo "Gradle publish/release failed" >&2 + exit 1 + } +fi diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d17c3c307b..29a1425108 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -14,7 +14,15 @@ jobs: runs-on: ubuntu-22.04-8core-32gb strategy: matrix: - app: ['dotnet', 'dotnet4', 'go', 'python', 'ruby'] + app: + [ + 'dotnet', + 'dotnet4', + 'go', + 'java-plugin-spring-boot', + 'python', + 'ruby', + ] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/java-observability.yml b/.github/workflows/java-observability.yml new file mode 100644 index 0000000000..e0710b5767 --- /dev/null +++ b/.github/workflows/java-observability.yml @@ -0,0 +1,104 @@ +name: 'Java Observability' + +on: + push: + branches: ['main'] + paths: + - 'sdk/@launchdarkly/observability-java/**' + - 'e2e/java-plugin/**' + - 'e2e/java-plugin-spring-boot/**' + # Shared sampling scenarios; :lib:checkSamplingFixtures compares them. + - 'go/internal/otel/*-test-scenarios.json' + - '.github/workflows/java-observability.yml' + pull_request: + types: [opened, synchronize] + paths: + - 'sdk/@launchdarkly/observability-java/**' + - 'e2e/java-plugin/**' + - 'e2e/java-plugin-spring-boot/**' + # Shared sampling scenarios; :lib:checkSamplingFixtures compares them. + - 'go/internal/otel/*-test-scenarios.json' + - '.github/workflows/java-observability.yml' + +permissions: + contents: read + +concurrency: ${{ github.workflow }}-${{ github.ref }} +jobs: + test: + # The library targets Java 11, so it is built and tested on every LTS + # a server might run it on, not only the newest. + name: Unit Tests (JDK ${{ matrix.java }}) + runs-on: ubuntu-22.04-8core-32gb + strategy: + fail-fast: false + matrix: + java: [11, 17, 21] + defaults: + run: + working-directory: ./sdk/@launchdarkly/observability-java + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ matrix.java }} + cache: 'gradle' + + # `build` includes `check`, which runs the unit tests plus the + # runtime-classpath guard (no OkHttp/Okio/Kotlin) and the POM + # guard (opentelemetry-api at compile scope, JDK sender at runtime). + - name: Build, test and verify the published POM + run: ./gradlew :lib:build --no-daemon --console=plain + + # The plugin needs the Plugin API, first shipped in SDK 7.10.1. + - name: Test against the oldest supported LaunchDarkly SDK + if: matrix.java == 11 + run: ./gradlew :lib:test -PldServerSdkVersion=7.10.1 --no-daemon --console=plain + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: java-unit-test-results-jdk${{ matrix.java }} + path: sdk/@launchdarkly/observability-java/**/build/test-results/**/TEST-*.xml + retention-days: 7 + + consumer: + # Builds the e2e example against the artifact as a consumer sees it: + # published to Maven local and resolved through its POM, with no + # composite build to paper over scope or transitive-dependency mistakes. + name: Consumer check (JDK ${{ matrix.java }}) + runs-on: ubuntu-22.04-8core-32gb + strategy: + fail-fast: false + matrix: + java: [11, 21] + defaults: + run: + working-directory: ./e2e/java-plugin + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ matrix.java }} + cache: 'gradle' + + - name: Publish the plugin to Maven local and build the example against it + run: make consumer-check + + # Spring Boot 3 needs Java 17, so the web backend is checked as a + # consumer on the newer JDK only. + - name: Build the Spring Boot backend against the Maven local artifact + if: matrix.java != 11 + working-directory: ./e2e/java-plugin-spring-boot + run: make consumer-check diff --git a/.github/workflows/java-plugin-e2e.yml b/.github/workflows/java-plugin-e2e.yml new file mode 100644 index 0000000000..789f4eaf1f --- /dev/null +++ b/.github/workflows/java-plugin-e2e.yml @@ -0,0 +1,90 @@ +name: Java Observability Plugin E2E + +on: + push: + branches: ['main'] + paths: + - 'e2e/java-plugin/**' + - 'e2e/java-plugin-spring-boot/**' + - 'sdk/@launchdarkly/observability-java/**' + - '.github/workflows/java-plugin-e2e.yml' + pull_request: + types: [opened, synchronize] + paths: + - 'e2e/java-plugin/**' + - 'e2e/java-plugin-spring-boot/**' + - 'sdk/@launchdarkly/observability-java/**' + - '.github/workflows/java-plugin-e2e.yml' + +permissions: + contents: read + +concurrency: ${{ github.workflow }}-${{ github.ref }} +jobs: + run: + name: Run example + runs-on: ubuntu-22.04-8core-32gb + defaults: + run: + working-directory: ./e2e/java-plugin + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 21 + cache: 'gradle' + + # Runs once per owner of GlobalOpenTelemetry: the plugin, a no-op + # claimed before the client starts, and the Java agent. + - name: Run the self-checking example + run: make run + + # Builds the Spring Boot web backend, starts it offline (no SDK + # key) and drives every route. It must come up, answer, record the + # /error exception as a 500, and stop cleanly on SIGTERM with the + # plugin flushing on the way out. + - name: Build and test the Spring Boot backend + working-directory: ./e2e/java-plugin-spring-boot + run: make test build + + - name: Smoke-test the Spring Boot backend + working-directory: ./e2e/java-plugin-spring-boot + # The app under test runs on this runner, so it is reached on the + # loopback address. DevSkim DS162092 flags that as debug code. + env: + APP: http://localhost:8080 # DevSkim: ignore DS162092 + run: | + java -jar build/libs/*.jar > app.log 2>&1 & + APP_PID=$! + for i in $(seq 1 120); do + if curl -sf -o /dev/null "$APP/"; then break; fi + if ! kill -0 "$APP_PID" 2>/dev/null; then echo "app exited early"; cat app.log; exit 1; fi + sleep 1 + done + curl -sf "$APP/" || { cat app.log; exit 1; } + curl -sf -H 'X-User-Key: ci' "$APP/rolldice" || { cat app.log; exit 1; } + curl -sf "$APP/flags/example-greeting" || { cat app.log; exit 1; } + curl -sf "$APP/actuator/health" || { cat app.log; exit 1; } + code=$(curl -s -o /dev/null -w '%{http_code}' "$APP/error") + [ "$code" = "500" ] || { echo "expected 500 from /error, got $code"; cat app.log; exit 1; } + kill -TERM "$APP_PID" + wait "$APP_PID" || true + grep -q "Closing LaunchDarkly Client" app.log || { echo "client did not close on shutdown"; cat app.log; exit 1; } + echo "Spring Boot backend OK" + + # The OpenTelemetry Java agent owns GlobalOpenTelemetry here, as + # it does in many production services. Flag evaluations and + # request spans must still reach the plugin's exporter, checked + # against a local collector. + - name: Smoke-test the Spring Boot backend under the Java agent + working-directory: ./e2e/java-plugin-spring-boot + run: make javaagent-smoke + + - name: Build the dockerized Spring Boot backend + working-directory: . + run: docker build -f e2e/java-plugin-spring-boot/Dockerfile -t e2e-java . diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index 547334574a..4764c89b63 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -26,6 +26,14 @@ on: description: 'The tag name for the python package. Should be set when publishing a python package.' type: string required: false + release_launchdarkly_java: + description: 'Should release the launchdarkly-java-server-sdk-observability package.' + type: boolean + required: true + java_tag_name: + description: 'The tag name for the java package. Should be set when publishing a java package.' + type: string + required: false release_launchdarkly_dotnet: description: 'Should release @launchdarkly/observability-dotnet package.' type: boolean @@ -143,6 +151,39 @@ jobs: dry-run: ${{ inputs.dry-run }} prerelease: ${{ inputs.prerelease }} + publish-java-sdk: + runs-on: ubuntu-latest + if: ${{ inputs.release_launchdarkly_java == true }} + permissions: + id-token: write + outputs: + package-hashes: ${{ steps.publish.outputs.package-hashes }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Publish Java SDK + id: publish + uses: ./.github/actions/publish-java-sdk + with: + workspace-path: sdk/@launchdarkly/observability-java + aws-role-arn: ${{ vars.AWS_ROLE_ARN }} + dry-run: ${{ inputs.dry-run }} + prerelease: ${{ inputs.prerelease }} + + publish-java-sdk-provenance: + needs: ['publish-java-sdk'] + if: ${{ inputs.release_launchdarkly_java == true && inputs.dry-run == false }} + permissions: + actions: read + id-token: write + contents: write + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 + with: + base64-subjects: '${{ needs.publish-java-sdk.outputs.package-hashes }}' + upload-assets: true + upload-tag-name: ${{ inputs.java_tag_name }} + publish-dotnet-sdk: runs-on: ubuntu-latest if: ${{ inputs.release_launchdarkly_dotnet == true }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 7ab4689f9d..19effc14cf 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -24,6 +24,8 @@ jobs: ruby-plugin-tag-name: ${{ steps.release.outputs['sdk/@launchdarkly/observability-ruby--tag_name'] }} flutter-plugin-released: ${{ steps.release.outputs['sdk/flutter/packages/observability--release_created'] }} flutter-plugin-tag-name: ${{ steps.release.outputs['sdk/flutter/packages/observability--tag_name'] }} + java-plugin-released: ${{ steps.release.outputs['sdk/@launchdarkly/observability-java--release_created'] }} + java-plugin-tag-name: ${{ steps.release.outputs['sdk/@launchdarkly/observability-java--tag_name'] }} steps: # Normally a workflow cannot trigger another workflow. For this workflow we need to run required checks. # Github determines that something is created by a workflow/bot based on the token. @@ -101,6 +103,39 @@ jobs: workspace-path: sdk/@launchdarkly/observability-android aws-role-arn: ${{ vars.AWS_ROLE_ARN }} + release-java-plugin: + runs-on: ubuntu-latest + permissions: + id-token: write # Used for publishing secrets. + contents: write + needs: ['release-package'] + if: ${{ needs.release-package.outputs.java-plugin-released == 'true' }} + outputs: + package-hashes: ${{ steps.publish.outputs.package-hashes }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Publish Java SDK + id: publish + uses: ./.github/actions/publish-java-sdk + with: + workspace-path: sdk/@launchdarkly/observability-java + aws-role-arn: ${{ vars.AWS_ROLE_ARN }} + + release-java-provenance: + needs: ['release-package', 'release-java-plugin'] + if: ${{ needs.release-package.outputs.java-plugin-released == 'true' }} + permissions: + actions: read + id-token: write + contents: write + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 + with: + base64-subjects: '${{ needs.release-java-plugin.outputs.package-hashes }}' + upload-assets: true + upload-tag-name: ${{ needs.release-package.outputs.java-plugin-tag-name }} + release-dotnet-plugin: runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/turbo.yml b/.github/workflows/turbo.yml index 8ae851ac28..93b4083f47 100644 --- a/.github/workflows/turbo.yml +++ b/.github/workflows/turbo.yml @@ -65,14 +65,19 @@ jobs: - '!sdk/flutter/**' - '!sdk/@launchdarkly/launchdarkly_flutter_observability/**' - '!sdk/@launchdarkly/observability-dotnet/**' + - '!sdk/@launchdarkly/observability-java/**' - '!sdk/@launchdarkly/observability-python/**' - '!sdk/@launchdarkly/observability-ruby/**' - '!e2e/android/**' + - '!e2e/java-plugin/**' + - '!e2e/java-plugin-spring-boot/**' - '!.github/workflows/android-observability.yml' - '!.github/workflows/android-e2e.yml' - '!.github/workflows/flutter-observability.yml' - '!.github/workflows/flutter-observability-publish.yml' - '!.github/workflows/dotnet-plugin.yml' + - '!.github/workflows/java-observability.yml' + - '!.github/workflows/java-plugin-e2e.yml' - '!.github/workflows/python-plugin.yml' - '!.github/workflows/ruby-plugin.yml' diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c84331f76d..c35ec21750 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -4,6 +4,7 @@ "sdk/@launchdarkly/observability": "1.2.2", "sdk/@launchdarkly/observability-next": "0.3.2", "sdk/@launchdarkly/observability-android": "0.69.1", + "sdk/@launchdarkly/observability-java": "0.1.0", "sdk/flutter/packages/observability": "1.0.2", "sdk/@launchdarkly/observability-dotnet": "1.2.1", "sdk/@launchdarkly/observability-node": "1.4.0", diff --git a/e2e/compose.yml b/e2e/compose.yml index 79b78c8e58..692ab10140 100644 --- a/e2e/compose.yml +++ b/e2e/compose.yml @@ -48,3 +48,11 @@ services: args: IMAGE_NAME_BASE: ${IMAGE_NAME_BASE-ghcr.io/highlight/e2e:latest} container_name: ruby + # Service name matches the e2e/ directory, which app_runner.py requires. + java-plugin-spring-boot: + image: ${IMAGE_NAME-ghcr.io/highlight/e2e-java-plugin-spring-boot:latest} + build: + # The plugin is compiled from source, so the context is the repo root. + context: .. + dockerfile: e2e/java-plugin-spring-boot/Dockerfile + container_name: java-plugin-spring-boot diff --git a/e2e/java-plugin-spring-boot/.gitignore b/e2e/java-plugin-spring-boot/.gitignore new file mode 100644 index 0000000000..d8fe6f5978 --- /dev/null +++ b/e2e/java-plugin-spring-boot/.gitignore @@ -0,0 +1,3 @@ +.gradle +build +app-javaagent.log diff --git a/e2e/java-plugin-spring-boot/Dockerfile b/e2e/java-plugin-spring-boot/Dockerfile new file mode 100644 index 0000000000..ca3aa92dbc --- /dev/null +++ b/e2e/java-plugin-spring-boot/Dockerfile @@ -0,0 +1,23 @@ +# Build context is the repository root: +# docker build -f e2e/java-plugin-spring-boot/Dockerfile -t e2e-java . +# The plugin is built from source, so the image never needs a published artifact. +FROM eclipse-temurin:21-jdk AS build +WORKDIR /src +COPY sdk/@launchdarkly/observability-java sdk/@launchdarkly/observability-java +COPY e2e/java-plugin-spring-boot e2e/java-plugin-spring-boot +WORKDIR /src/e2e/java-plugin-spring-boot +RUN ../../sdk/@launchdarkly/observability-java/gradlew -p . bootJar --no-daemon --console=plain \ + && cp build/libs/*.jar /src/app.jar + +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build /src/app.jar /app/app.jar +ENV PORT=8080 +# e2e/tests/src/app_runner.py reads BACKEND_URI from the container to find the +# port to publish and the URL to health-check, as the other e2e apps do. +# (DevSkim DS162092 flags the loopback literal; it is dismissed as used in +# tests, like the other e2e Dockerfiles, because ENV lines take no inline +# suppression comment.) +ENV BACKEND_URI="http://localhost:8080/" +EXPOSE 8080 +CMD ["java", "-jar", "/app/app.jar"] diff --git a/e2e/java-plugin-spring-boot/Makefile b/e2e/java-plugin-spring-boot/Makefile new file mode 100644 index 0000000000..4ce400ffd2 --- /dev/null +++ b/e2e/java-plugin-spring-boot/Makefile @@ -0,0 +1,30 @@ +.PHONY: build run test javaagent-smoke consumer-check + +SDK_DIR := ../../sdk/@launchdarkly/observability-java +SDK_GRADLE := $(SDK_DIR)/gradlew +GRADLE_ARGS := --no-daemon --console=plain + +# Builds the executable Spring Boot jar at build/libs/*.jar. +build: + $(SDK_GRADLE) -p $(CURDIR) bootJar $(GRADLE_ARGS) + +# Runs the web backend on PORT (default 8080). Honours LAUNCHDARKLY_SDK_KEY, +# OTEL_EXPORTER_OTLP_ENDPOINT, LD_OBSERVABILITY_BACKEND_URL and HTTPS_PROXY. +run: + $(SDK_GRADLE) -p $(CURDIR) bootRun $(GRADLE_ARGS) + +# Unit tests plus the runtime-classpath guard. +test: + $(SDK_GRADLE) -p $(CURDIR) check $(GRADLE_ARGS) + +# Runs the jar under the OpenTelemetry Java agent, which owns +# GlobalOpenTelemetry, and checks that flag evaluations still reach the +# plugin's exporter (a local collector). +javaagent-smoke: + $(SDK_GRADLE) -p $(CURDIR) bootJar javaagentJar $(GRADLE_ARGS) + python3 scripts/javaagent_smoke.py build/libs/java-plugin-spring-boot-example.jar build/agent/opentelemetry-javaagent.jar + +# Publishes the plugin to ~/.m2 and builds the app against that artifact. +consumer-check: + cd $(SDK_DIR) && ./gradlew :lib:publishToMavenLocal $(GRADLE_ARGS) + $(SDK_GRADLE) -p $(CURDIR) check bootJar $(GRADLE_ARGS) -PuseMavenLocal diff --git a/e2e/java-plugin-spring-boot/README.md b/e2e/java-plugin-spring-boot/README.md new file mode 100644 index 0000000000..493c479115 --- /dev/null +++ b/e2e/java-plugin-spring-boot/README.md @@ -0,0 +1,88 @@ +# Spring Boot web backend with the Java observability plugin + +A Spring Boot 3 service that uses the LaunchDarkly Java Server SDK with the +observability plugin the way a production backend does. It is the e2e app for +the plugin: CI builds it, runs it, and drives its routes, and it is the place +to exercise features the plugin needs on a server (request spans, flag +evaluations under load, errors, proxies, custom headers). + +It builds against the plugin sources in this repository by default, so it +never needs a published artifact. + +## Routes + +| Route | What it exercises | +|-------|-------------------| +| `GET /` | Flag evaluation (`example-greeting`) inside the request span | +| `GET /rolldice` | Flag evaluation (`dice-sides`), a span attribute, JSON response | +| `GET /flags/{key}` | Any flag's JSON value for the request context | +| `GET /error` | An unhandled exception recorded with `LDObserve.recordError`, answered 500 | +| `GET /actuator/health` | Spring Boot health | + +Send `X-User-Key: ` to pin the LaunchDarkly context; otherwise a random +user key is used per request. Every request gets a span, a duration histogram, +a request counter and a log line from `RequestTelemetryFilter`. + +## Run + +```bash +export LAUNCHDARKLY_SDK_KEY="key-from-your-project" # unset = offline client +make run & +until curl -s -o /dev/null localhost:8080/; do sleep 1; done +curl -s localhost:8080/rolldice +curl -s localhost:8080/error +``` + +Environment the app honours: + +| Variable | Purpose | +|----------|---------| +| `LAUNCHDARKLY_SDK_KEY` | SDK key. Unset runs the client offline. | +| `PORT` | Listen port, default `8080`. | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP/HTTP base URL, e.g. `http://localhost:4318` for a local stack. | +| `LD_OBSERVABILITY_BACKEND_URL` | Sampling-config URL for a local stack. | +| `HTTPS_PROXY` | `host:port` of an egress proxy; applied to exports and the sampling request. | +| `SERVICE_VERSION` | Reported as `service.version`, default `dev`. | +| `LD_BASE_URI`, `LD_STREAM_URI`, `LD_EVENTS_URI` | Flag SDK polling, streaming and events URLs for a non-default LaunchDarkly instance. Used only when all three are set. | + +`make build` produces the executable jar in `build/libs/`. `make test` boots +the application against a local OTLP collector and sampling endpoint +(`ApplicationTelemetryTest`), drives every route, and checks the exported +request spans, evaluation spans and `feature_flag` events, the recorded error, +logs, metrics, resource attributes and a backend sampling rule. It also runs a +guard that fails if the plugin leaks OkHttp, Okio or Kotlin onto the +application's classpath. + +## Under the OpenTelemetry Java agent + +```bash +make javaagent-smoke +``` + +Runs the jar with the OpenTelemetry Java agent attached, so the agent owns +`GlobalOpenTelemetry`, and drives the routes against a local collector. It +fails unless the evaluation spans and the request spans with their +`feature_flag` events reach the plugin's exporter. CI runs it on every change +to the plugin. + +## Docker + +The image is built from the repository root so the plugin can be compiled +from source. It is the `java-plugin-spring-boot` service in `e2e/compose.yml`, which the +dockerized e2e workflow builds, starts and health-checks. + +```bash +docker build -f e2e/java-plugin-spring-boot/Dockerfile -t e2e-java . +docker run --rm -p 8080:8080 -e LAUNCHDARKLY_SDK_KEY e2e-java +``` + +## Consumer check + +```bash +make consumer-check +``` + +Publishes the plugin to `~/.m2` and builds the app against that artifact with +`-PuseMavenLocal`, the way an application would. The app compiles against +`Attributes`, `Severity` and `Span` without declaring `opentelemetry-api` +itself, which checks the published POM's scopes. diff --git a/e2e/java-plugin-spring-boot/build.gradle b/e2e/java-plugin-spring-boot/build.gradle new file mode 100644 index 0000000000..d71c265e06 --- /dev/null +++ b/e2e/java-plugin-spring-boot/build.gradle @@ -0,0 +1,78 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.16' + id 'io.spring.dependency-management' version '1.1.7' +} + +def useMavenLocal = project.hasProperty('useMavenLocal') + +repositories { + if (useMavenLocal) { + mavenLocal() + } + mavenCentral() +} + +// Spring Boot 3 needs Java 17. The plugin itself targets Java 11. +tasks.withType(JavaCompile).configureEach { + options.release = 17 +} + +// The plugin version comes from the SDK's gradle.properties, which +// release-please bumps, so this app never pins a stale version. +def sdkProperties = new Properties() +file('../../sdk/@launchdarkly/observability-java/gradle.properties').withInputStream { sdkProperties.load(it) } +def pluginVersion = sdkProperties.getProperty('version') + +configurations { + javaagent +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation "com.launchdarkly:launchdarkly-java-server-sdk-observability:${pluginVersion}" + implementation 'com.launchdarkly:launchdarkly-java-server-sdk:7.12.0' + // No explicit opentelemetry-api: Attributes, Severity and Span arrive + // through the plugin's compile-scoped dependency. + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Agent release that ships the OpenTelemetry SDK version the plugin uses. + javaagent 'io.opentelemetry.javaagent:opentelemetry-javaagent:2.17.0' +} + +// Copies the OpenTelemetry Java agent to build/agent/opentelemetry-javaagent.jar +// for `make javaagent-smoke`. +tasks.register('javaagentJar', Copy) { + from configurations.javaagent + into layout.buildDirectory.dir('agent') + rename { 'opentelemetry-javaagent.jar' } +} + +tasks.named('test') { + useJUnitPlatform() +} + +// Groups that must never reach an application's runtime classpath through the plugin. +def forbiddenRuntimeGroups = ['com.squareup.okhttp3', 'com.squareup.okio', 'org.jetbrains.kotlin', 'org.jetbrains.kotlinx'] + +tasks.register('checkConsumerClasspath') { + group = 'verification' + description = 'Fails when the plugin leaks OkHttp, Okio or Kotlin into this application.' + def runtime = configurations.runtimeClasspath + doLast { + def leaked = runtime.resolvedConfiguration.resolvedArtifacts + .collect { it.moduleVersion.id } + .findAll { forbiddenRuntimeGroups.contains(it.group) } + .collect { "${it.group}:${it.name}:${it.version}" } + .unique() + if (!leaked.isEmpty()) { + throw new GradleException("Forbidden artifacts on the application runtime classpath: ${leaked.join(', ')}") + } + } +} + +tasks.named('check') { + dependsOn 'checkConsumerClasspath' +} diff --git a/e2e/java-plugin-spring-boot/scripts/javaagent_smoke.py b/e2e/java-plugin-spring-boot/scripts/javaagent_smoke.py new file mode 100755 index 0000000000..3c67ef8bac --- /dev/null +++ b/e2e/java-plugin-spring-boot/scripts/javaagent_smoke.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Runs the Spring Boot backend under the OpenTelemetry Java agent and checks +that flag evaluations still reach the plugin's OTLP exporter. + +The agent owns GlobalOpenTelemetry and ignores the plugin's attempt to +register its SDK as the global. A local collector stands in for LaunchDarkly; the agent's own +exporters are switched off so everything it receives came from the plugin. + +Usage: javaagent_smoke.py +""" + +import gzip +import http.server +import os +import signal +import socket +import struct +import subprocess +import sys +import threading +import time +import urllib.request + +LOOPBACK = socket.inet_ntoa(struct.pack("!I", socket.INADDR_LOOPBACK)) +TRACES = [] +SAMPLING_QUERIES = [] + + +class Collector(http.server.BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if "gzip" in (self.headers.get("Content-Encoding") or ""): + body = gzip.decompress(body) + if self.path.startswith("/v1/traces"): + TRACES.append(body) + elif self.path.startswith("/sampling"): + SAMPLING_QUERIES.append(body) + reply = b'{"data":{"sampling":{"spans":[],"logs":[]}}}' if self.path.startswith("/sampling") else b"" + self.send_response(200) + self.send_header("Content-Length", str(len(reply))) + self.end_headers() + self.wfile.write(reply) + + def log_message(self, *args): + pass + + +def free_port(): + with socket.socket() as s: + s.bind((LOOPBACK, 0)) + return s.getsockname()[1] + + +def get(url): + try: + with urllib.request.urlopen(url, timeout=5) as response: + return response.status + except urllib.error.HTTPError as e: + return e.code + except OSError: + return None + + +def traces_contain(*needles): + return any(all(n in payload for n in needles) for payload in list(TRACES)) + + +def main(): + app_jar, agent_jar = sys.argv[1], sys.argv[2] + collector = http.server.ThreadingHTTPServer((LOOPBACK, 0), Collector) + threading.Thread(target=collector.serve_forever, daemon=True).start() + # Both ends run on this machine over loopback, so plain HTTP is intended. + collector_url = "http://%s:%d" % (LOOPBACK, collector.server_address[1]) # DevSkim: ignore DS137138 + app_url = "http://%s:%d" % (LOOPBACK, free_port()) # DevSkim: ignore DS137138 + + env = dict(os.environ) + env.pop("LAUNCHDARKLY_SDK_KEY", None) + env.update({ + "PORT": app_url.rsplit(":", 1)[1], + "OTEL_EXPORTER_OTLP_ENDPOINT": collector_url, + "LD_OBSERVABILITY_BACKEND_URL": collector_url + "/sampling", + }) + command = [ + "java", + "-javaagent:" + agent_jar, + # The agent also reads OTEL_EXPORTER_OTLP_ENDPOINT; keep its own + # exporters off so only the plugin's data reaches the collector. + "-Dotel.traces.exporter=none", + "-Dotel.metrics.exporter=none", + "-Dotel.logs.exporter=none", + "-jar", app_jar, + ] + log_path = "app-javaagent.log" + failures = [] + with open(log_path, "wb") as log: + app = subprocess.Popen(command, env=env, stdout=log, stderr=subprocess.STDOUT) + try: + for _ in range(120): + if get(app_url + "/actuator/health") == 200: + break + if app.poll() is not None: + failures.append("app exited early with %s" % app.returncode) + break + time.sleep(1) + else: + failures.append("app did not become healthy") + + if not failures: + for path in ("/", "/rolldice", "/flags/example-greeting"): + if get(app_url + path) != 200: + failures.append("GET %s did not answer 200" % path) + if get(app_url + "/error") != 500: + failures.append("GET /error did not answer 500") + + # BatchSpanProcessor exports every second. + deadline = time.time() + 20 + while time.time() < deadline and not ( + traces_contain(b"LDClient.boolVariation", b"example-greeting") + and traces_contain(b"LDClient.intVariation", b"dice-sides") + and traces_contain(b"LDClient.jsonValueVariation", b"example-greeting") + ): + time.sleep(0.5) + for method, flag in ( + (b"LDClient.boolVariation", b"example-greeting"), + (b"LDClient.intVariation", b"dice-sides"), + (b"LDClient.jsonValueVariation", b"example-greeting"), + ): + if not traces_contain(method, flag): + failures.append("evaluation span %s for %s did not reach the plugin exporter" + % (method.decode(), flag.decode())) + if not traces_contain(b"feature_flag", b"GET /rolldice"): + failures.append("request span with a feature_flag event did not reach the plugin exporter") + if not SAMPLING_QUERIES: + failures.append("plugin did not fetch sampling config") + finally: + if app.poll() is None: + app.send_signal(signal.SIGTERM) + try: + app.wait(timeout=30) + except subprocess.TimeoutExpired: + app.kill() + failures.append("app did not stop on SIGTERM") + collector.shutdown() + + with open(log_path, "rb") as log: + output = log.read() + # The agent logs its version on startup. It also turns the plugin's + # GlobalOpenTelemetry.set() into a silent no-op, so the plugin never + # becomes the global in this run. + if b"opentelemetry-javaagent - version" not in output: + failures.append("the OpenTelemetry Java agent did not start") + if b"Closing LaunchDarkly Client" not in output: + failures.append("client did not close on shutdown") + + if failures: + sys.stdout.write(output.decode("utf-8", "replace")) + for failure in failures: + print("FAIL " + failure) + return 1 + print("Spring Boot backend under the Java agent OK: %d trace exports" % len(TRACES)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/e2e/java-plugin-spring-boot/settings.gradle b/e2e/java-plugin-spring-boot/settings.gradle new file mode 100644 index 0000000000..957c2ef056 --- /dev/null +++ b/e2e/java-plugin-spring-boot/settings.gradle @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = 'java-plugin-spring-boot-example' + +// By default the app builds against the plugin sources in this repository, so +// it needs no published artifact. Pass -PuseMavenLocal to resolve the plugin +// from ~/.m2 instead (after `./gradlew :lib:publishToMavenLocal` in the SDK +// directory), which is how an application would consume it. +if (!gradle.startParameter.projectProperties.containsKey('useMavenLocal')) { + includeBuild('../../sdk/@launchdarkly/observability-java') { + dependencySubstitution { + substitute module('com.launchdarkly:launchdarkly-java-server-sdk-observability') using project(':lib') + } + } +} diff --git a/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ApiController.java b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ApiController.java new file mode 100644 index 0000000000..1881ca8979 --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ApiController.java @@ -0,0 +1,62 @@ +package com.launchdarkly.observability.example.springboot; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.server.LDClient; +import io.opentelemetry.api.trace.Span; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Routes that exercise the plugin: every handler evaluates a flag (recorded as + * a span event by the plugin's hook), and some record errors. + */ +@RestController +public class ApiController { + private final LDClient client; + + public ApiController(LDClient client) { + this.client = client; + } + + @GetMapping("/") + public String index(@RequestHeader(value = "X-User-Key", required = false) String userKey) { + boolean flagged = client.boolVariation("example-greeting", context(userKey), false); + return (flagged ? "Hello from the flagged path" : "Hello, World!") + "\n"; + } + + @GetMapping("/rolldice") + public Map rollDice(@RequestHeader(value = "X-User-Key", required = false) String userKey) { + LDContext context = context(userKey); + int sides = client.intVariation("dice-sides", context, 6); + int roll = ThreadLocalRandom.current().nextInt(1, sides + 1); + Span.current().setAttribute("dice.roll", roll); + return Map.of("roll", roll, "sides", sides, "user", context.getKey()); + } + + @GetMapping("/flags/{key}") + public Map flag(@PathVariable String key, + @RequestHeader(value = "X-User-Key", required = false) String userKey) { + LDContext context = context(userKey); + return Map.of("key", key, "value", client.jsonValueVariation(key, context, com.launchdarkly.sdk.LDValue.ofNull()).toJsonString()); + } + + @GetMapping("/error") + public String error(@RequestHeader(value = "X-User-Key", required = false) String userKey) { + if (client.boolVariation("soft-errors", context(userKey), false)) { + return "soft error recorded\n"; + } + throw new IllegalStateException("random error from the Spring Boot example"); + } + + private static LDContext context(String userKey) { + String key = userKey == null || userKey.isEmpty() + ? "user-" + ThreadLocalRandom.current().nextInt(1000) + : userKey; + return LDContext.builder(key).build(); + } +} diff --git a/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/Application.java b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/Application.java new file mode 100644 index 0000000000..3a242a293b --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/Application.java @@ -0,0 +1,29 @@ +package com.launchdarkly.observability.example.springboot; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * A Spring Boot web backend that uses the LaunchDarkly Java Server SDK with + * the observability plugin the way a service does: a flag-aware client bean, + * a span, metrics and a log per request, and recorded errors. + * + *

Environment:

+ *
    + *
  • {@code LAUNCHDARKLY_SDK_KEY}: the SDK key. When unset the client runs + * offline so the backend still works without LaunchDarkly access.
  • + *
  • {@code PORT}: listen port, default 8080.
  • + *
  • {@code OTEL_EXPORTER_OTLP_ENDPOINT}: OTLP/HTTP base URL. Defaults to + * the LaunchDarkly endpoint.
  • + *
  • {@code LD_OBSERVABILITY_BACKEND_URL}: sampling-config URL. Defaults to + * the LaunchDarkly backend.
  • + *
  • {@code HTTPS_PROXY}: {@code host:port} of an egress proxy for exports + * and the sampling request.
  • + *
+ */ +@SpringBootApplication +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ErrorHandling.java b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ErrorHandling.java new file mode 100644 index 0000000000..ec07fa5c94 --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/ErrorHandling.java @@ -0,0 +1,28 @@ +package com.launchdarkly.observability.example.springboot; + +import com.launchdarkly.observability.LDObserve; +import io.opentelemetry.api.common.Attributes; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; + +/** + * Records every unhandled exception through the plugin before answering 500. + */ +@RestControllerAdvice +public class ErrorHandling { + + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity notFound(NoHandlerFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("no route for " + e.getRequestURL() + "\n"); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity unhandled(Exception e, HttpServletRequest request) { + LDObserve.recordError(e, Attributes.of(RequestTelemetryFilter.ROUTE, request.getRequestURI())); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("error: " + e.getMessage() + "\n"); + } +} diff --git a/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/LaunchDarklyConfiguration.java b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/LaunchDarklyConfiguration.java new file mode 100644 index 0000000000..b6d1db699d --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/LaunchDarklyConfiguration.java @@ -0,0 +1,94 @@ +package com.launchdarkly.observability.example.springboot; + +import com.launchdarkly.observability.LDObserve; +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.ObservabilityPlugin; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; +import java.util.List; + +/** + * Builds the one {@link LDClient} for the application with the observability + * plugin installed, and shuts both down with the context. + */ +@Configuration +public class LaunchDarklyConfiguration { + private static final Logger log = LoggerFactory.getLogger(LaunchDarklyConfiguration.class); + + @Bean(destroyMethod = "close") + public LDClient ldClient( + @Value("${LAUNCHDARKLY_SDK_KEY:}") String sdkKey, + @Value("${OTEL_EXPORTER_OTLP_ENDPOINT:}") String otlpEndpoint, + @Value("${LD_OBSERVABILITY_BACKEND_URL:}") String backendUrl, + @Value("${HTTPS_PROXY:${https_proxy:}}") String httpsProxy, + @Value("${SERVICE_VERSION:dev}") String serviceVersion, + @Value("${LD_BASE_URI:}") String baseUri, + @Value("${LD_STREAM_URI:}") String streamUri, + @Value("${LD_EVENTS_URI:}") String eventsUri) { + ObservabilityOptions.Builder options = new ObservabilityOptions.Builder() + .serviceName("java-plugin-spring-boot-example") + .serviceVersion(serviceVersion) + .environment("test") + .resourceAttributes(Attributes.of( + AttributeKey.stringKey("example.framework"), "spring-boot", + AttributeKey.stringKey("example.runtime"), System.getProperty("java.version"))) + .logsApiLevel(ObservabilityOptions.LogLevel.INFO) + // Spring closes the context in its own shutdown hook, after + // draining in-flight requests. The plugin's JVM hook would + // run alongside it and stop the providers mid-drain, so + // observabilityShutdown() below stops them instead. + .shutdownHook(false); + if (!otlpEndpoint.isEmpty()) { + options.otlpEndpoint(otlpEndpoint); + } + if (!backendUrl.isEmpty()) { + options.backendUrl(backendUrl); + } + if (!httpsProxy.isEmpty()) { + String hostPort = httpsProxy.replaceFirst("^[a-z]+://", "").replaceAll("/.*$", ""); + int colon = hostPort.lastIndexOf(':'); + options.proxyHostAndPort(hostPort.substring(0, colon), Integer.parseInt(hostPort.substring(colon + 1))); + } + + boolean offline = sdkKey.isEmpty(); + LDConfig.Builder config = new LDConfig.Builder() + .offline(offline) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options.build())))); + // Point the flag SDK at another LaunchDarkly instance (e.g. an + // internal one) when all three are set; otherwise the defaults apply. + if (!baseUri.isEmpty() && !streamUri.isEmpty() && !eventsUri.isEmpty()) { + config.serviceEndpoints(Components.serviceEndpoints() + .polling(baseUri) + .streaming(streamUri) + .events(eventsUri)); + } + LDClient client = new LDClient(offline ? "offline" : sdkKey, config.build()); + log.info("LaunchDarkly client initialized={}{}", client.isInitialized(), + offline ? " (offline: LAUNCHDARKLY_SDK_KEY not set)" : ""); + return client; + } + + /** Flushes and stops the OpenTelemetry providers after the client bean closes. */ + @Bean + public ObservabilityShutdown observabilityShutdown() { + return new ObservabilityShutdown(); + } + + static final class ObservabilityShutdown implements AutoCloseable { + @Override + public void close() { + LDObserve.shutdown(); + } + } +} diff --git a/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/RequestTelemetryFilter.java b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/RequestTelemetryFilter.java new file mode 100644 index 0000000000..19b7646996 --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/RequestTelemetryFilter.java @@ -0,0 +1,59 @@ +package com.launchdarkly.observability.example.springboot; + +import com.launchdarkly.observability.LDObserve; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Scope; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * One span, a duration histogram, a request counter and a log line per HTTP + * request. Controllers run inside the span, so flag evaluations and errors + * recorded there are children of it. + */ +@Component +public class RequestTelemetryFilter extends OncePerRequestFilter { + static final AttributeKey ROUTE = AttributeKey.stringKey("http.route"); + static final AttributeKey METHOD = AttributeKey.stringKey("http.request.method"); + static final AttributeKey STATUS = AttributeKey.longKey("http.response.status_code"); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String route = request.getRequestURI(); + long started = System.nanoTime(); + Span span = LDObserve.startSpan(request.getMethod() + " " + route, + Attributes.of(ROUTE, route, METHOD, request.getMethod())); + try (Scope ignored = span.makeCurrent()) { + chain.doFilter(request, response); + } catch (Exception e) { + span.setStatus(StatusCode.ERROR, e.getMessage()); + throw e; + } finally { + int status = response.getStatus(); + if (status >= 500) { + span.setStatus(StatusCode.ERROR); + } + span.setAttribute(STATUS, (long) status); + span.end(); + + double millis = TimeUnit.NANOSECONDS.toMicros(System.nanoTime() - started) / 1000.0; + Attributes attrs = Attributes.of(ROUTE, route, STATUS, (long) status); + LDObserve.recordHistogram("http.server.request.duration_ms", millis, attrs); + LDObserve.recordIncr("http.server.requests", attrs); + LDObserve.recordLog(request.getMethod() + " " + route + " -> " + status, + status >= 500 ? Severity.ERROR : Severity.INFO, attrs); + } + } +} diff --git a/e2e/java-plugin-spring-boot/src/main/resources/application.properties b/e2e/java-plugin-spring-boot/src/main/resources/application.properties new file mode 100644 index 0000000000..e71dddaa4e --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/main/resources/application.properties @@ -0,0 +1,8 @@ +spring.application.name=java-plugin-spring-boot-example +server.port=${PORT:8080} +server.shutdown=graceful +spring.lifecycle.timeout-per-shutdown-phase=15s +management.endpoints.web.exposure.include=health +# Let the controller advice see unknown paths as 404s instead of the whitelabel page. +spring.web.resources.add-mappings=false +spring.mvc.throw-exception-if-no-handler-found=true diff --git a/e2e/java-plugin-spring-boot/src/test/java/com/launchdarkly/observability/example/springboot/ApplicationTelemetryTest.java b/e2e/java-plugin-spring-boot/src/test/java/com/launchdarkly/observability/example/springboot/ApplicationTelemetryTest.java new file mode 100644 index 0000000000..7b0f233d58 --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/test/java/com/launchdarkly/observability/example/springboot/ApplicationTelemetryTest.java @@ -0,0 +1,250 @@ +package com.launchdarkly.observability.example.springboot; + +import com.launchdarkly.observability.LDObserve; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BooleanSupplier; +import java.util.zip.GZIPInputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * Boots the whole application against a local OTLP collector and sampling + * endpoint, drives its routes over HTTP, and checks what the plugin exports. + * The LaunchDarkly client is offline (no SDK key), so flags return defaults. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ApplicationTelemetryTest { + + /** Drops health-check request spans, keeps everything else. */ + private static final String SAMPLING_RESPONSE = + "{\"data\":{\"sampling\":{\"spans\":[" + + "{\"name\":{\"regexValue\":\"^GET /actuator/health\"},\"attributes\":[],\"events\":[],\"samplingRatio\":0}" + + "],\"logs\":[]}}}"; + + private static final List TRACES = new CopyOnWriteArrayList<>(); + private static final List LOGS = new CopyOnWriteArrayList<>(); + private static final List METRICS = new CopyOnWriteArrayList<>(); + private static final List SAMPLING_QUERIES = new CopyOnWriteArrayList<>(); + private static final HttpServer COLLECTOR = startCollector(); + + @Autowired + private TestRestTemplate http; + + @DynamicPropertySource + static void pointPluginAtCollector(DynamicPropertyRegistry registry) { + String base = "http".concat("://") + COLLECTOR.getAddress().getHostString() + ":" + COLLECTOR.getAddress().getPort(); + registry.add("OTEL_EXPORTER_OTLP_ENDPOINT", () -> base); + registry.add("LD_OBSERVABILITY_BACKEND_URL", () -> base + "/sampling"); + registry.add("LAUNCHDARKLY_SDK_KEY", () -> ""); + registry.add("SERVICE_VERSION", () -> "test-version"); + } + + @AfterAll + static void stopCollector() { + COLLECTOR.stop(0); + } + + @Test + void indexEvaluatesFlagInsideRequestSpan() { + ResponseEntity response = get("/", "alice"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo("Hello, World!\n"); + + awaitExported(TRACES, "GET /", "LDClient.boolVariation", "example-greeting"); + // The plugin's hook adds the feature_flag event to the current + // (request) span and records its own evaluation span. + awaitExported(TRACES, "GET /", "feature_flag", "feature_flag.key"); + awaitExported(TRACES, "java-plugin-spring-boot-example", "test-version", "example.framework", "spring-boot"); + } + + @Test + void rollDiceUsesFlagDefaultAndRecordsAttribute() { + ResponseEntity response = http.exchange("/rolldice", HttpMethod.GET, withUser("bob"), Map.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("user", "bob").containsEntry("sides", 6); + assertThat((Integer) response.getBody().get("roll")).isBetween(1, 6); + + awaitExported(TRACES, "GET /rolldice", "dice.roll"); + awaitExported(TRACES, "LDClient.intVariation", "dice-sides", "feature_flag.context.id", "bob"); + } + + @Test + void flagRouteReturnsJsonDefault() { + ResponseEntity response = http.exchange("/flags/some-flag", HttpMethod.GET, withUser("carol"), Map.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("key", "some-flag").containsEntry("value", "null"); + + awaitExported(TRACES, "LDClient.jsonValueVariation", "some-flag"); + } + + @Test + void errorIsRecordedAndAnswered500() { + ResponseEntity response = get("/error", "dave"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isEqualTo("error: random error from the Spring Boot example\n"); + + // Recorded as an exception event on the filter's request span. + awaitExported(TRACES, "GET /error", "java.lang.IllegalStateException", + "random error from the Spring Boot example", "exception.stacktrace"); + awaitExported(LOGS, "GET /error -> 500", "ERROR"); + } + + @Test + void unknownRouteIs404() { + ResponseEntity response = get("/no-such-route", "erin"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + awaitExported(LOGS, "GET /no-such-route -> 404"); + } + + @Test + void everyRequestEmitsLogAndMetrics() { + assertThat(get("/", "frank").getStatusCode()).isEqualTo(HttpStatus.OK); + + awaitExported(LOGS, "GET / -> 200", "INFO", "http.route"); + // The periodic metric reader exports every 5 seconds. + awaitExported(METRICS, "http.server.requests", "http.server.request.duration_ms", "http.route"); + } + + @Test + void backendSamplingRuleDropsHealthChecks() { + awaitTrue(() -> !SAMPLING_QUERIES.isEmpty(), "the plugin never fetched sampling config"); + assertThat(SAMPLING_QUERIES.get(0)).contains("organization_verbose_id"); + + // The rule applies once the background fetch lands; retry until a + // batch carries the kept span without the health check. + awaitTrue(() -> { + int before = TRACES.size(); + assertThat(http.getForEntity("/actuator/health", String.class).getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(get("/flags/after-health", "gina").getStatusCode()).isEqualTo(HttpStatus.OK); + LDObserve.flush(); + String batch = String.join("\n", TRACES.subList(before, TRACES.size())); + return batch.contains("GET /flags/after-health") && !batch.contains("GET /actuator/health"); + }, "sampling rule did not drop GET /actuator/health spans: " + summarize(TRACES)); + } + + private ResponseEntity get(String path, String userKey) { + return http.exchange(path, HttpMethod.GET, withUser(userKey), String.class); + } + + private static HttpEntity withUser(String userKey) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-User-Key", userKey); + return new HttpEntity<>(headers); + } + + /** Waits until one exported payload contains every needle. */ + private static void awaitExported(List payloads, String... needles) { + awaitTrue(() -> { + LDObserve.flush(); + for (String payload : new ArrayList<>(payloads)) { + boolean all = true; + for (String needle : needles) { + if (!payload.contains(needle)) { + all = false; + break; + } + } + if (all) { + return true; + } + } + return false; + }, "no exported payload contains all of " + List.of(needles) + ":\n" + summarize(payloads)); + } + + private static void awaitTrue(BooleanSupplier condition, String message) { + long deadline = System.nanoTime() + 15_000_000_000L; + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) { + return; + } + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + fail(message); + } + + private static String summarize(List payloads) { + StringBuilder text = new StringBuilder(); + for (String payload : new ArrayList<>(payloads)) { + for (char c : payload.toCharArray()) { + text.append(c >= 32 && c < 127 ? c : ' '); + } + text.append('\n'); + } + return text.length() > 2000 ? text.substring(0, 2000) : text.toString(); + } + + private static HttpServer startCollector() { + try { + HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/sampling", exchange -> { + SAMPLING_QUERIES.add(body(exchange)); + respond(exchange, "application/json", SAMPLING_RESPONSE.getBytes(StandardCharsets.UTF_8)); + }); + server.createContext("/v1/traces", exchange -> { + TRACES.add(body(exchange)); + respond(exchange, "application/x-protobuf", new byte[0]); + }); + server.createContext("/v1/logs", exchange -> { + LOGS.add(body(exchange)); + respond(exchange, "application/x-protobuf", new byte[0]); + }); + server.createContext("/v1/metrics", exchange -> { + METRICS.add(body(exchange)); + respond(exchange, "application/x-protobuf", new byte[0]); + }); + server.start(); + return server; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static String body(HttpExchange exchange) throws IOException { + byte[] raw = exchange.getRequestBody().readAllBytes(); + String encoding = exchange.getRequestHeaders().getFirst("Content-Encoding"); + if (encoding != null && encoding.toLowerCase().contains("gzip")) { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(raw))) { + raw = in.readAllBytes(); + } + } + return new String(raw, StandardCharsets.UTF_8); + } + + private static void respond(HttpExchange exchange, String contentType, byte[] body) throws IOException { + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } +} diff --git a/e2e/java-plugin/.gitignore b/e2e/java-plugin/.gitignore new file mode 100644 index 0000000000..f8b92c3aa0 --- /dev/null +++ b/e2e/java-plugin/.gitignore @@ -0,0 +1,2 @@ +.gradle +build diff --git a/e2e/java-plugin/Makefile b/e2e/java-plugin/Makefile new file mode 100644 index 0000000000..b50f6e502d --- /dev/null +++ b/e2e/java-plugin/Makefile @@ -0,0 +1,20 @@ +.PHONY: run check consumer-check + +SDK_DIR := ../../sdk/@launchdarkly/observability-java +SDK_GRADLE := $(SDK_DIR)/gradlew +GRADLE_ARGS := --no-daemon --console=plain + +# Self-checking example against the plugin sources in this repository, once +# per owner of GlobalOpenTelemetry: the plugin, a no-op claimed before the +# client starts, and the OpenTelemetry Java agent. +RUN_TASKS := run runClaimedGlobal runWithJavaAgent + +run check: + $(SDK_GRADLE) -p $(CURDIR) $(RUN_TASKS) $(GRADLE_ARGS) + +# Publishes the plugin to ~/.m2 and builds this example against that +# artifact, the way an application would. Catches POM scope mistakes and +# transitive dependency leaks that the composite build hides. +consumer-check: + cd $(SDK_DIR) && ./gradlew :lib:publishToMavenLocal $(GRADLE_ARGS) + $(SDK_GRADLE) -p $(CURDIR) check $(RUN_TASKS) $(GRADLE_ARGS) -PuseMavenLocal diff --git a/e2e/java-plugin/README.md b/e2e/java-plugin/README.md new file mode 100644 index 0000000000..3806a44000 --- /dev/null +++ b/e2e/java-plugin/README.md @@ -0,0 +1,41 @@ +# Java observability plugin self-check + +`PluginExample` is a runnable check for the LaunchDarkly Java server +observability plugin. It starts a local OTLP and sampling endpoint, registers +`ObservabilityPlugin` on an offline Java server SDK client, and exits non-zero +unless the collector saw the expected signals. It builds against the plugin +sources in this repository by default, so it never needs a published artifact. + +For a web backend that uses the plugin the way a service does, see +[`../java-plugin-spring-boot`](../java-plugin-spring-boot). + +## Run + +```bash +make run +``` + +`make run` runs the check three times, once per owner of +`GlobalOpenTelemetry`: the plugin (`run`), a no-op claimed by +`GlobalOpenTelemetry.get()` before the client starts (`runClaimedGlobal`), and +the OpenTelemetry Java agent (`runWithJavaAgent`). The flag evaluation must +reach the plugin's exporter in all three. + +Each passing run prints one `RESULT` line. `checkout-enabled` is the +flag-evaluation span from the plugin's tracing hook. `kept-checkout` is kept by the +sampling rule and carries `launchdarkly.sampling.ratio`. `health-check` is +dropped. A call to `LDObserve` before the client starts must not install the +OpenTelemetry no-op global; the flag span only arrives when the hook is on the +plugin's exporters. + +## Consumer check + +```bash +make consumer-check +``` + +Publishes the plugin to `~/.m2` and builds the example against that artifact +with `-PuseMavenLocal`, the way an application would. This verifies the +published POM: the example compiles against `Attributes` and `Severity` without +declaring `opentelemetry-api` itself, and no OkHttp, Okio or Kotlin arrives +transitively. CI runs this on JDK 11 and 21 for every change to the plugin. diff --git a/e2e/java-plugin/build.gradle b/e2e/java-plugin/build.gradle new file mode 100644 index 0000000000..b7e29447e0 --- /dev/null +++ b/e2e/java-plugin/build.gradle @@ -0,0 +1,98 @@ +plugins { + id 'application' +} + +def useMavenLocal = project.hasProperty('useMavenLocal') + +repositories { + if (useMavenLocal) { + mavenLocal() + } + mavenCentral() +} + +tasks.withType(JavaCompile).configureEach { + options.release = 11 +} + +// The plugin version comes from the SDK's gradle.properties, which +// release-please bumps, so this example never pins a stale version. +def sdkProperties = new Properties() +file('../../sdk/@launchdarkly/observability-java/gradle.properties').withInputStream { sdkProperties.load(it) } +def pluginVersion = sdkProperties.getProperty('version') + +configurations { + javaagent +} + +dependencies { + implementation "com.launchdarkly:launchdarkly-java-server-sdk-observability:${pluginVersion}" + implementation 'com.launchdarkly:launchdarkly-java-server-sdk:7.12.0' + // No explicit opentelemetry-api: the example uses Attributes and Severity, + // which must arrive through the plugin's compile-scoped dependency. + + // Agent release that ships the OpenTelemetry SDK version the plugin uses. + javaagent 'io.opentelemetry.javaagent:opentelemetry-javaagent:2.17.0' +} + +application { + mainClass = 'com.launchdarkly.observability.example.PluginExample' +} + +// The plugin must export flag evaluations whoever owns GlobalOpenTelemetry. +// `run` is the plain case where the plugin registers its SDK as the global; +// these cover the global being claimed first and the Java agent owning it. +tasks.register('runClaimedGlobal', JavaExec) { + group = 'verification' + description = 'Runs the self-check with GlobalOpenTelemetry claimed before the client starts.' + classpath = sourceSets.main.runtimeClasspath + mainClass = application.mainClass + args 'claimed-global' +} + +tasks.register('runWithJavaAgent', JavaExec) { + group = 'verification' + description = 'Runs the self-check under the OpenTelemetry Java agent.' + classpath = sourceSets.main.runtimeClasspath + mainClass = application.mainClass + args 'javaagent' + def agent = configurations.javaagent + inputs.files(agent) + doFirst { + jvmArgs "-javaagent:${agent.singleFile}", + // The agent's own exporters have no collector here. + '-Dotel.traces.exporter=none', + '-Dotel.metrics.exporter=none', + '-Dotel.logs.exporter=none', + '-Dotel.javaagent.logging=none' + } +} + +// Groups that must never reach a consumer's runtime classpath through the plugin. +def forbiddenRuntimeGroups = ['com.squareup.okhttp3', 'com.squareup.okio', 'org.jetbrains.kotlin', 'org.jetbrains.kotlinx'] + +tasks.register('checkConsumerClasspath') { + group = 'verification' + description = 'Fails when the plugin leaks OkHttp, Okio or Kotlin into this consumer, or fails to provide opentelemetry-api.' + def runtime = configurations.runtimeClasspath + def compile = configurations.compileClasspath + doLast { + def leaked = runtime.resolvedConfiguration.resolvedArtifacts + .collect { it.moduleVersion.id } + .findAll { forbiddenRuntimeGroups.contains(it.group) } + .collect { "${it.group}:${it.name}:${it.version}" } + .unique() + if (!leaked.isEmpty()) { + throw new GradleException("Plugin leaked forbidden artifacts into the consumer runtime classpath: ${leaked.join(', ')}") + } + def otelApi = compile.resolvedConfiguration.resolvedArtifacts + .find { it.moduleVersion.id.group == 'io.opentelemetry' && it.moduleVersion.id.name == 'opentelemetry-api' } + if (otelApi == null) { + throw new GradleException('opentelemetry-api is not on the consumer compile classpath; the plugin must publish it with compile scope') + } + } +} + +tasks.named('check') { + dependsOn 'checkConsumerClasspath' +} diff --git a/e2e/java-plugin/settings.gradle b/e2e/java-plugin/settings.gradle new file mode 100644 index 0000000000..75ca90cbf7 --- /dev/null +++ b/e2e/java-plugin/settings.gradle @@ -0,0 +1,15 @@ +rootProject.name = 'java-plugin-example' + +// By default the example builds against the plugin sources in this repository, +// so it needs no published artifact. Pass -PuseMavenLocal to resolve the +// plugin from ~/.m2 instead (after `./gradlew :lib:publishToMavenLocal` in the +// SDK directory). CI uses that mode to check the published POM: the example +// must compile against OpenTelemetry API types it never declares itself, and +// no OkHttp, Okio or Kotlin may arrive transitively. +if (!gradle.startParameter.projectProperties.containsKey('useMavenLocal')) { + includeBuild('../../sdk/@launchdarkly/observability-java') { + dependencySubstitution { + substitute module('com.launchdarkly:launchdarkly-java-server-sdk-observability') using project(':lib') + } + } +} diff --git a/e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/PluginExample.java b/e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/PluginExample.java new file mode 100644 index 0000000000..5cb78a7216 --- /dev/null +++ b/e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/PluginExample.java @@ -0,0 +1,252 @@ +package com.launchdarkly.observability.example; + +import com.launchdarkly.observability.LDObserve; +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.ObservabilityPlugin; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Scope; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.zip.GZIPInputStream; + +/** + * Runs the Java observability plugin against a local collector and exits + * non-zero when a required signal is missing. + * + *

The first argument picks who owns {@code GlobalOpenTelemetry}:

+ *
    + *
  • {@code plugin} (default): nothing else claims it, so the plugin + * registers its SDK as the global.
  • + *
  • {@code claimed-global}: {@code GlobalOpenTelemetry.get()} runs before + * the client is built, as a library would, locking the global onto a + * no-op.
  • + *
  • {@code javaagent}: the OpenTelemetry Java agent owns the global and + * the evaluation runs inside an agent span.
  • + *
+ *

In every mode the flag evaluation must reach the plugin's exporter.

+ */ +public final class PluginExample { + private static final String SAMPLING_RESPONSE = + "{\"data\":{\"sampling\":{\"spans\":[" + + "{\"name\":{\"matchValue\":\"health-check\"},\"attributes\":[],\"events\":[],\"samplingRatio\":0}," + + "{\"name\":{\"matchValue\":\"kept-checkout\"},\"attributes\":[],\"events\":[],\"samplingRatio\":1}" + + "],\"logs\":[]}}}"; + + private final List traces = new CopyOnWriteArrayList<>(); + private final List samplingQueries = new CopyOnWriteArrayList<>(); + private final List failures = new ArrayList<>(); + + public static void main(String[] args) throws Exception { + String mode = args.length > 0 ? args[0] : "plugin"; + int status = new PluginExample().run(mode); + if (status != 0) { + System.exit(status); + } + } + + private int run(String mode) throws Exception { + boolean agentAttached = ManagementFactory.getRuntimeMXBean().getInputArguments().stream() + .anyMatch(arg -> arg.startsWith("-javaagent:")); + switch (mode) { + case "plugin": + case "claimed-global": + if (agentAttached) { + failures.add("mode " + mode + " must run without -javaagent"); + } + break; + case "javaagent": + if (!agentAttached) { + failures.add("mode javaagent needs -javaagent on the command line"); + } + break; + default: + System.err.println("unknown mode " + mode + "; expected plugin, claimed-global or javaagent"); + return 2; + } + if (mode.equals("claimed-global")) { + GlobalOpenTelemetry.get(); + } + + LDObserve.startSpan("before-init", Attributes.empty()).end(); + LDObserve.recordLog("before init", Severity.INFO, Attributes.empty()); + + HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/sampling", exchange -> { + samplingQueries.add(bodyAsString(exchange)); + respond(exchange, SAMPLING_RESPONSE.getBytes(StandardCharsets.UTF_8)); + }); + server.createContext("/v1/traces", exchange -> { + traces.add(bodyAsString(exchange)); + respond(exchange, new byte[0]); + }); + server.createContext("/v1/logs", exchange -> { + bodyAsString(exchange); + respond(exchange, new byte[0]); + }); + server.createContext("/v1/metrics", exchange -> { + bodyAsString(exchange); + respond(exchange, new byte[0]); + }); + server.start(); + + String base = "http".concat("://") + server.getAddress().getHostString() + ":" + server.getAddress().getPort(); + ObservabilityOptions options = new ObservabilityOptions.Builder() + .serviceName("orders-service") + .serviceVersion("1.2.3") + .environment("test") + .otlpEndpoint(base) + .backendUrl(base + "/sampling") + .build(); + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options)))) + .build(); + + try (LDClient client = new LDClient("sdk-test", config)) { + if (!waitForSamplingQuery()) { + failures.add("plugin did not fetch sampling config"); + } + + String global = globalOwner(); + String expectedGlobal = mode.equals("plugin") ? "plugin" : mode.equals("javaagent") ? "agent" : "noop"; + if (!global.equals(expectedGlobal)) { + failures.add("GlobalOpenTelemetry is owned by " + global + ", expected " + expectedGlobal); + } + + // Evaluate inside a span from the global, as instrumented request + // handling would. Under the agent that is an agent span. + Span request = GlobalOpenTelemetry.getTracer("example").spanBuilder("GET /checkout").startSpan(); + try (Scope ignored = request.makeCurrent()) { + client.boolVariation("checkout-enabled", LDContext.create("user-key-test"), false); + } finally { + request.end(); + } + LDObserve.flush(); + boolean flagArrived = contains(traces, 0, "checkout-enabled"); + + boolean kept = false; + boolean dropped = false; + boolean ratioArrived = false; + for (int attempt = 0; attempt < 50 && !(kept && dropped); attempt++) { + int before = traces.size(); + LDObserve.startSpan("health-check", Attributes.empty()).end(); + LDObserve.startSpan("kept-checkout", Attributes.empty()).end(); + LDObserve.flush(); + boolean batchHasKept = contains(traces, before, "kept-checkout"); + boolean batchHasHealth = contains(traces, before, "health-check"); + if (batchHasKept && !batchHasHealth) { + kept = true; + dropped = true; + ratioArrived = contains(traces, before, "launchdarkly.sampling.ratio"); + } + if (!kept) { + Thread.sleep(100); + } + } + + if (!flagArrived) { + failures.add("flag evaluation span checkout-enabled did not arrive with the global owned by " + global + + "; the tracing hook is not on the plugin exporters"); + } + if (!kept) { + failures.add("kept span kept-checkout did not arrive"); + } + if (!dropped) { + failures.add("sampling rule did not drop health-check"); + } + if (kept && !ratioArrived) { + failures.add("kept span kept-checkout arrived without launchdarkly.sampling.ratio"); + } + if (contains(traces, 0, "before-init")) { + failures.add("span before-init was exported; the pre-init call was not a local no-op"); + } + + System.out.println("RESULT" + + " mode=" + mode + + " flag-evaluation=" + (flagArrived ? "checkout-enabled" : "missing") + + " kept=" + (kept ? "kept-checkout" : "missing") + + " dropped=" + (dropped ? "health-check" : "still-exported") + + " sampling-ratio=" + (ratioArrived ? "present" : "missing") + + " global=" + global); + } finally { + LDObserve.shutdown(); + server.stop(0); + } + + if (!failures.isEmpty()) { + for (String failure : failures) { + System.err.println("FAIL " + failure); + } + return 1; + } + return 0; + } + + /** + * Reports who owns GlobalOpenTelemetry: "noop" when its spans are + * invalid, "agent" when the Java agent bridges it, otherwise "plugin". + */ + private static String globalOwner() { + Span probe = GlobalOpenTelemetry.getTracer("example").spanBuilder("global-probe").startSpan(); + boolean recording = probe.getSpanContext().isValid(); + probe.end(); + if (!recording) { + return "noop"; + } + return GlobalOpenTelemetry.get().getClass().getName().contains("javaagent") ? "agent" : "plugin"; + } + + private boolean waitForSamplingQuery() throws InterruptedException { + for (int i = 0; i < 50 && samplingQueries.isEmpty(); i++) { + Thread.sleep(100); + } + return !samplingQueries.isEmpty(); + } + + private static boolean contains(List payloads, int from, String needle) { + List snapshot = new ArrayList<>(payloads); + for (int i = from; i < snapshot.size(); i++) { + if (snapshot.get(i).contains(needle)) { + return true; + } + } + return false; + } + + private static String bodyAsString(HttpExchange exchange) throws IOException { + byte[] raw = exchange.getRequestBody().readAllBytes(); + String encoding = exchange.getRequestHeaders().getFirst("Content-Encoding"); + if (encoding != null && encoding.toLowerCase().contains("gzip")) { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(raw))) { + raw = in.readAllBytes(); + } + } + return new String(raw, StandardCharsets.UTF_8); + } + + private static void respond(HttpExchange exchange, byte[] body) throws IOException { + exchange.getResponseHeaders().set("Content-Type", "application/x-protobuf"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } +} diff --git a/e2e/tests/src/app_runner.py b/e2e/tests/src/app_runner.py index c68b076eaa..9e76eb3336 100644 --- a/e2e/tests/src/app_runner.py +++ b/e2e/tests/src/app_runner.py @@ -45,10 +45,11 @@ def run_and_poll( request: Optional[Callable[[], requests.Response]], pre: Optional[Callable[[], None]] = None, cwd: Optional[str] = None, + env: Optional[dict[str, str]] = None, ): if pre: pre() - proc = run(node_bin, args, cwd=cwd) + proc = run(node_bin, args, cwd=cwd, env=env) try: for _ in range(60): try: @@ -179,6 +180,7 @@ def main(): "dotnet", "dotnet4", "go", + "java-plugin-spring-boot", "python", "ruby", ], diff --git a/e2e/tests/src/conftest.py b/e2e/tests/src/conftest.py index 94f18870ba..7fcd2b213b 100644 --- a/e2e/tests/src/conftest.py +++ b/e2e/tests/src/conftest.py @@ -1,3 +1,4 @@ +import glob import logging import os import subprocess @@ -131,3 +132,63 @@ def dotnet_app(dotnet_bin): lambda: requests.get("http://localhost:5249/"), cwd="dotnet", ) + + +@pytest.fixture(scope="session") +def java_bin(): + stdout = subprocess.check_output(["which", "java"], text=True, env=os.environ) + return os.path.realpath(os.path.dirname(stdout)) + + +JAVA_APP_PORT = 8090 + + +@pytest.fixture() +def java_app(java_bin): + """The Spring Boot backend in e2e/java-plugin-spring-boot, built against + the plugin from source and exporting to the local backend as project 1.""" + sdk_dir = "../sdk/@launchdarkly/observability-java" + app_dir = "java-plugin-spring-boot" + + def build(): + for args in ( + [f"{sdk_dir}/gradlew", "-p", sdk_dir, ":lib:publishToMavenLocal"], + [f"{sdk_dir}/gradlew", "-p", app_dir, "bootJar", "-PuseMavenLocal"], + ): + proc = run(java_bin, args + ["--no-daemon", "--console=plain"]) + stdout, stderr = proc.communicate() + logging.info("java build output") + for line in stdout.splitlines(): + logging.info(line) + for line in stderr.splitlines(): + logging.info(line) + assert not proc.returncode, stderr + + def app_jar(): + e2e_dir = os.path.realpath( + os.path.join(__file__, os.pardir, os.pardir, os.pardir) + ) + jars = [ + j + for j in glob.glob(f"{e2e_dir}/{app_dir}/build/libs/*.jar") + if not j.endswith("-plain.jar") + ] + assert len(jars) == 1, jars + return jars[0] + + build() + yield from run_and_poll( + java_bin, + ["java", "-jar", app_jar()], + lambda: requests.get(f"http://localhost:{JAVA_APP_PORT}/actuator/health"), + env={ + "PORT": str(JAVA_APP_PORT), + # The plugin reports the SDK key as the project id; the local + # backend's project is 1. The flag SDK cannot connect with this + # key and serves flag defaults, which the tests expect. + "LAUNCHDARKLY_SDK_KEY": "1", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318", + "LD_OBSERVABILITY_BACKEND_URL": "http://localhost:8082/public", + "SERVICE_VERSION": "e2e", + }, + ) diff --git a/e2e/tests/src/test_sdk.py b/e2e/tests/src/test_sdk.py index 57c87ff229..e532dd2bd8 100644 --- a/e2e/tests/src/test_sdk.py +++ b/e2e/tests/src/test_sdk.py @@ -333,7 +333,7 @@ def validator_traces(data: dict[str, any]): for item in filter( lambda eg: eg["node"]["spanName"] == exp, data["traces"]["edges"] ): - logging.info(f"checking item {item}") + logging.info(f"checking span {exp}") assert item["node"]["projectID"] == 1 assert item["node"]["secureSessionID"] == "a1b2c30002" assert item["node"]["traceID"] == "aadbc7a427cc836727783c887b329f44" @@ -362,3 +362,118 @@ def validator_traces(data: dict[str, any]): }, validator=validator_traces, ) + + +JAVA_SERVICE = "java-plugin-spring-boot-example" + + +def _date_range(start, ts): + return { + "start_date": f'{start.isoformat(timespec="microseconds")}000-00:00', + "end_date": f'{ts.isoformat(timespec="microseconds")}000-00:00', + } + + +def test_java_logs(java_app, oauth_api): + start = datetime.utcnow() - timedelta(minutes=1) + r = requests.get( + "http://localhost:8090/rolldice", headers={"X-User-Key": "e2e-java"}, timeout=30 + ) + logging.info(f"GET {r.url} {r.status_code} {r.text}") + assert r.ok + + def validator(data: dict[str, any]): + logs = [ + e["node"] + for e in data["logs"]["edges"] + if e["node"]["message"] == "GET /rolldice -> 200" + ] + assert logs, f"no rolldice log in {data['logs']['edges']}" + for log in logs: + assert log["level"] == "info" + assert log["serviceName"] == JAVA_SERVICE + assert log["serviceVersion"] == "e2e" + + query( + oauth_api, + "GetLogs", + GET_LOGS, + variables_fn=lambda ts: { + "project_id": "1", + "direction": "DESC", + "params": { + "query": "telemetry.sdk.language=java", + "date_range": _date_range(start, ts), + }, + }, + validator=validator, + ) + + +def test_java_traces(java_app, oauth_api): + start = datetime.utcnow() - timedelta(minutes=1) + r = requests.get( + "http://localhost:8090/rolldice", headers={"X-User-Key": "e2e-java"}, timeout=30 + ) + logging.info(f"GET {r.url} {r.status_code} {r.text}") + assert r.ok + + def validator(data: dict[str, any]): + spans = {e["node"]["spanName"]: e["node"] for e in data["traces"]["edges"]} + # The request span from LDObserve.startSpan and the flag evaluation + # span from the plugin's hook, which is its child. + for name in ("GET /rolldice", "LDClient.intVariation"): + assert name in spans, f"{name} not in {list(spans)}" + node = spans[name] + assert node["projectID"] == 1 + assert node["serviceName"] == JAVA_SERVICE + assert node["serviceVersion"] == "e2e" + assert ( + node["traceAttributes"]["telemetry"]["distro"]["name"] + == "com.launchdarkly.observability" + ) + evaluation = spans["LDClient.intVariation"] + assert evaluation["traceID"] == spans["GET /rolldice"]["traceID"] + assert evaluation["parentSpanID"] == spans["GET /rolldice"]["spanID"] + + query( + oauth_api, + "GetTraces", + GET_TRACES, + variables_fn=lambda ts: { + "project_id": "1", + "direction": "DESC", + "params": { + "query": "telemetry.sdk.language=java", + "date_range": _date_range(start, ts), + }, + }, + validator=validator, + ) + + +def test_java_error(java_app, oauth_api): + start = datetime.utcnow() - timedelta(minutes=1) + r = requests.get( + "http://localhost:8090/error", headers={"X-User-Key": "e2e-java"}, timeout=30 + ) + logging.info(f"GET {r.url} {r.status_code} {r.text}") + assert r.status_code == 500 + + def validator(data: dict[str, any]): + events = {eg["event"][0] for eg in data["error_groups"]["error_groups"]} + assert "random error from the Spring Boot example" in events, events + + query( + oauth_api, + "GetErrorGroups", + GET_ERROR_GROUPS, + variables_fn=lambda ts: { + "params": {"query": "", "date_range": _date_range(start, ts)}, + "count": 10, + "page": 1, + "project_id": "1", + "sort_desc": False, + }, + validator=validator, + ) diff --git a/release-please-config.json b/release-please-config.json index a4432ea213..34795656e4 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -22,6 +22,17 @@ "include-component-in-tag": true, "extra-files": ["gradle.properties"] }, + "sdk/@launchdarkly/observability-java": { + "package-name": "launchdarkly-java-server-sdk-observability", + "release-type": "simple", + "versioning": "default", + "include-v-in-tag": false, + "include-component-in-tag": true, + "extra-files": [ + "gradle.properties", + "lib/src/main/java/com/launchdarkly/observability/internal/Constants.java" + ] + }, "sdk/flutter/packages/observability": { "package-name": "launchdarkly_flutter_observability", "release-type": "dart", diff --git a/sdk/@launchdarkly/observability-java/.gitignore b/sdk/@launchdarkly/observability-java/.gitignore new file mode 100644 index 0000000000..1b6985c009 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/.gitignore @@ -0,0 +1,5 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build diff --git a/sdk/@launchdarkly/observability-java/CHANGELOG.md b/sdk/@launchdarkly/observability-java/CHANGELOG.md new file mode 100644 index 0000000000..825c32f0d0 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/sdk/@launchdarkly/observability-java/Makefile b/sdk/@launchdarkly/observability-java/Makefile new file mode 100644 index 0000000000..32678817c2 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/Makefile @@ -0,0 +1,10 @@ +.PHONY: install build test + +install: + @echo "Install target does nothing for this project" + +build: + ./gradlew :lib:build + +test: + ./gradlew :lib:test diff --git a/sdk/@launchdarkly/observability-java/README.md b/sdk/@launchdarkly/observability-java/README.md new file mode 100644 index 0000000000..1f784a55ad --- /dev/null +++ b/sdk/@launchdarkly/observability-java/README.md @@ -0,0 +1,316 @@ +# LaunchDarkly Observability plugin for the Java Server SDK + +Official LaunchDarkly Observability plugin for the [Java Server SDK](https://github.com/launchdarkly/java-core). This plugin integrates OpenTelemetry tracing, logging, and metrics with LaunchDarkly feature flag evaluations. + +> **Early Access** — This plugin is in early access preview. APIs may change before 1.0. + +## Requirements + +- Java 11+ +- LaunchDarkly Java Server SDK 7.10.1+ (the first release with the plugin API). The plugin does not bring the SDK with it; declare it yourself. Older SDKs, such as the 7.4.0 that `launchdarkly-java-server-sdk-otel` 0.2.0 depends on, have no `Plugin` class and fail with `NoClassDefFoundError`. +- OpenTelemetry Java SDK 1.38.0+ on the runtime classpath. The plugin is built against 1.51.0, but a BOM in your build (Spring Boot's, for one) can change that version; see [OpenTelemetry versions](#opentelemetry-versions). + +## Installation + +The artifact follows the Java server SDK add-on family (`launchdarkly-java-server-sdk-otel`, `-redis-store`, `-ai`). The Android observability plugin is a separate artifact, `launchdarkly-observability-android`. + +### Gradle + +```groovy +dependencies { + implementation 'com.launchdarkly:launchdarkly-java-server-sdk-observability:0.1.0' + implementation 'com.launchdarkly:launchdarkly-java-server-sdk:7.12.0' +} +``` + +### Maven + +```xml + + com.launchdarkly + launchdarkly-java-server-sdk-observability + 0.1.0 + + + com.launchdarkly + launchdarkly-java-server-sdk + 7.12.0 + +``` + +The plugin does not bring the LaunchDarkly SDK with it, so you choose its version; declare both. + +### Dependencies you get + +- `io.opentelemetry:opentelemetry-api` is a compile-scoped (`api`) dependency, because `LDObserve` and `ObservabilityOptions` take OpenTelemetry API types such as `Attributes` and `Severity`. You do not need to declare it yourself. +- OTLP is sent over HTTP with OpenTelemetry's JDK sender, which uses `java.net.http.HttpClient`. The plugin does **not** pull OkHttp, Okio or the Kotlin standard library onto your classpath. The build fails if any of those leak in. +- The OpenTelemetry SDK and exporters are runtime dependencies, and they are the only ones: JSON is handled with the LaunchDarkly SDK's own `LDValue`, so no Gson or other JSON library is added. The plugin records flag evaluations with its own hook and does not depend on `launchdarkly-java-server-sdk-otel` or on the LaunchDarkly SDK itself. The build fails if the POM names anything outside `io.opentelemetry`. + +### OpenTelemetry versions + +The plugin needs OpenTelemetry Java 1.38.0 or newer at runtime. Dependency-management BOMs override the version the plugin asks for, so check what your build resolves (`./gradlew dependencies --configuration runtimeClasspath` or `mvn dependency:tree -Dincludes=io.opentelemetry`): + +| Spring Boot | OpenTelemetry it manages | Plugin | +|-------------|--------------------------|--------| +| 3.2 | 1.31.0 | Too old: the plugin logs an error and stays off. Override the version. | +| 3.3 | 1.38.0 | Works | +| 3.4 | 1.43.0 | Works | +| 3.5 | 1.49.0 | Works | +| 4.1 | 1.62.0 | Works | + +To override Spring Boot's version, set its `opentelemetry.version` property: + +```groovy +// build.gradle, with io.spring.dependency-management +ext['opentelemetry.version'] = '1.51.0' +``` + +```xml + + + 1.51.0 + +``` + +Keep every `io.opentelemetry` artifact on one version. If you add OpenTelemetry instrumentation libraries (such as the Logback appender below), pick the release built on the same core version (instrumentation 2.17.x is built on 1.51.0) or override `opentelemetry.version` to match it. Mixing them fails at runtime with `AbstractMethodError` or `NoSuchMethodError`. + +## Usage + +```java +import com.launchdarkly.observability.ObservabilityPlugin; +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; + +import java.util.List; + +LDConfig config = new LDConfig.Builder() + .plugins(Components.plugins().setPlugins(List.of( + new ObservabilityPlugin( + new ObservabilityOptions.Builder() + .serviceName("my-service") + .serviceVersion("1.0.0") + .environment("production") + .build() + ) + ))) + .build(); + +LDClient client = new LDClient("YOUR_SDK_KEY", config); +``` + +The plugin automatically: + +1. Sets up OpenTelemetry TracerProvider, LoggerProvider, and MeterProvider with OTLP HTTP exporters pointed at the LaunchDarkly observability backend. +2. Registers a tracing hook that records a span for each flag evaluation and adds a `feature_flag` event to the current span, enabling guarded rollouts and observability. +3. Fetches sampling configuration from the backend to control telemetry volume. Regex rules match anywhere in the value, as in the other SDKs; a rule whose pattern does not compile is skipped with a warning. + +Flag-evaluation spans are children of whatever span is current when you evaluate. The plugin does not create request spans itself: in a web service, connect your HTTP instrumentation to the plugin (see [Spring Boot](#spring-boot)) or each evaluation becomes its own trace. + +On startup the plugin logs one line saying where it exports and under which service name, and warns if no service name is set. If it cannot start (a malformed endpoint, an OpenTelemetry version that is too old) it logs the cause as an error and stays a no-op; the LaunchDarkly client and your application keep running. + +### Running alongside other OpenTelemetry setups + +The plugin registers its SDK as `GlobalOpenTelemetry` when it can. When the global is already taken (the OpenTelemetry Java agent, your own SDK, a library that called `GlobalOpenTelemetry.get()` first, or `manualStart(true)` after such a call), the plugin keeps its own providers. Evaluation spans and everything sent through `LDObserve` still go to LaunchDarkly. The `feature_flag` event is added to whichever span is current, so it is exported by whoever owns that span. + +`LDObserve.getOpenTelemetry()` returns the plugin's OpenTelemetry whether or not it is the global, for instrumentation that should export to LaunchDarkly through the plugin. + +### Lifecycle and multiple clients + +The OpenTelemetry providers are process-wide. The first `LDClient` that registers the plugin starts them and its SDK key identifies the project for everything exported. Another `LDClient` with the same SDK key shares them. An `LDClient` with a different SDK key logs a warning and its plugin starts nothing: its flag evaluations are still recorded, under the first client's SDK key. + +Closing an `LDClient` does not stop the providers. Call `LDObserve.shutdown()` when your application stops (for example after closing the client) to flush and stop them; the plugin also registers one JVM shutdown hook that does the same, unless you turn it off with `shutdownHook(false)` (see [Spring Boot](#spring-boot)). + +### OpenTelemetry Java agent + +With the agent attached, the agent owns the global and creates the request spans, and exports them to its own endpoint. The plugin's evaluation spans point at those request spans, so for whole traces in LaunchDarkly send the agent's data there too: + +```bash +export OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.observability.app.launchdarkly.com:4318 +export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +export OTEL_SERVICE_NAME=my-service +export OTEL_RESOURCE_ATTRIBUTES=highlight.project_id=YOUR_SDK_KEY +java -javaagent:opentelemetry-javaagent.jar -jar app.jar +``` + +`highlight.project_id` is the resource attribute the plugin itself sets to the SDK key. The agent also captures Logback and Log4j records and exports them the same way. + +## Spring Boot + +Build one `LDClient` bean with the plugin and let Spring close it. Spring Boot shuts down from its own JVM hook, after draining in-flight requests, so switch off the plugin's hook and stop the plugin from a bean instead; otherwise telemetry from requests that finish during the drain is lost. + +```java +@Configuration +public class LaunchDarklyConfig { + @Bean(destroyMethod = "close") + public LDClient ldClient(@Value("${launchdarkly.sdk-key}") String sdkKey) { + ObservabilityOptions options = new ObservabilityOptions.Builder() + .serviceName("my-service") + .serviceVersion("1.0.0") + .environment("production") + .shutdownHook(false) + .build(); + LDConfig config = new LDConfig.Builder() + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options)))) + .build(); + return new LDClient(sdkKey, config); + } + + /** Flushes and stops the plugin when the context closes. */ + @Bean(destroyMethod = "shutdown") + public ObservabilityShutdown observabilityShutdown() { + return new ObservabilityShutdown(); + } + + public static class ObservabilityShutdown { + public void shutdown() { + LDObserve.shutdown(); + } + } +} +``` + +### Request spans and errors + +Spring Boot's Micrometer tracing creates a span per HTTP request and records unhandled exceptions on it. Add the OpenTelemetry bridge and hand it the plugin's OpenTelemetry; Boot then exports request spans to LaunchDarkly with the flag-evaluation spans as children, the `feature_flag` events on the request span, and exceptions as error events. The `LDClient` parameter makes Spring start the plugin first. + +```groovy +implementation 'org.springframework.boot:spring-boot-starter-actuator' +implementation 'io.micrometer:micrometer-tracing-bridge-otel' +``` + +```java +@Bean +public OpenTelemetry openTelemetry(LDClient ldClient) { + return LDObserve.getOpenTelemetry(); +} +``` + +The plugin's tracer provider records every span, so `management.tracing.sampling.probability` does not apply; the LaunchDarkly sampling rules decide what is kept. + +### Logs + +`LDObserve.recordLog` exports a record directly. To export what you already log through SLF4J and Logback, add the OpenTelemetry Logback appender (see [OpenTelemetry versions](#opentelemetry-versions) for the version) and install it on the plugin's OpenTelemetry: + +```groovy +implementation 'io.opentelemetry.instrumentation:opentelemetry-logback-appender-1.0:2.17.0-alpha' +``` + +```xml + + + + + + + + + +``` + +```java +@Bean +public OpenTelemetry openTelemetry(LDClient ldClient) { + OpenTelemetry openTelemetry = LDObserve.getOpenTelemetry(); + OpenTelemetryAppender.install(openTelemetry); + return openTelemetry; +} +``` + +Records logged inside a request carry its trace id. `logsApiLevel` still applies. + +With Log4j 2 (`spring-boot-starter-log4j2`) use `io.opentelemetry.instrumentation:opentelemetry-log4j-appender-2.17` instead: add `` to the appenders in `log4j2-spring.xml` (with `packages="io.opentelemetry.instrumentation.log4j.appender.v2_17"` on ``), reference it from the root logger, and call `io.opentelemetry.instrumentation.log4j.appender.v2_17.OpenTelemetryAppender.install(openTelemetry)` in the bean. + +## Manual Instrumentation + +Use `LDObserve` for custom telemetry: + +```java +import com.launchdarkly.observability.LDObserve; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.Span; + +// Record metrics +LDObserve.recordCount("requests.total", 1, Attributes.empty()); +LDObserve.recordHistogram("request.duration_ms", 42.5, Attributes.empty()); +LDObserve.recordMetric("queue.depth", 12.0, Attributes.empty()); + +// Record logs +LDObserve.recordLog("User logged in", Severity.INFO, Attributes.empty()); + +// Record errors: added to the current span if the plugin started it, +// otherwise recorded on a new error span +LDObserve.recordError(exception, Attributes.empty()); + +// Create spans +Span span = LDObserve.startSpan("my-operation", Attributes.empty()); +try { + // ... your code ... +} finally { + span.end(); +} + +// Flush / shutdown +LDObserve.flush(); +LDObserve.shutdown(); +``` + +Every `LDObserve` method is safe to call before the plugin is initialized; such calls are local no-ops and do not install the OpenTelemetry no-op global. + +## Configuration Options + +| Option | Default | Description | +|--------|---------|-------------| +| `enabled` | `true` | When `false`, no providers start, no flag-evaluation hook is registered, no sampling config is fetched, and `LDObserve` is a no-op. | +| `serviceName` | `""` | Service name for resource attributes | +| `serviceVersion` | `""` | Service version for resource attributes | +| `environment` | `""` | Deployment environment name | +| `otlpEndpoint` | LaunchDarkly endpoint | Custom OTLP/HTTP endpoint | +| `backendUrl` | LaunchDarkly backend | Custom backend URL for sampling config | +| `resourceAttributes` | empty | Extra resource attributes merged over the plugin's defaults, so they can override `service.name` and friends | +| `customHeaders` / `customHeader` | none | Headers sent with every OTLP export and the sampling-config request | +| `proxySelector` / `proxyHostAndPort` | JVM default | Egress proxy for exports and the sampling-config request | +| `logsApiLevel` | `INFO` | Minimum severity for exported logs. `NONE` disables log export. | +| `debug` | `false` | Enable debug logging | +| `manualStart` | `false` | Delay OTLP provider startup until `LDObserve.start(sdkKey, options)` | +| `shutdownHook` | `true` | Flush and stop the providers from a JVM shutdown hook. Set `false` under Spring Boot or another framework that owns shutdown, and call `LDObserve.shutdown()` from its close callback, so telemetry from requests drained during graceful shutdown is not dropped | + +### Proxies and custom headers + +Both the OTLP exporters and the sampling-config request use `java.net.http.HttpClient`, so they behave the same way behind an egress proxy. With nothing configured, the JVM default proxy selector applies, which honours `-Dhttps.proxyHost`, `-Dhttps.proxyPort` and `-Dhttp.nonProxyHosts`. To set a proxy or headers explicitly: + +```java +new ObservabilityOptions.Builder() + .proxyHostAndPort("egress.internal", 3128) + .customHeader("X-Tenant", "acme") + .build(); +``` + +`proxySelector(java.net.ProxySelector)` accepts any selector when per-host rules are needed. + +### Log level + +```java +new ObservabilityOptions.Builder() + .logsApiLevel(ObservabilityOptions.LogLevel.WARN) // export WARN, ERROR and FATAL + .build(); +``` + +Levels are `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL` and `NONE`, matching the OpenTelemetry severity numbers. A record whose severity is unset is treated as `TRACE`. + +## Development + +```bash +./gradlew :lib:build # compile, unit tests, classpath and POM guards +./gradlew :lib:publishToMavenLocal # then see ../../e2e/java-plugin for consumer and server examples +``` + +`:lib:build` fails if OkHttp, Okio or Kotlin appear on the runtime classpath, if the generated POM does not expose `opentelemetry-api` at compile scope, or if it names anything outside `io.opentelemetry`. `./gradlew :lib:test -PldServerSdkVersion=7.10.1` runs the tests against the oldest supported SDK. + +The unit tests run the sampling scenarios shared with the Go, .NET, Python and Node SDKs (`lib/src/test/resources/sampling`, copied from `go/internal/otel`). `:lib:build` also fails if those copies drift from Go's; copy the files over when the scenarios change. + +## License + +Apache-2.0 diff --git a/sdk/@launchdarkly/observability-java/build.gradle b/sdk/@launchdarkly/observability-java/build.gradle new file mode 100644 index 0000000000..a2efa83f4c --- /dev/null +++ b/sdk/@launchdarkly/observability-java/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'io.github.gradle-nexus.publish-plugin' version '2.0.0' apply true +} + +// Must be specified in root project for the gradle nexus publish plugin. +group = 'com.launchdarkly' + +nexusPublishing { + repositories { + sonatype { + nexusUrl.set(uri('https://ossrh-staging-api.central.sonatype.com/service/local/')) + snapshotRepositoryUrl.set(uri('https://central.sonatype.com/repository/maven-snapshots/')) + } + } +} diff --git a/sdk/@launchdarkly/observability-java/gradle.properties b/sdk/@launchdarkly/observability-java/gradle.properties new file mode 100644 index 0000000000..248acf1e6b --- /dev/null +++ b/sdk/@launchdarkly/observability-java/gradle.properties @@ -0,0 +1,3 @@ +#x-release-please-start-version +version=0.1.0 +#x-release-please-end diff --git a/sdk/@launchdarkly/observability-java/gradle/wrapper/gradle-wrapper.jar b/sdk/@launchdarkly/observability-java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..1b33c55baa Binary files /dev/null and b/sdk/@launchdarkly/observability-java/gradle/wrapper/gradle-wrapper.jar differ diff --git a/sdk/@launchdarkly/observability-java/gradle/wrapper/gradle-wrapper.properties b/sdk/@launchdarkly/observability-java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..d4081da476 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/sdk/@launchdarkly/observability-java/gradlew b/sdk/@launchdarkly/observability-java/gradlew new file mode 100755 index 0000000000..23d15a9367 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/sdk/@launchdarkly/observability-java/gradlew.bat b/sdk/@launchdarkly/observability-java/gradlew.bat new file mode 100644 index 0000000000..db3a6ac207 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/sdk/@launchdarkly/observability-java/lib/build.gradle b/sdk/@launchdarkly/observability-java/lib/build.gradle new file mode 100644 index 0000000000..4fc25a0fb6 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/build.gradle @@ -0,0 +1,216 @@ +plugins { + id 'java-library' + id 'maven-publish' + id 'signing' +} + +repositories { + mavenCentral() +} + +java { + withJavadocJar() + withSourcesJar() +} + +// --release compiles against the Java 11 API, not just Java 11 bytecode, so +// a build on JDK 17 or 21 cannot call an API that Java 11 lacks. +tasks.withType(JavaCompile).configureEach { + options.release = 11 +} + +// Name the jars after the Maven artifactId, so the provenance subjects +// match the files on Maven Central. +base { + archivesName = 'launchdarkly-java-server-sdk-observability' +} + +ext { + otelVersion = '1.51.0' + // The SDK the plugin compiles and tests against. The plugin needs the + // Plugin API, which first shipped in 7.10.1; run the tests against that + // floor with -PldServerSdkVersion=7.10.1. + ldServerSdkVersion = project.findProperty('ldServerSdkVersion') ?: '7.12.0' +} + +dependencies { + // LaunchDarkly SDK - compileOnly so users provide their own version + compileOnly "com.launchdarkly:launchdarkly-java-server-sdk:${ldServerSdkVersion}" + + // The public API (LDObserve, ObservabilityOptions) takes OpenTelemetry API + // types such as Attributes and Severity, so consumers must compile against + // it. 'api' publishes it with compile scope in the POM. + api "io.opentelemetry:opentelemetry-api:${otelVersion}" + implementation "io.opentelemetry:opentelemetry-sdk:${otelVersion}" + implementation "io.opentelemetry:opentelemetry-sdk-logs:${otelVersion}" + implementation "io.opentelemetry:opentelemetry-sdk-metrics:${otelVersion}" + + // OTLP over HTTP. The default exporter artifact pulls in the OkHttp sender, + // which drags okhttp, okio and the Kotlin stdlib onto a server classpath + // unshaded. The JDK sender uses java.net.http.HttpClient (Java 11+) and + // has no third-party dependencies, so the OkHttp sender is excluded. + implementation("io.opentelemetry:opentelemetry-exporter-otlp:${otelVersion}") { + exclude group: 'io.opentelemetry', module: 'opentelemetry-exporter-sender-okhttp' + } + runtimeOnly "io.opentelemetry:opentelemetry-exporter-sender-jdk:${otelVersion}" + + // Testing + testImplementation "com.launchdarkly:launchdarkly-java-server-sdk:${ldServerSdkVersion}" + testImplementation platform('org.junit:junit-bom:5.13.4') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation "io.opentelemetry:opentelemetry-sdk-testing:${otelVersion}" + // Only the tests read the shared scenario fixtures with Gson. The plugin + // parses JSON with the LaunchDarkly SDK's LDValue. + testImplementation 'com.google.code.gson:gson:2.11.0' +} + +tasks.withType(Test).configureEach { + useJUnitPlatform() +} + +tasks.withType(Javadoc).configureEach { + options.addStringOption('Xdoclint:none', '-quiet') +} + +// Groups that must never reach a consumer's runtime classpath through this +// library. The LaunchDarkly server SDK shades its own copies of these so that +// server applications do not inherit version clashes; this plugin must not +// undo that. +def forbiddenRuntimeGroups = [ + 'com.squareup.okhttp3', + 'com.squareup.okio', + 'org.jetbrains.kotlin', + 'org.jetbrains.kotlinx', +] + +tasks.register('checkRuntimeClasspath') { + group = 'verification' + description = 'Fails when OkHttp, Okio or the Kotlin stdlib leak into the runtime classpath.' + def runtime = configurations.runtimeClasspath + doLast { + def leaked = runtime.resolvedConfiguration.resolvedArtifacts + .collect { it.moduleVersion.id } + .findAll { forbiddenRuntimeGroups.contains(it.group) } + .collect { "${it.group}:${it.name}:${it.version}" } + .unique() + if (!leaked.isEmpty()) { + throw new GradleException("Forbidden artifacts on the runtime classpath: ${leaked.join(', ')}") + } + } +} + +tasks.register('checkPublishedPom') { + group = 'verification' + description = 'Checks the generated POM exposes opentelemetry-api at compile scope and depends on nothing but OpenTelemetry.' + dependsOn 'generatePomFileForReleasePublication' + def pomFile = layout.buildDirectory.file('publications/release/pom-default.xml') + doLast { + def pom = new XmlSlurper(false, false).parse(pomFile.get().asFile) + // GPathResult.find returns an empty result, never null, so test size(). + def deps = pom.dependencies.dependency + def otelApi = deps.find { it.artifactId.text() == 'opentelemetry-api' } + if (otelApi.size() == 0 || otelApi.scope.text() != 'compile') { + throw new GradleException("opentelemetry-api must be a compile-scoped dependency in the POM, found scope '" + + (otelApi.size() == 0 ? 'missing' : otelApi.scope.text()) + "'") + } + def okhttp = deps.find { it.artifactId.text() == 'opentelemetry-exporter-sender-okhttp' } + if (okhttp.size() != 0) { + throw new GradleException('The POM must not depend on opentelemetry-exporter-sender-okhttp') + } + // The application brings its own LaunchDarkly SDK. Any SDK artifact in + // the POM (directly, or via launchdarkly-java-server-sdk-otel, which + // depends on 7.4.0) could win resolution with a version that has no + // Plugin class and fail with NoClassDefFoundError. + def ldSdk = deps.findAll { it.groupId.text() == 'com.launchdarkly' } + if (ldSdk.size() != 0) { + throw new GradleException('The POM must not depend on LaunchDarkly artifacts, found: ' + + ldSdk.collect { it.artifactId.text() }.join(', ')) + } + // OpenTelemetry is the only third-party dependency a server + // application inherits from the plugin. + def foreign = deps.findAll { it.groupId.text() != 'io.opentelemetry' } + if (foreign.size() != 0) { + throw new GradleException('The POM may only depend on io.opentelemetry artifacts, found: ' + + foreign.collect { "${it.groupId.text()}:${it.artifactId.text()}" }.join(', ')) + } + def jdk = deps.find { it.artifactId.text() == 'opentelemetry-exporter-sender-jdk' } + if (jdk.size() == 0 || jdk.scope.text() != 'runtime') { + throw new GradleException('The POM must carry opentelemetry-exporter-sender-jdk at runtime scope') + } + } +} + +// The sampling scenario fixtures are copies of the Go SDK's, which the other +// server SDKs share. Fail when they drift instead of silently testing less. +tasks.register('checkSamplingFixtures') { + group = 'verification' + description = 'Fails when the shared sampling scenario fixtures differ from the Go SDK copies.' + def pairs = ['span-test-scenarios.json', 'log-test-scenarios.json'].collect { name -> + [file("src/test/resources/sampling/${name}"), rootProject.file("../../../go/internal/otel/${name}")] + } + inputs.files(pairs.flatten()) + doLast { + def drifted = pairs.findAll { local, upstream -> local.bytes != upstream.bytes } + .collect { local, upstream -> "${local} differs from ${upstream}" } + if (!drifted.isEmpty()) { + throw new GradleException("Sampling fixtures drifted; copy them from go/internal/otel:\n${drifted.join('\n')}") + } + } +} + +tasks.named('check') { + dependsOn 'checkRuntimeClasspath', 'checkPublishedPom', 'checkSamplingFixtures' +} + +publishing { + publications { + release(MavenPublication) { + groupId = 'com.launchdarkly' + // Follows the Java server SDK add-on family + // (launchdarkly-java-server-sdk-otel, -redis-store, -ai) so the + // coordinates say which LaunchDarkly SDK this plugs into. The + // Android observability artifact is also a Java library, so a + // bare "-java" suffix would be ambiguous. + artifactId = 'launchdarkly-java-server-sdk-observability' + version = version.toString() + + from components.java + + pom { + name = 'LaunchDarkly Observability plugin for the Java Server SDK' + description = 'Official LaunchDarkly Observability plugin for the LaunchDarkly Java Server SDK.' + url = 'https://github.com/launchdarkly/observability-sdk/' + organization { + name = 'LaunchDarkly' + url = 'https://launchdarkly.com/' + } + developers { + developer { + id = 'sdks' + name = 'LaunchDarkly SDK Team' + email = 'sdks@launchdarkly.com' + } + } + licenses { + license { + name = 'The Apache License, Version 2.0' + url = 'https://www.apache.org/licenses/LICENSE-2.0.txt' + } + } + scm { + connection = 'scm:git:https://github.com/launchdarkly/observability-sdk.git' + developerConnection = 'scm:git:ssh:github.com/launchdarkly/observability-sdk.git' + url = 'https://github.com/launchdarkly/observability-sdk/' + } + } + } + } +} + +signing { + // Only a Sonatype publish needs a signature. publishToMavenLocal (used by + // the CI consumer check and local e2e runs) must work without signing keys. + required = { gradle.taskGraph.allTasks.any { it.name.toLowerCase().contains('sonatype') } } + sign publishing.publications.release +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/LDObserve.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/LDObserve.java new file mode 100644 index 0000000000..babfb5a030 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/LDObserve.java @@ -0,0 +1,179 @@ +package com.launchdarkly.observability; + +import com.launchdarkly.observability.internal.Constants; +import com.launchdarkly.observability.internal.OtelManager; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.LogRecordBuilder; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; + +/** + * Static singleton public API for LaunchDarkly Observability. + * + *

Before the plugin is initialized, all methods are safe to call and + * will use no-op implementations from the OpenTelemetry API. Methods are + * safe to call from any thread and do not throw on null arguments: a null + * metric name records nothing, null attributes are treated as empty, and + * {@link #recordError} ignores a null throwable.

+ */ +public final class LDObserve { + private LDObserve() {} + + /** + * Records a gauge metric. + */ + public static void recordMetric(String name, double value, Attributes attributes) { + try { + OtelManager.getGauge(name).set(value, orEmpty(attributes)); + } catch (LinkageError e) { + // The synchronous gauge API arrived in OpenTelemetry 1.38. On an + // older API (Spring Boot 3.2 pins 1.31) the plugin is already off + // and has logged why; recording must not fail the caller. + } + } + + /** + * Records a counter metric. + */ + public static void recordCount(String name, long value, Attributes attributes) { + OtelManager.getCounter(name).add(value, orEmpty(attributes)); + } + + /** + * Records a counter increment of 1. + */ + public static void recordIncr(String name, Attributes attributes) { + recordCount(name, 1, attributes); + } + + /** + * Records a histogram metric. + */ + public static void recordHistogram(String name, double value, Attributes attributes) { + OtelManager.getHistogram(name).record(value, orEmpty(attributes)); + } + + /** + * Records an up/down counter metric. + */ + public static void recordUpDownCounter(String name, long value, Attributes attributes) { + OtelManager.getUpDownCounter(name).add(value, orEmpty(attributes)); + } + + /** + * Records a log message. + */ + public static void recordLog(String message, Severity severity, Attributes attributes) { + LogRecordBuilder builder = OtelManager.getLogger().logRecordBuilder(); + if (severity != null) { + // Severity number and text are separate OTLP fields; the backend + // UI and severityText sampling rules read the text. + builder.setSeverity(severity).setSeverityText(severity.name()); + } + if (message != null) { + builder.setBody(message); + } + builder.setAllAttributes(attributes).emit(); + } + + /** + * Records an error with exception details. Like the other LaunchDarkly + * server SDKs, an error inside a span the plugin exports is added to that + * span as an exception event, with {@code attributes} on the event. + * Otherwise it is recorded on a new error span, a child of the current + * span if there is one. + */ + public static void recordError(Throwable throwable, Attributes attributes) { + if (throwable == null) { + return; + } + Span current = Span.current(); + if (current.isRecording() && OtelManager.ownsSpan(current)) { + current.recordException(throwable, orEmpty(attributes)); + return; + } + Tracer tracer = OtelManager.getTracer(); + Span span = tracer.spanBuilder(Constants.ERROR_SPAN_NAME) + .setAllAttributes(attributes) + .startSpan(); + try { + span.setStatus(StatusCode.ERROR, throwable.getMessage()); + span.recordException(throwable); + } finally { + span.end(); + } + } + + /** + * Starts a new span with the given name and attributes. + * + * @return the started span; caller is responsible for calling {@link Span#end()}. + */ + public static Span startSpan(String name, Attributes attributes) { + Tracer tracer = OtelManager.getTracer(); + return tracer.spanBuilder(name) + .setAllAttributes(attributes) + .startSpan(); + } + + /** + * Returns the plugin's OpenTelemetry, so other instrumentation can export + * to LaunchDarkly through the same providers, resource and sampling + * rules. It works whether or not the plugin also owns + * {@code GlobalOpenTelemetry}. + * + *

Typical uses: expose it as the {@code OpenTelemetry} bean so Spring + * Boot's Micrometer tracing bridge sends HTTP server spans (with the + * flag-evaluation spans as children) to LaunchDarkly, or pass it to + * {@code OpenTelemetryAppender.install} so Logback or Log4j records are + * exported.

+ * + *

The returned instance looks the providers up on every call: before + * the plugin starts, or after {@link #shutdown()}, it hands out no-op + * tracers, meters and loggers. Instruments and tracers obtained in that + * window stay no-ops, so take them after the {@code LDClient} is built + * (or after {@link #start} with manual start). It cannot be closed; + * use {@link #shutdown()}.

+ */ + public static OpenTelemetry getOpenTelemetry() { + return OtelManager.getOpenTelemetry(); + } + + /** + * Manually starts the OTLP providers and fetches the sampling + * configuration. Needed when + * {@link ObservabilityOptions.Builder#manualStart(boolean)} is true, or + * to use {@code LDObserve} without an {@code LDClient}. + */ + public static void start(String sdkKey, ObservabilityOptions options) { + if (OtelManager.initialize(sdkKey, options)) { + OtelManager.fetchSamplingConfig(sdkKey, options); + } + } + + /** + * Flushes all pending telemetry data. + */ + public static void flush() { + OtelManager.flush(); + } + + /** + * Flushes all pending data and shuts down the providers. Call it after + * the last telemetry is recorded: under Spring Boot, from a bean's + * destroy method (see + * {@link ObservabilityOptions.Builder#shutdownHook(boolean)}). Later + * calls, and the plugin's JVM shutdown hook if it is registered, then + * find nothing to do. + */ + public static void shutdown() { + OtelManager.shutdown(); + } + + private static Attributes orEmpty(Attributes attributes) { + return attributes != null ? attributes : Attributes.empty(); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityOptions.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityOptions.java new file mode 100644 index 0000000000..1ab432c9db --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityOptions.java @@ -0,0 +1,296 @@ +package com.launchdarkly.observability; + +import com.launchdarkly.observability.internal.Constants; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Configuration options for the LaunchDarkly Observability plugin. + * Use the {@link Builder} to construct an instance. + */ +public final class ObservabilityOptions { + + private final boolean enabled; + private final String serviceName; + private final String serviceVersion; + private final String environment; + private final String otlpEndpoint; + private final String backendUrl; + private final Attributes resourceAttributes; + private final Map customHeaders; + private final ProxySelector proxySelector; + private final LogLevel logsApiLevel; + private final boolean debug; + private final boolean manualStart; + private final boolean shutdownHook; + + private ObservabilityOptions(Builder builder) { + this.enabled = builder.enabled; + this.serviceName = builder.serviceName; + this.serviceVersion = builder.serviceVersion; + this.environment = builder.environment; + this.otlpEndpoint = builder.otlpEndpoint; + this.backendUrl = builder.backendUrl; + this.resourceAttributes = builder.resourceAttributes; + this.customHeaders = Collections.unmodifiableMap(new LinkedHashMap<>(builder.customHeaders)); + this.proxySelector = builder.proxySelector; + this.logsApiLevel = builder.logsApiLevel; + this.debug = builder.debug; + this.manualStart = builder.manualStart; + this.shutdownHook = builder.shutdownHook; + } + + /** Whether the plugin exports telemetry at all. See {@link Builder#enabled(boolean)}. */ + public boolean isEnabled() { return enabled; } + public String getServiceName() { return serviceName; } + public String getServiceVersion() { return serviceVersion; } + public String getEnvironment() { return environment; } + public String getOtlpEndpoint() { return otlpEndpoint; } + public String getBackendUrl() { return backendUrl; } + /** Additional resource attributes attached to every exported signal. Never null. */ + public Attributes getResourceAttributes() { return resourceAttributes; } + /** Headers sent with every OTLP export and sampling-configuration request. Never null. */ + public Map getCustomHeaders() { return customHeaders; } + /** The proxy selector for outbound HTTP, or null to use the JVM default. */ + public ProxySelector getProxySelector() { return proxySelector; } + /** The minimum severity a log record needs to be exported. */ + public LogLevel getLogsApiLevel() { return logsApiLevel; } + public boolean isDebug() { return debug; } + public boolean isManualStart() { return manualStart; } + /** Whether the plugin flushes and stops on JVM exit by itself. See {@link Builder#shutdownHook(boolean)}. */ + public boolean isShutdownHook() { return shutdownHook; } + + /** + * Minimum severity for exported log records. Mirrors the OpenTelemetry + * log severity numbers; a record is exported when its severity number is + * at or above the configured level. {@link #NONE} disables log export. + * + *

A record whose severity is unset + * ({@link Severity#UNDEFINED_SEVERITY_NUMBER}) is treated as + * {@link #TRACE}, so it is only exported at the {@code TRACE} level.

+ * + * @see OpenTelemetry Log Data Model - Severity + */ + public enum LogLevel { + TRACE(Severity.TRACE.getSeverityNumber()), + DEBUG(Severity.DEBUG.getSeverityNumber()), + INFO(Severity.INFO.getSeverityNumber()), + WARN(Severity.WARN.getSeverityNumber()), + ERROR(Severity.ERROR.getSeverityNumber()), + FATAL(Severity.FATAL.getSeverityNumber()), + /** Export no log records. */ + NONE(Integer.MAX_VALUE); + + private final int severityNumber; + + LogLevel(int severityNumber) { + this.severityNumber = severityNumber; + } + + /** The OpenTelemetry severity number this level starts at. */ + public int getSeverityNumber() { + return severityNumber; + } + } + + /** + * Builder for {@link ObservabilityOptions}. + */ + public static class Builder { + private boolean enabled = true; + private String serviceName = ""; + private String serviceVersion = ""; + private String environment = ""; + private String otlpEndpoint = Constants.DEFAULT_OTLP_ENDPOINT; + private String backendUrl = Constants.DEFAULT_BACKEND_URL; + private Attributes resourceAttributes = Attributes.empty(); + private Map customHeaders = new LinkedHashMap<>(); + private ProxySelector proxySelector = null; + private LogLevel logsApiLevel = LogLevel.INFO; + private boolean debug = false; + private boolean manualStart = false; + private boolean shutdownHook = true; + + public Builder() {} + + /** + * Enables or disables the plugin. When {@code false} the plugin is + * registered but starts no OpenTelemetry providers, registers no + * flag-evaluation hook, fetches no sampling configuration, and every + * {@link LDObserve} call is a no-op. Defaults to {@code true}. + */ + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** + * Sets the service name for resource attributes. + */ + public Builder serviceName(String serviceName) { + this.serviceName = serviceName; + return this; + } + + /** + * Sets the service version for resource attributes. + */ + public Builder serviceVersion(String serviceVersion) { + this.serviceVersion = serviceVersion; + return this; + } + + /** + * Sets the deployment environment name for resource attributes. + */ + public Builder environment(String environment) { + this.environment = environment; + return this; + } + + /** + * Sets a custom OTLP endpoint. Defaults to the LaunchDarkly OTLP endpoint. + */ + public Builder otlpEndpoint(String otlpEndpoint) { + this.otlpEndpoint = otlpEndpoint; + return this; + } + + /** + * Sets a custom backend URL for fetching sampling configuration. + */ + public Builder backendUrl(String backendUrl) { + this.backendUrl = backendUrl; + return this; + } + + /** + * Adds resource attributes to every exported span, log record and + * metric. These are merged over the plugin's own resource, so they + * can override {@code service.name} and friends. + */ + public Builder resourceAttributes(Attributes resourceAttributes) { + this.resourceAttributes = Objects.requireNonNull(resourceAttributes, "resourceAttributes"); + return this; + } + + /** + * Sets headers that are sent with every OTLP export and with the + * sampling-configuration request. Replaces any previously set headers. + */ + public Builder customHeaders(Map customHeaders) { + this.customHeaders = new LinkedHashMap<>(Objects.requireNonNull(customHeaders, "customHeaders")); + return this; + } + + /** + * Adds one header that is sent with every OTLP export and with the + * sampling-configuration request. + */ + public Builder customHeader(String name, String value) { + this.customHeaders.put(Objects.requireNonNull(name, "name"), Objects.requireNonNull(value, "value")); + return this; + } + + /** + * Routes every outbound request (OTLP exports and the + * sampling-configuration request) through the given proxy selector. + * + *

When unset, the JVM default selector is used, which honours the + * standard {@code http.proxyHost}, {@code https.proxyHost} and + * {@code http.nonProxyHosts} system properties.

+ */ + public Builder proxySelector(ProxySelector proxySelector) { + this.proxySelector = proxySelector; + return this; + } + + /** + * Routes every outbound request through an HTTP proxy at the given + * host and port. + */ + public Builder proxyHostAndPort(String host, int port) { + Objects.requireNonNull(host, "host"); + return proxySelector(new FixedProxySelector(new InetSocketAddress(host, port))); + } + + /** + * Sets the minimum severity for exported log records. Defaults to + * {@link LogLevel#INFO}. Use {@link LogLevel#NONE} to disable log export. + */ + public Builder logsApiLevel(LogLevel logsApiLevel) { + this.logsApiLevel = Objects.requireNonNull(logsApiLevel, "logsApiLevel"); + return this; + } + + /** + * Enables debug logging for the observability plugin. + */ + public Builder debug(boolean debug) { + this.debug = debug; + return this; + } + + /** + * When set to true, the OTLP providers will not start automatically. + * Call {@link LDObserve#start(String, ObservabilityOptions)} to start them manually. + */ + public Builder manualStart(boolean manualStart) { + this.manualStart = manualStart; + return this; + } + + /** + * Whether the plugin registers a JVM shutdown hook that flushes and + * stops its providers. Defaults to {@code true}, which suits a plain + * {@code main} application. + * + *

Set it to {@code false} when a framework owns shutdown, such as + * Spring Boot, and call {@link LDObserve#shutdown()} from the + * framework's close callback instead. The JVM runs shutdown hooks + * concurrently, so the plugin's hook would otherwise stop the + * providers while Spring is still draining in-flight requests, and + * everything those requests record would be dropped.

+ */ + public Builder shutdownHook(boolean shutdownHook) { + this.shutdownHook = shutdownHook; + return this; + } + + public ObservabilityOptions build() { + return new ObservabilityOptions(this); + } + } + + /** + * A {@link ProxySelector} that sends everything through one HTTP proxy. + */ + static final class FixedProxySelector extends ProxySelector { + private final List proxies; + + FixedProxySelector(InetSocketAddress address) { + this.proxies = Collections.singletonList(new Proxy(Proxy.Type.HTTP, address)); + } + + @Override + public List select(URI uri) { + return proxies; + } + + @Override + public void connectFailed(URI uri, SocketAddress sa, java.io.IOException ioe) { + // Nothing to fall back to; the exporter retries on its own schedule. + } + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityPlugin.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityPlugin.java new file mode 100644 index 0000000000..d7e5ece790 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityPlugin.java @@ -0,0 +1,106 @@ +package com.launchdarkly.observability; + +import com.launchdarkly.observability.internal.EvaluationTracingHook; +import com.launchdarkly.observability.internal.OtelManager; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.integrations.EnvironmentMetadata; +import com.launchdarkly.sdk.server.integrations.Hook; +import com.launchdarkly.sdk.server.integrations.Plugin; +import com.launchdarkly.sdk.server.integrations.PluginMetadata; + +import java.util.Collections; +import java.util.List; +import java.util.logging.Logger; + +/** + * LaunchDarkly Observability plugin that integrates OpenTelemetry tracing, + * logging, and metrics with the LaunchDarkly Java Server SDK. + * + *

Usage:

+ *
{@code
+ * LDConfig config = new LDConfig.Builder()
+ *     .plugins(Components.plugins().setPlugins(List.of(
+ *         new ObservabilityPlugin(
+ *             new ObservabilityOptions.Builder()
+ *                 .serviceName("my-service")
+ *                 .serviceVersion("1.0.0")
+ *                 .environment("production")
+ *                 .build()
+ *         )
+ *     )))
+ *     .build();
+ * LDClient client = new LDClient("YOUR_SDK_KEY", config);
+ * }
+ * + *

Outbound HTTP (OTLP exports and the sampling-configuration request) goes + * through {@code java.net.http.HttpClient}. It honours the JVM proxy system + * properties by default; use + * {@link ObservabilityOptions.Builder#proxySelector(java.net.ProxySelector)} or + * {@link ObservabilityOptions.Builder#proxyHostAndPort(String, int)} to set + * one explicitly, and + * {@link ObservabilityOptions.Builder#customHeaders(java.util.Map)} to add + * headers to every request.

+ */ +public class ObservabilityPlugin extends Plugin { + + private static final Logger log = Logger.getLogger(ObservabilityPlugin.class.getName()); + private static final String PLUGIN_NAME = "launchdarkly-observability"; + + private final ObservabilityOptions options; + + /** + * Creates a new observability plugin with the given options. + * + * @param options configuration options for the plugin + */ + public ObservabilityPlugin(ObservabilityOptions options) { + this.options = options; + } + + @Override + public PluginMetadata getMetadata() { + return new PluginMetadata() { + @Override + public String getName() { + return PLUGIN_NAME; + } + }; + } + + @Override + public void register(LDClient client, EnvironmentMetadata metadata) { + if (!options.isEnabled()) { + if (options.isDebug()) { + log.info("Observability plugin is disabled; no telemetry will be exported"); + } + return; + } + + String sdkKey = metadata.getSdkKey(); + + if (!OtelManager.registerClient(sdkKey)) { + log.warning("The observability plugin is already registered with an LDClient for another SDK key." + + " Its OpenTelemetry providers are shared by the whole process, so telemetry from this" + + " client, flag evaluations included, is exported under the first SDK key, and this" + + " plugin's options are ignored."); + return; + } + + if (!options.isManualStart() && !OtelManager.initialize(sdkKey, options)) { + // Already logged; nothing would use a sampling config. + return; + } + + // Fetched in the background; with manual start it is kept and + // applied when LDObserve.start runs. + OtelManager.fetchSamplingConfig(sdkKey, options); + } + + @Override + public List getHooks(EnvironmentMetadata metadata) { + if (!options.isEnabled()) { + return Collections.emptyList(); + } + return Collections.singletonList(new EvaluationTracingHook(true, true)); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java new file mode 100644 index 0000000000..151bb33fcd --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java @@ -0,0 +1,37 @@ +package com.launchdarkly.observability.internal; + +/** + * Internal constants for the LaunchDarkly Observability plugin. + */ +public final class Constants { + private Constants() {} + + public static final String DEFAULT_OTLP_ENDPOINT = "https://otel.observability.app.launchdarkly.com:4318"; + public static final String DEFAULT_BACKEND_URL = "https://pub.observability.app.launchdarkly.com"; + public static final String INSTRUMENTATION_SCOPE_NAME = "com.launchdarkly.observability"; + + /** + * The plugin version. Kept in step with {@code version} in + * {@code gradle.properties}, which release-please updates on release. A + * plain constant rather than a manifest or resource lookup so it resolves + * in unit tests, shaded jars and classloaders that hide non-class files. + */ + // x-release-please-start-version + public static final String INSTRUMENTATION_VERSION = "0.1.0"; + // x-release-please-end + + /** + * The oldest OpenTelemetry Java SDK the plugin runs on. 1.38 made the + * synchronous gauge stable and has every exporter builder method the + * plugin calls. Spring Boot 3.3 and later manage 1.38 or newer. + */ + public static final String MIN_OTEL_SDK_VERSION = "1.38.0"; + + public static final String PROJECT_ID_ATTRIBUTE = "highlight.project_id"; + public static final String ERROR_SPAN_NAME = "highlight.error"; + public static final String ATTR_SAMPLING_RATIO = "launchdarkly.sampling.ratio"; + + public static final String TRACES_PATH = "/v1/traces"; + public static final String LOGS_PATH = "/v1/logs"; + public static final String METRICS_PATH = "/v1/metrics"; +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/EvaluationTracingHook.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/EvaluationTracingHook.java new file mode 100644 index 0000000000..8fe6b91b67 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/EvaluationTracingHook.java @@ -0,0 +1,96 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.server.integrations.Hook; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; + +import java.util.HashMap; +import java.util.Map; + +/** + * Records flag evaluations on the plugin's own tracer. + * + *

Emits the same data as {@code TracingHook} from + * {@code launchdarkly-java-server-sdk-otel}: a span per evaluation and a + * {@code feature_flag} event on the caller's current span. That hook resolves + * its tracer from {@code GlobalOpenTelemetry} on every evaluation, so when the + * global is already claimed (the OpenTelemetry Java agent, a library calling + * {@code GlobalOpenTelemetry.get()} first, or manual start) its spans never + * reach LaunchDarkly. This hook asks {@link OtelManager} instead, which always + * holds the plugin's SDK whether or not it is also the global.

+ */ +public final class EvaluationTracingHook extends Hook { + static final String HOOK_NAME = "LaunchDarkly Tracing Hook"; + static final String INSTRUMENTATION_NAME = "launchdarkly-client"; + static final String DATA_KEY_SPAN = "variationSpan"; + static final String EVENT_NAME = "feature_flag"; + + static final String PROVIDER_NAME = "LaunchDarkly"; + static final String FEATURE_FLAG_PROVIDER_NAME = "feature_flag.provider.name"; + static final String FEATURE_FLAG_KEY = "feature_flag.key"; + static final String FEATURE_FLAG_VALUE = "feature_flag.result.value"; + static final String FEATURE_FLAG_CONTEXT_ID = "feature_flag.context.id"; + static final String FEATURE_FLAG_VARIATION_INDEX = "feature_flag.result.variationIndex"; + static final String FEATURE_FLAG_IN_EXPERIMENT = "feature_flag.result.reason.inExperiment"; + + private final boolean withSpans; + private final boolean withValue; + + public EvaluationTracingHook(boolean withSpans, boolean withValue) { + super(HOOK_NAME); + this.withSpans = withSpans; + this.withValue = withValue; + } + + @Override + public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map data) { + if (!withSpans) { + return data; + } + Span span = OtelManager.getTracer(INSTRUMENTATION_NAME) + .spanBuilder(seriesContext.method) + .setParent(Context.current()) + .setAllAttributes(baseAttributes(seriesContext).build()) + .startSpan(); + Map next = new HashMap<>(data); + next.put(DATA_KEY_SPAN, span); + return next; + } + + @Override + public Map afterEvaluation( + EvaluationSeriesContext seriesContext, + Map data, + EvaluationDetail detail + ) { + Object span = data.get(DATA_KEY_SPAN); + if (span instanceof Span) { + ((Span) span).end(); + } + + AttributesBuilder attributes = baseAttributes(seriesContext); + if (withValue) { + attributes.put(FEATURE_FLAG_VALUE, detail.getValue().toJsonString()); + } + if (detail.getReason() != null && detail.getReason().isInExperiment()) { + attributes.put(FEATURE_FLAG_IN_EXPERIMENT, true); + } + if (detail.getVariationIndex() != EvaluationDetail.NO_VARIATION) { + attributes.put(FEATURE_FLAG_VARIATION_INDEX, (long) detail.getVariationIndex()); + } + Span.current().addEvent(EVENT_NAME, attributes.build()); + return data; + } + + private static AttributesBuilder baseAttributes(EvaluationSeriesContext seriesContext) { + return Attributes.builder() + .put(FEATURE_FLAG_KEY, seriesContext.flagKey) + .put(FEATURE_FLAG_PROVIDER_NAME, PROVIDER_NAME) + .put(FEATURE_FLAG_CONTEXT_ID, seriesContext.context.getFullyQualifiedKey()); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java new file mode 100644 index 0000000000..3fd1dc00cc --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java @@ -0,0 +1,131 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.ObjectBuilder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.logging.Logger; +import java.util.zip.GZIPOutputStream; + +/** + * Minimal GraphQL client on {@link HttpClient} (Java 11). + * + *

JSON goes through the LaunchDarkly SDK's {@link LDValue}, which the + * application already has, so the plugin adds no JSON library to a server + * classpath.

+ * + *

Uses the same HTTP stack as the OTLP exporters, so proxy settings and + * custom headers behave the same for the sampling request as for exports. + * Instances are immutable and safe to share between threads.

+ */ +public final class GraphQLClient { + + private static final Logger log = Logger.getLogger(GraphQLClient.class.getName()); + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); + + private final URI endpoint; + private final Map headers; + private final HttpClient httpClient; + + /** + * Creates a client that uses the JVM default proxy selector and sends no + * extra headers. + */ + public GraphQLClient(String backendUrl) { + this(backendUrl, Collections.emptyMap(), null); + } + + /** + * @param backendUrl the GraphQL endpoint + * @param headers headers added to every request; may be empty + * @param proxySelector the proxy selector for requests, or null for the JVM default + */ + public GraphQLClient(String backendUrl, Map headers, ProxySelector proxySelector) { + this.endpoint = URI.create(backendUrl); + this.headers = Collections.unmodifiableMap(new LinkedHashMap<>(headers)); + HttpClient.Builder builder = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NORMAL); + if (proxySelector != null) { + builder.proxy(proxySelector); + } + this.httpClient = builder.build(); + } + + /** + * Executes a GraphQL query and returns the parsed "data" element. + * + * @param query the GraphQL query string + * @param variables query variables + * @return the "data" object from the response, or null on error + */ + public LDValue execute(String query, Map variables) throws IOException { + ObjectBuilder vars = LDValue.buildObject(); + for (Map.Entry variable : variables.entrySet()) { + vars.put(variable.getKey(), variable.getValue()); + } + String body = LDValue.buildObject() + .put("query", query) + .put("variables", vars.build()) + .build() + .toJsonString(); + + byte[] payload = gzip(body.getBytes(StandardCharsets.UTF_8)); + + HttpRequest.Builder request = HttpRequest.newBuilder(endpoint) + .timeout(REQUEST_TIMEOUT) + .header("Content-Type", "application/json") + .header("Content-Encoding", "gzip") + .POST(HttpRequest.BodyPublishers.ofByteArray(payload)); + for (Map.Entry header : headers.entrySet()) { + request.header(header.getKey(), header.getValue()); + } + + HttpResponse response; + try { + response = httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for the GraphQL response", e); + } + + int status = response.statusCode(); + if (status < 200 || status >= 300) { + log.warning("GraphQL request failed with status " + status); + return null; + } + + LDValue envelope = LDValue.parse(response.body()); + if (envelope.getType() != LDValueType.OBJECT) { + log.warning("GraphQL response was not a JSON object"); + return null; + } + LDValue errors = envelope.get("errors"); + if (errors.size() > 0) { + log.warning("GraphQL errors: " + errors.toJsonString()); + } + LDValue data = envelope.get("data"); + return data.getType() == LDValueType.OBJECT ? data : null; + } + + private static byte[] gzip(byte[] data) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (GZIPOutputStream gzos = new GZIPOutputStream(bos)) { + gzos.write(data); + } + return bos.toByteArray(); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/LogLevelFilterProcessor.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/LogLevelFilterProcessor.java new file mode 100644 index 0000000000..1226364806 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/LogLevelFilterProcessor.java @@ -0,0 +1,44 @@ +package com.launchdarkly.observability.internal; + +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.LogRecordProcessor; +import io.opentelemetry.sdk.logs.ReadWriteLogRecord; + +/** + * Drops log records below a minimum severity before they reach the next + * processor. A record with no severity set is treated as {@code TRACE}. + */ +public final class LogLevelFilterProcessor implements LogRecordProcessor { + + private final LogRecordProcessor delegate; + private final int minimumSeverityNumber; + + public LogLevelFilterProcessor(LogRecordProcessor delegate, int minimumSeverityNumber) { + this.delegate = delegate; + this.minimumSeverityNumber = minimumSeverityNumber; + } + + @Override + public void onEmit(Context context, ReadWriteLogRecord logRecord) { + Severity severity = logRecord.toLogRecordData().getSeverity(); + int number = severity == null || severity == Severity.UNDEFINED_SEVERITY_NUMBER + ? Severity.TRACE.getSeverityNumber() + : severity.getSeverityNumber(); + if (number < minimumSeverityNumber) { + return; + } + delegate.onEmit(context, logRecord); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } + + @Override + public CompletableResultCode forceFlush() { + return delegate.forceFlush(); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java new file mode 100644 index 0000000000..7704f15117 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java @@ -0,0 +1,655 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.internal.sampling.CustomSampler; +import com.launchdarkly.observability.internal.sampling.SamplingConfig; +import com.launchdarkly.observability.internal.sampling.SamplingLogProcessor; +import com.launchdarkly.observability.internal.sampling.SamplingTraceExporter; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.logs.LoggerProvider; +import io.opentelemetry.api.metrics.DoubleGauge; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.LongUpDownCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.metrics.MeterProvider; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.TracerProvider; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporterBuilder; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporterBuilder; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.common.export.ProxyOptions; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.ReadableSpan; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.samplers.Sampler; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import java.util.logging.Level; + +/** + * Manages OpenTelemetry provider setup and lifecycle for LaunchDarkly Observability. + */ +public final class OtelManager { + + private static final java.util.logging.Logger log = + java.util.logging.Logger.getLogger(OtelManager.class.getName()); + + private static final AtomicReference INSTANCE = new AtomicReference<>(); + private static final AtomicReference PENDING_SAMPLING_CONFIG = new AtomicReference<>(); + private static final Object INIT_LOCK = new Object(); + // Before init, and for a null instrument name, LDObserve records into + // this no-op meter instead of throwing into the caller. + private static final Meter NOOP_METER = + OpenTelemetry.noop().getMeter(Constants.INSTRUMENTATION_SCOPE_NAME); + // Guarded by INIT_LOCK. One JVM shutdown hook for the process; it shuts + // down whichever manager is current, so a restart does not add another. + private static Thread shutdownHook; + // Guarded by INIT_LOCK. The SDK key of the first LDClient that + // registered the plugin; the providers carry it as the project id. + private static String registeredSdkKey; + // Guarded by INIT_LOCK. The SDK key whose sampling config has been + // requested, so the plugin and LDObserve.start fetch it only once. + private static String samplingFetchSdkKey; + + private final SdkTracerProvider tracerProvider; + private final SdkLoggerProvider loggerProvider; + private final SdkMeterProvider meterProvider; + private final OpenTelemetrySdk openTelemetrySdk; + private final Resource resource; + private final Tracer tracer; + private final Logger logger; + private final Meter meter; + private final CustomSampler customSampler; + + // Instruments are cached by name so recording a metric does not run the + // SDK's builder and registration lookup on every call. + private final Map gauges = new ConcurrentHashMap<>(); + private final Map counters = new ConcurrentHashMap<>(); + private final Map histograms = new ConcurrentHashMap<>(); + private final Map upDownCounters = new ConcurrentHashMap<>(); + + private OtelManager( + SdkTracerProvider tracerProvider, + SdkLoggerProvider loggerProvider, + SdkMeterProvider meterProvider, + OpenTelemetrySdk openTelemetrySdk, + Resource resource, + CustomSampler customSampler + ) { + this.tracerProvider = tracerProvider; + this.loggerProvider = loggerProvider; + this.meterProvider = meterProvider; + this.openTelemetrySdk = openTelemetrySdk; + this.resource = resource; + this.customSampler = customSampler; + + this.tracer = openTelemetrySdk.getTracer( + Constants.INSTRUMENTATION_SCOPE_NAME, + Constants.INSTRUMENTATION_VERSION + ); + this.logger = openTelemetrySdk.getLogsBridge().get( + Constants.INSTRUMENTATION_SCOPE_NAME + ); + this.meter = openTelemetrySdk.getMeter( + Constants.INSTRUMENTATION_SCOPE_NAME + ); + } + + /** + * Initializes the OTel providers with the given options and SDK key. + * + *

Safe for concurrent callers. Only the first call builds providers and + * registers a shutdown hook. Sampling configuration fetched before this + * returns is applied to the new sampler.

+ * + *

Never throws. A bad option (an endpoint that is not a URL) or an + * OpenTelemetry SDK on the classpath too old for the plugin is logged, + * and the plugin stays a no-op, so observability cannot stop the + * application or the LaunchDarkly client from starting.

+ * + * @return whether providers are running when this returns + */ + public static boolean initialize(String sdkKey, ObservabilityOptions options) { + synchronized (INIT_LOCK) { + if (INSTANCE.get() != null) { + log.fine("OtelManager already initialized, skipping"); + return true; + } + OtelManager manager; + try { + manager = create(sdkKey, options); + } catch (RuntimeException | LinkageError e) { + // LinkageError: Spring Boot and other BOMs pin their own + // OpenTelemetry version, which can be older than this plugin + // needs. LDClient only catches Exception from a plugin, so + // a NoSuchMethodError here would abort application startup. + String hint = e instanceof LinkageError + ? " OpenTelemetry SDK on the classpath: " + sdkVersion() + "; this plugin needs " + + Constants.MIN_OTEL_SDK_VERSION + " or newer." + : ""; + log.log(Level.SEVERE, "LaunchDarkly observability could not start and exports nothing;" + + " flag evaluations are unaffected." + hint + " Cause: " + e, e); + return false; + } + INSTANCE.set(manager); + SamplingConfig pending = PENDING_SAMPLING_CONFIG.get(); + if (pending != null) { + manager.customSampler.setConfig(pending); + } + // One hook for the process, and none when the application owns + // shutdown (see ObservabilityOptions.Builder#shutdownHook). + if (options.isShutdownHook() && shutdownHook == null) { + shutdownHook = new Thread(() -> { + OtelManager current = INSTANCE.getAndSet(null); + if (current != null) { + current.shutdownProviders(); + } + }, "ld-observability-shutdown"); + Runtime.getRuntime().addShutdownHook(shutdownHook); + } + return true; + } + } + + /** + * Records that an {@code LDClient} with this SDK key is registering the + * plugin. The providers are process-wide and carry one project id, so + * only the first key is used: a later client with a different key gets + * {@code false}, and its plugin neither starts providers nor replaces the + * sampling configuration. A client with the same key gets {@code true}. + */ + public static boolean registerClient(String sdkKey) { + synchronized (INIT_LOCK) { + if (registeredSdkKey == null) { + registeredSdkKey = sdkKey; + return true; + } + return registeredSdkKey.equals(sdkKey); + } + } + + /** The OpenTelemetry SDK version on the classpath, as the SDK reports it. */ + static String sdkVersion() { + try { + String version = Resource.getDefault().getAttribute(AttributeKey.stringKey("telemetry.sdk.version")); + return version == null ? "unknown" : version; + } catch (RuntimeException | LinkageError e) { + return "unknown"; + } + } + + private static OtelManager create(String sdkKey, ObservabilityOptions options) { + Resource resource = buildResource(sdkKey, options); + String endpoint = options.getOtlpEndpoint(); + Map headers = options.getCustomHeaders(); + ProxyOptions proxy = options.getProxySelector() == null + ? null + : ProxyOptions.create(options.getProxySelector()); + + CustomSampler customSampler = new CustomSampler(); + + // Each exporter below reports its own otlp.exporter.* counters to + // GlobalOpenTelemetry's meter provider, which is the plugin's once it + // registers, so they would land in the customer's LaunchDarkly + // metrics next to their own. They are switched off. + + // Trace exporter with sampling + OtlpHttpSpanExporterBuilder spanExporterBuilder = OtlpHttpSpanExporter.builder() + .setMeterProvider(MeterProvider.noop()) + .setEndpoint(endpoint + Constants.TRACES_PATH) + .setCompression("gzip") + .setHeaders(() -> headers); + if (proxy != null) { + spanExporterBuilder.setProxy(proxy); + } + OtlpHttpSpanExporter rawSpanExporter = spanExporterBuilder.build(); + SamplingTraceExporter samplingTraceExporter = + new SamplingTraceExporter(rawSpanExporter, customSampler); + + // Always record, as the Node plugin does. The default parent-based + // sampler would drop a span, flag evaluations included, whenever the + // current parent comes unsampled from the Java agent, another SDK or + // an upstream service. Volume is governed by the backend rules that + // SamplingTraceExporter applies at export. + SdkTracerProvider tracerProvider = SdkTracerProvider.builder() + .setSampler(Sampler.alwaysOn()) + .addSpanProcessor(BatchSpanProcessor.builder(samplingTraceExporter) + .setScheduleDelay(Duration.ofSeconds(1)) + .setExporterTimeout(Duration.ofSeconds(30)) + .build()) + .setResource(resource) + .build(); + + // Log exporter with level filter and sampling + OtlpHttpLogRecordExporterBuilder logExporterBuilder = OtlpHttpLogRecordExporter.builder() + .setMeterProvider(MeterProvider.noop()) + .setEndpoint(endpoint + Constants.LOGS_PATH) + .setCompression("gzip") + .setHeaders(() -> headers); + if (proxy != null) { + logExporterBuilder.setProxyOptions(proxy); + } + OtlpHttpLogRecordExporter rawLogExporter = logExporterBuilder.build(); + BatchLogRecordProcessor batchLogProcessor = BatchLogRecordProcessor.builder(rawLogExporter) + .setExporterTimeout(Duration.ofSeconds(30)) + .build(); + SamplingLogProcessor samplingLogProcessor = + new SamplingLogProcessor(batchLogProcessor, customSampler); + LogLevelFilterProcessor levelFilter = new LogLevelFilterProcessor( + samplingLogProcessor, options.getLogsApiLevel().getSeverityNumber()); + + SdkLoggerProvider loggerProvider = SdkLoggerProvider.builder() + .addLogRecordProcessor(levelFilter) + .setResource(resource) + .build(); + + // Metric exporter + OtlpHttpMetricExporterBuilder metricExporterBuilder = OtlpHttpMetricExporter.builder() + .setEndpoint(endpoint + Constants.METRICS_PATH) + .setCompression("gzip") + .setHeaders(() -> headers); + try { + metricExporterBuilder.setMeterProvider(MeterProvider.noop()); + } catch (NoSuchMethodError e) { + // Added in 1.50 together with the metric exporter's own + // counters; older metric exporters count nothing. Spring Boot + // 3.5 pins 1.49. + } + if (proxy != null) { + metricExporterBuilder.setProxyOptions(proxy); + } + OtlpHttpMetricExporter metricExporter = metricExporterBuilder.build(); + + SdkMeterProvider meterProvider = SdkMeterProvider.builder() + .registerMetricReader(PeriodicMetricReader.builder(metricExporter) + .setInterval(Duration.ofSeconds(5)) + .build()) + .setResource(resource) + .build(); + + // Propagator + TextMapPropagator propagator = W3CTraceContextPropagator.getInstance(); + + OpenTelemetrySdk sdk; + try { + sdk = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setLoggerProvider(loggerProvider) + .setMeterProvider(meterProvider) + .setPropagators(ContextPropagators.create(propagator)) + .buildAndRegisterGlobal(); + } catch (IllegalStateException e) { + // GlobalOpenTelemetry was already set (the OTel Java agent, the + // application's own SDK, or a library that called + // GlobalOpenTelemetry.get() first). Build without registering + // globally. LDObserve and EvaluationTracingHook use this SDK + // through OtelManager, so they keep exporting to LaunchDarkly. + log.warning("GlobalOpenTelemetry already set. Building SDK without global registration;" + + " LaunchDarkly telemetry is still exported through the plugin's own providers."); + sdk = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setLoggerProvider(loggerProvider) + .setMeterProvider(meterProvider) + .setPropagators(ContextPropagators.create(propagator)) + .build(); + } + + OtelManager manager = new OtelManager( + tracerProvider, loggerProvider, meterProvider, sdk, resource, customSampler); + + // One line at startup, like the LaunchDarkly SDK's own, so a user + // can see where telemetry goes and under which service name. + String serviceName = resource.getAttribute(AttributeKey.stringKey("service.name")); + log.info("LaunchDarkly observability started: exporting to " + endpoint + + " as service '" + serviceName + "'"); + if (serviceName == null || serviceName.startsWith("unknown_service")) { + log.warning("LaunchDarkly observability has no service name, so telemetry is grouped under '" + + serviceName + "'. Set ObservabilityOptions.Builder.serviceName(...)."); + } + return manager; + } + + /** + * Sets the sampling configuration received from the backend. + * + *

If providers have not been started yet (manual start), the config is + * kept and applied when {@link #initialize} runs. Concurrent callers share + * one config reference; the latest write wins.

+ */ + public static void setSamplingConfig(SamplingConfig config) { + PENDING_SAMPLING_CONFIG.set(config); + OtelManager manager = INSTANCE.get(); + if (manager != null) { + manager.customSampler.setConfig(config); + } + } + + /** + * Fetches the sampling configuration for this SDK key on a background + * daemon thread and applies it with {@link #setSamplingConfig}. Called + * by the plugin on registration and by {@code LDObserve.start}, so + * standalone use without an {@code LDClient} is sampled too. A key whose + * config has already been requested is not fetched again, and a key other + * than the registered client's does not replace that client's config. + */ + public static void fetchSamplingConfig(String sdkKey, ObservabilityOptions options) { + synchronized (INIT_LOCK) { + if (sdkKey == null || sdkKey.equals(samplingFetchSdkKey) + || (registeredSdkKey != null && !registeredSdkKey.equals(sdkKey))) { + return; + } + samplingFetchSdkKey = sdkKey; + } + Thread samplingThread = new Thread(() -> { + try { + GraphQLClient graphQLClient = new GraphQLClient( + options.getBackendUrl(), options.getCustomHeaders(), options.getProxySelector()); + SamplingConfig config = new SamplingApiService(graphQLClient).getSamplingConfig(sdkKey); + if (config != null) { + setSamplingConfig(config); + if (options.isDebug()) { + log.info("Loaded sampling config: " + config); + } + } + } catch (Exception e) { + log.log(Level.WARNING, "Failed to fetch sampling config", e); + } + }, "ld-observability-sampling"); + samplingThread.setDaemon(true); + samplingThread.start(); + } + + /** + * A view of the plugin's OpenTelemetry that resolves the providers on + * every call, so it works even when taken before the providers start. + * It has no {@code close()}: frameworks that close beans (Spring infers + * a destroy method from the instance) must not stop the plugin's + * providers behind its back. + */ + private static final OpenTelemetry VIEW = new OpenTelemetry() { + @Override + public TracerProvider getTracerProvider() { + OtelManager manager = INSTANCE.get(); + return manager != null ? manager.openTelemetrySdk.getTracerProvider() : TracerProvider.noop(); + } + + @Override + public MeterProvider getMeterProvider() { + OtelManager manager = INSTANCE.get(); + return manager != null ? manager.openTelemetrySdk.getMeterProvider() : MeterProvider.noop(); + } + + @Override + public LoggerProvider getLogsBridge() { + OtelManager manager = INSTANCE.get(); + return manager != null ? manager.openTelemetrySdk.getLogsBridge() : LoggerProvider.noop(); + } + + @Override + public ContextPropagators getPropagators() { + OtelManager manager = INSTANCE.get(); + return manager != null ? manager.openTelemetrySdk.getPropagators() : ContextPropagators.noop(); + } + + @Override + public String toString() { + return "LaunchDarklyObservabilityOpenTelemetry{started=" + (INSTANCE.get() != null) + "}"; + } + }; + + public static OpenTelemetry getOpenTelemetry() { + return VIEW; + } + + public static Tracer getTracer() { + OtelManager manager = INSTANCE.get(); + if (manager != null) { + return manager.tracer; + } + // OpenTelemetry.noop() does not install a process-wide global. Calling + // GlobalOpenTelemetry.getTracer before init would lock the JVM onto a + // no-op for everything else in the process. + return OpenTelemetry.noop().getTracer(Constants.INSTRUMENTATION_SCOPE_NAME); + } + + /** + * Returns a tracer from the plugin's own SDK under another + * instrumentation scope, whether or not that SDK is also the global. + */ + public static Tracer getTracer(String instrumentationScopeName) { + OtelManager manager = INSTANCE.get(); + if (manager != null) { + return manager.openTelemetrySdk.getTracer(instrumentationScopeName); + } + return OpenTelemetry.noop().getTracer(instrumentationScopeName); + } + + /** + * Whether the span was started by the plugin's own tracer provider, so + * it is exported to LaunchDarkly. A span from the Java agent or the + * application's own SDK is not: anything recorded on it goes to that + * provider's exporter instead. + */ + public static boolean ownsSpan(Span span) { + OtelManager manager = INSTANCE.get(); + // Spans share their provider's Resource instance. + return manager != null + && span instanceof ReadableSpan + && ((ReadableSpan) span).toSpanData().getResource() == manager.resource; + } + + public static Logger getLogger() { + OtelManager manager = INSTANCE.get(); + if (manager != null) { + return manager.logger; + } + return OpenTelemetry.noop().getLogsBridge().get(Constants.INSTRUMENTATION_SCOPE_NAME); + } + + public static Meter getMeter() { + OtelManager manager = INSTANCE.get(); + if (manager != null) { + return manager.meter; + } + return NOOP_METER; + } + + /** + * Returns the gauge with this name, built once per provider. Before + * initialization, or for a null name, it is a no-op and is not cached. + */ + public static DoubleGauge getGauge(String name) { + OtelManager manager = INSTANCE.get(); + if (manager == null || name == null) { + return NOOP_METER.gaugeBuilder("").build(); + } + return manager.gauges.computeIfAbsent(name, n -> manager.meter.gaugeBuilder(n).build()); + } + + /** Returns the counter with this name; see {@link #getGauge}. */ + public static LongCounter getCounter(String name) { + OtelManager manager = INSTANCE.get(); + if (manager == null || name == null) { + return NOOP_METER.counterBuilder("").build(); + } + return manager.counters.computeIfAbsent(name, n -> manager.meter.counterBuilder(n).build()); + } + + /** Returns the histogram with this name; see {@link #getGauge}. */ + public static DoubleHistogram getHistogram(String name) { + OtelManager manager = INSTANCE.get(); + if (manager == null || name == null) { + return NOOP_METER.histogramBuilder("").build(); + } + return manager.histograms.computeIfAbsent(name, n -> manager.meter.histogramBuilder(n).build()); + } + + /** Returns the up/down counter with this name; see {@link #getGauge}. */ + public static LongUpDownCounter getUpDownCounter(String name) { + OtelManager manager = INSTANCE.get(); + if (manager == null || name == null) { + return NOOP_METER.upDownCounterBuilder("").build(); + } + return manager.upDownCounters.computeIfAbsent(name, n -> manager.meter.upDownCounterBuilder(n).build()); + } + + /** + * Flushes all pending telemetry data. + */ + public static void flush() { + OtelManager manager = INSTANCE.get(); + if (manager != null) { + manager.flushProviders(); + } + } + + /** + * Shuts down all providers and flushes pending data. + */ + public static void shutdown() { + OtelManager manager; + synchronized (INIT_LOCK) { + manager = INSTANCE.getAndSet(null); + registeredSdkKey = null; + samplingFetchSdkKey = null; + } + if (manager != null) { + manager.shutdownProviders(); + } + } + + static Thread shutdownHookForTest() { + synchronized (INIT_LOCK) { + return shutdownHook; + } + } + + static boolean isInitialized() { + return INSTANCE.get() != null; + } + + static boolean isSamplingConfigured() { + OtelManager manager = INSTANCE.get(); + return manager != null && manager.customSampler.isSamplingEnabled(); + } + + /** + * Drops process-wide state so tests can initialize again. Removes the + * shutdown hook registered by {@link #initialize}. + */ + static void resetForTest() { + OtelManager manager; + synchronized (INIT_LOCK) { + manager = INSTANCE.getAndSet(null); + PENDING_SAMPLING_CONFIG.set(null); + registeredSdkKey = null; + samplingFetchSdkKey = null; + if (shutdownHook != null) { + try { + Runtime.getRuntime().removeShutdownHook(shutdownHook); + } catch (IllegalStateException ignored) { + // JVM shutdown is already in progress. + } + shutdownHook = null; + } + } + if (manager != null) { + manager.shutdownProviders(); + } + } + + // The three providers flush in parallel under one deadline. Waiting on + // each in turn, with a flush before the shutdown, took up to a minute + // when the endpoint was unreachable (26 s measured against a blackholed + // address), past a Kubernetes pod's default 30 s grace period. + static final long FLUSH_TIMEOUT_SECONDS = 10; + static final long SHUTDOWN_TIMEOUT_SECONDS = 5; + + private void flushProviders() { + awaitAll(FLUSH_TIMEOUT_SECONDS, "flush", + tracerProvider::forceFlush, loggerProvider::forceFlush, meterProvider::forceFlush); + } + + /** Shutting a provider down exports what it still holds, so no separate flush. */ + private void shutdownProviders() { + awaitAll(SHUTDOWN_TIMEOUT_SECONDS, "shutdown", + tracerProvider::shutdown, loggerProvider::shutdown, meterProvider::shutdown); + } + + /** + * Runs each operation on its own daemon thread and waits for all of them + * up to one deadline. Some of them block before returning their result + * (the metric reader's shutdown waits for an in-flight export), so + * joining the returned codes alone does not bound the wait. + */ + @SafeVarargs + private static void awaitAll(long timeoutSeconds, String what, Supplier... operations) { + CountDownLatch done = new CountDownLatch(operations.length); + for (Supplier operation : operations) { + Thread thread = new Thread(() -> { + try { + operation.get().join(timeoutSeconds, TimeUnit.SECONDS); + } finally { + done.countDown(); + } + }, "ld-observability-" + what); + thread.setDaemon(true); + thread.start(); + } + try { + if (!done.await(timeoutSeconds, TimeUnit.SECONDS)) { + log.warning("LaunchDarkly observability " + what + " did not finish within " + timeoutSeconds + + " s; telemetry still queued for an unreachable endpoint is dropped"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static Resource buildResource(String sdkKey, ObservabilityOptions options) { + io.opentelemetry.api.common.AttributesBuilder attrs = Attributes.builder(); + attrs.put(Constants.PROJECT_ID_ATTRIBUTE, sdkKey); + attrs.put("telemetry.distro.name", Constants.INSTRUMENTATION_SCOPE_NAME); + attrs.put("telemetry.distro.version", Constants.INSTRUMENTATION_VERSION); + + if (options.getServiceName() != null && !options.getServiceName().isEmpty()) { + attrs.put("service.name", options.getServiceName()); + } + if (options.getServiceVersion() != null && !options.getServiceVersion().isEmpty()) { + attrs.put("service.version", options.getServiceVersion()); + } + if (options.getEnvironment() != null && !options.getEnvironment().isEmpty()) { + attrs.put("deployment.environment.name", options.getEnvironment()); + } + + // User attributes are merged last so they can override the defaults. + return Resource.getDefault() + .merge(Resource.create(attrs.build())) + .merge(Resource.create(options.getResourceAttributes())); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java new file mode 100644 index 0000000000..3aaf76b28c --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java @@ -0,0 +1,228 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.observability.internal.sampling.SamplingConfig; +import com.launchdarkly.observability.internal.sampling.SamplingConfig.AttributeMatchConfig; +import com.launchdarkly.observability.internal.sampling.SamplingConfig.LogSamplingConfig; +import com.launchdarkly.observability.internal.sampling.SamplingConfig.MatchConfig; +import com.launchdarkly.observability.internal.sampling.SamplingConfig.SpanEventMatchConfig; +import com.launchdarkly.observability.internal.sampling.SamplingConfig.SpanSamplingConfig; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Fetches sampling configuration from the LaunchDarkly backend via GraphQL. + */ +public final class SamplingApiService { + + private static final Logger log = Logger.getLogger(SamplingApiService.class.getName()); + /** + * The sampling query as a compile-time constant. Go, .NET, Android and + * Node keep the query in code too: a classpath resource can be dropped by + * shaded jars built with minimize(), by ProGuard, and by some app-server + * and OSGi classloaders, and reading it at runtime adds I/O on a server. + */ + static final String QUERY = "" + + "fragment MatchParts on MatchConfig {\n" + + " regexValue\n" + + " matchValue\n" + + "}\n" + + "\n" + + "query GetSamplingConfig($organization_verbose_id: String!) {\n" + + " sampling(organization_verbose_id: $organization_verbose_id) {\n" + + " spans {\n" + + " name {\n" + + " ...MatchParts\n" + + " }\n" + + " attributes {\n" + + " key {\n" + + " ...MatchParts\n" + + " }\n" + + " attribute {\n" + + " ...MatchParts\n" + + " }\n" + + " }\n" + + " events {\n" + + " name {\n" + + " ...MatchParts\n" + + " }\n" + + " attributes {\n" + + " key {\n" + + " ...MatchParts\n" + + " }\n" + + " attribute {\n" + + " ...MatchParts\n" + + " }\n" + + " }\n" + + " }\n" + + " samplingRatio\n" + + " }\n" + + " logs {\n" + + " message {\n" + + " ...MatchParts\n" + + " }\n" + + " severityText {\n" + + " ...MatchParts\n" + + " }\n" + + " attributes {\n" + + " key {\n" + + " ...MatchParts\n" + + " }\n" + + " attribute {\n" + + " ...MatchParts\n" + + " }\n" + + " }\n" + + " samplingRatio\n" + + " }\n" + + " }\n" + + "}\n"; + + private final GraphQLClient client; + + public SamplingApiService(GraphQLClient client) { + this.client = client; + } + + /** + * Fetches the sampling configuration for the given SDK key. + * + * @param sdkKey the LaunchDarkly SDK key (used as organization_verbose_id) + * @return the parsed sampling configuration, or null on error + */ + public SamplingConfig getSamplingConfig(String sdkKey) { + try { + LDValue data = client.execute(QUERY, Collections.singletonMap("organization_verbose_id", sdkKey)); + if (data == null) { + return null; + } + LDValue sampling = objectOrNull(data, "sampling"); + if (sampling == null) { + return null; + } + return parseSamplingConfig(sampling); + } catch (Exception e) { + // A server that cannot reach the backend logs this on every + // start, so one line; the stack trace is there at FINE. + log.warning("Could not fetch the LaunchDarkly sampling config; exporting everything unsampled: " + e); + log.log(Level.FINE, "Sampling config fetch failure", e); + return null; + } + } + + /** Parses the {@code sampling} object of the GraphQL response. Visible for the shared scenario tests. */ + static SamplingConfig parseSamplingConfig(LDValue sampling) { + List spans = new ArrayList<>(); + for (LDValue el : objects(sampling.get("spans"))) { + spans.add(parseSpanConfig(el)); + } + List logs = new ArrayList<>(); + for (LDValue el : objects(sampling.get("logs"))) { + logs.add(parseLogConfig(el)); + } + return new SamplingConfig(spans, logs); + } + + private static SpanSamplingConfig parseSpanConfig(LDValue obj) { + List events = new ArrayList<>(); + for (LDValue event : objects(obj.get("events"))) { + events.add(new SpanEventMatchConfig( + parseMatchConfig(objectOrNull(event, "name")), + parseAttributeConfigs(event.get("attributes")) + )); + } + return new SpanSamplingConfig( + parseMatchConfig(objectOrNull(obj, "name")), + parseAttributeConfigs(obj.get("attributes")), + events, + ratioOrDefault(obj)); + } + + private static LogSamplingConfig parseLogConfig(LDValue obj) { + return new LogSamplingConfig( + parseMatchConfig(objectOrNull(obj, "message")), + parseMatchConfig(objectOrNull(obj, "severityText")), + parseAttributeConfigs(obj.get("attributes")), + ratioOrDefault(obj)); + } + + private static List parseAttributeConfigs(LDValue array) { + List result = new ArrayList<>(); + for (LDValue obj : objects(array)) { + result.add(new AttributeMatchConfig( + parseMatchConfig(objectOrNull(obj, "key")), + parseMatchConfig(objectOrNull(obj, "attribute")) + )); + } + return result; + } + + /** + * GraphQL returns {@code null} for optional fields that are unset. One + * null or mistyped field must not abort the whole payload, so lookups go + * through these and treat anything unexpected like absent. + */ + private static LDValue objectOrNull(LDValue parent, String key) { + LDValue el = parent.get(key); + return el.getType() == LDValueType.OBJECT ? el : null; + } + + /** The object elements of an array; empty for null or a non-array. */ + private static List objects(LDValue array) { + if (array.getType() != LDValueType.ARRAY) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (LDValue el : array.values()) { + if (el.getType() == LDValueType.OBJECT) { + result.add(el); + } + } + return result; + } + + private static int ratioOrDefault(LDValue obj) { + LDValue el = obj.get("samplingRatio"); + return el.isNumber() ? el.intValue() : 1; + } + + /** + * Like Go, an exact value takes precedence over a regex, and an empty + * regex counts as unset. A config with neither is no filter. + */ + private static MatchConfig parseMatchConfig(LDValue obj) { + if (obj == null) return null; + LDValue match = obj.get("matchValue"); + if (!match.isNull()) { + return MatchConfig.ofValue(matchValue(match)); + } + LDValue regex = obj.get("regexValue"); + if (regex.isString() && !regex.stringValue().isEmpty()) { + return MatchConfig.ofRegex(regex.stringValue()); + } + return null; + } + + /** + * Keeps the JSON type of a match value so a number matches a numeric + * attribute and a boolean a boolean, as in the Go and .NET SDKs. An array + * or object is kept as is and never matches, instead of throwing and + * losing the whole sampling payload. + */ + private static Object matchValue(LDValue el) { + switch (el.getType()) { + case BOOLEAN: + return el.booleanValue(); + case NUMBER: + return el.doubleValue(); + case STRING: + return el.stringValue(); + default: + return el; + } + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/CustomSampler.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/CustomSampler.java new file mode 100644 index 0000000000..86675590b4 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/CustomSampler.java @@ -0,0 +1,198 @@ +package com.launchdarkly.observability.internal.sampling; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.Value; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.sdk.trace.data.SpanData; + +import com.launchdarkly.observability.internal.Constants; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.IntPredicate; + +/** + * Applies export-time sampling based on a configuration fetched from the backend. + */ +public final class CustomSampler { + + private static final AttributeKey SAMPLING_RATIO_KEY = + AttributeKey.longKey(Constants.ATTR_SAMPLING_RATIO); + + private final AtomicReference configRef = new AtomicReference<>(); + private final IntPredicate sampler; + + public CustomSampler() { + this(CustomSampler::defaultSampler); + } + + /** Visible for testing — inject a deterministic sampler. */ + public CustomSampler(IntPredicate sampler) { + this.sampler = sampler; + } + + public void setConfig(SamplingConfig config) { + configRef.set(config); + } + + public boolean isSamplingEnabled() { + return configRef.get() != null; + } + + // --- Span sampling --- + + public SamplingResult sampleSpan(SpanData span) { + SamplingConfig config = configRef.get(); + if (config == null) { + return SamplingResult.SAMPLED; + } + for (SamplingConfig.SpanSamplingConfig spanConfig : config.getSpans()) { + if (matchesSpanConfig(spanConfig, span)) { + boolean keep = sampler.test(spanConfig.getSamplingRatio()); + return new SamplingResult(keep, + Attributes.of(SAMPLING_RATIO_KEY, (long) spanConfig.getSamplingRatio())); + } + } + return SamplingResult.SAMPLED; + } + + // --- Log sampling --- + + public SamplingResult sampleLog(LogRecordData log) { + SamplingConfig config = configRef.get(); + if (config == null) { + return SamplingResult.SAMPLED; + } + for (SamplingConfig.LogSamplingConfig logConfig : config.getLogs()) { + if (matchesLogConfig(logConfig, log)) { + boolean keep = sampler.test(logConfig.getSamplingRatio()); + return new SamplingResult(keep, + Attributes.of(SAMPLING_RATIO_KEY, (long) logConfig.getSamplingRatio())); + } + } + return SamplingResult.SAMPLED; + } + + // --- Matching logic --- + + private boolean matchesSpanConfig(SamplingConfig.SpanSamplingConfig config, SpanData span) { + if (config.getName() != null) { + if (!matchesValue(config.getName(), span.getName())) { + return false; + } + } + if (!matchesAttributes(config.getAttributes(), span.getAttributes())) { + return false; + } + if (!config.getEvents().isEmpty()) { + if (!matchesEvents(config.getEvents(), span.getEvents())) { + return false; + } + } + return true; + } + + private boolean matchesLogConfig(SamplingConfig.LogSamplingConfig config, LogRecordData log) { + if (config.getSeverityText() != null) { + // Match the OTLP severityText field, as the other SDKs do, not + // the Severity enum name: a rule for "error" must match a log + // whose text is "error". + if (!matchesValue(config.getSeverityText(), log.getSeverityText())) { + return false; + } + } + if (config.getMessage() != null) { + Value body = log.getBodyValue(); + if (!matchesValue(config.getMessage(), body != null ? body.getValue() : null)) { + return false; + } + } + return matchesAttributes(config.getAttributes(), log.getAttributes()); + } + + private boolean matchesEvents(List eventConfigs, List events) { + for (SamplingConfig.SpanEventMatchConfig eventConfig : eventConfigs) { + boolean found = false; + for (EventData event : events) { + if (matchesEventConfig(eventConfig, event)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; + } + + private boolean matchesEventConfig(SamplingConfig.SpanEventMatchConfig config, EventData event) { + if (config.getName() != null) { + if (!matchesValue(config.getName(), event.getName())) { + return false; + } + } + return matchesAttributes(config.getAttributes(), event.getAttributes()); + } + + private boolean matchesAttributes(List configs, Attributes attributes) { + if (configs.isEmpty()) { + return true; + } + Map, Object> attrMap = attributes.asMap(); + for (SamplingConfig.AttributeMatchConfig attrConfig : configs) { + boolean found = false; + for (Map.Entry, Object> entry : attrMap.entrySet()) { + if (matchesValue(attrConfig.getKey(), entry.getKey().getKey()) + && matchesValue(attrConfig.getAttribute(), entry.getValue())) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; + } + + static boolean matchesValue(SamplingConfig.MatchConfig config, Object actual) { + if (config == null) { + return true; + } + if (actual == null) { + return false; + } + return config.matches(actual); + } + + /** + * Determine if an item should be sampled based on the sampling ratio. + * A ratio of 0 means never sample. A ratio of 1 means always sample. + * Otherwise, sample 1 in every {@code ratio} items. + */ + static boolean defaultSampler(int ratio) { + if (ratio <= 0) return false; + if (ratio == 1) return true; + return ThreadLocalRandom.current().nextInt(ratio) == 0; + } + + public static final class SamplingResult { + static final SamplingResult SAMPLED = new SamplingResult(true, null); + + private final boolean sample; + private final Attributes attributes; + + public SamplingResult(boolean sample, Attributes attributes) { + this.sample = sample; + this.attributes = attributes; + } + + public boolean isSampled() { return sample; } + public Attributes getAttributes() { return attributes; } + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java new file mode 100644 index 0000000000..ce8c9f7cca --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java @@ -0,0 +1,188 @@ +package com.launchdarkly.observability.internal.sampling; + +import java.util.Collections; +import java.util.List; +import java.util.logging.Logger; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Sampling configuration received from the LaunchDarkly backend. + */ +public final class SamplingConfig { + + private final List spans; + private final List logs; + + public SamplingConfig(List spans, List logs) { + this.spans = spans != null ? spans : Collections.emptyList(); + this.logs = logs != null ? logs : Collections.emptyList(); + } + + public List getSpans() { return spans; } + public List getLogs() { return logs; } + + @Override + public String toString() { + return "SamplingConfig{spans=" + spans.size() + ", logs=" + logs.size() + "}"; + } + + /** + * A match config is either an exact value or a regular expression. + * The pattern is compiled once so export-time matching does not recompile + * it on every span or log. + * + *

Matching follows the Go and .NET SDKs: a numeric value matches a + * numeric attribute of equal value whether it is a long or a double, a + * boolean matches a boolean, a string matches the value's text, and a + * regex only matches string values.

+ */ + public static final class MatchConfig { + private static final Logger log = Logger.getLogger(MatchConfig.class.getName()); + private static final double EPSILON = 0.0000000000000001; + + private final Object value; + private final String regexPattern; + private final Pattern compiledRegex; + + private MatchConfig(Object value, String regexPattern, Pattern compiledRegex) { + this.value = value; + this.regexPattern = regexPattern; + this.compiledRegex = compiledRegex; + } + + /** + * An exact-value match. The value is a {@link String}, a + * {@link Boolean} or a {@link Number}; any other type never matches. + */ + public static MatchConfig ofValue(Object value) { + return new MatchConfig(value, null, null); + } + + public static MatchConfig ofRegex(String pattern) { + return new MatchConfig(null, pattern, compile(pattern)); + } + + /** + * Compiles a sampling regex as sent by the backend, like the Go SDK. + * A pattern that does not compile is logged and the rule is skipped: + * it never matches, and parsing the rest of the config carries on. + */ + private static Pattern compile(String pattern) { + if (pattern == null) { + return null; + } + try { + return Pattern.compile(pattern); + } catch (PatternSyntaxException e) { + log.warning("Skipping sampling rule with invalid regex " + pattern + ": " + e.getDescription()); + return null; + } + } + + public boolean isRegex() { return regexPattern != null; } + public Object getValue() { return value; } + public String getRegexPattern() { return regexPattern; } + + /** + * @param actual a span name, log body, attribute key or attribute + * value as recorded: String, Boolean, Long, Double or + * a list of those + */ + public boolean matches(Object actual) { + if (actual == null) { + return false; + } + if (regexPattern != null) { + return compiledRegex != null + && actual instanceof String + && compiledRegex.matcher((String) actual).find(); + } + if (value instanceof String) { + return value.equals(actual instanceof String ? actual : String.valueOf(actual)); + } + if (value instanceof Boolean) { + return value.equals(actual); + } + if (value instanceof Number) { + return actual instanceof Number + && Math.abs(((Number) value).doubleValue() - ((Number) actual).doubleValue()) < EPSILON; + } + return false; + } + } + + public static final class AttributeMatchConfig { + private final MatchConfig key; + private final MatchConfig attribute; + + public AttributeMatchConfig(MatchConfig key, MatchConfig attribute) { + this.key = key; + this.attribute = attribute; + } + + public MatchConfig getKey() { return key; } + public MatchConfig getAttribute() { return attribute; } + } + + public static final class SpanEventMatchConfig { + private final MatchConfig name; + private final List attributes; + + public SpanEventMatchConfig(MatchConfig name, List attributes) { + this.name = name; + this.attributes = attributes != null ? attributes : Collections.emptyList(); + } + + public MatchConfig getName() { return name; } + public List getAttributes() { return attributes; } + } + + public static final class SpanSamplingConfig { + private final MatchConfig name; + private final List attributes; + private final List events; + private final int samplingRatio; + + public SpanSamplingConfig( + MatchConfig name, + List attributes, + List events, + int samplingRatio + ) { + this.name = name; + this.attributes = attributes != null ? attributes : Collections.emptyList(); + this.events = events != null ? events : Collections.emptyList(); + this.samplingRatio = samplingRatio; + } + + public MatchConfig getName() { return name; } + public List getAttributes() { return attributes; } + public List getEvents() { return events; } + public int getSamplingRatio() { return samplingRatio; } + } + + public static final class LogSamplingConfig { + private final MatchConfig message; + private final MatchConfig severityText; + private final List attributes; + private final int samplingRatio; + + public LogSamplingConfig( + MatchConfig message, + MatchConfig severityText, + List attributes, + int samplingRatio + ) { + this.message = message; + this.severityText = severityText; + this.attributes = attributes != null ? attributes : Collections.emptyList(); + this.samplingRatio = samplingRatio; + } + + public MatchConfig getMessage() { return message; } + public MatchConfig getSeverityText() { return severityText; } + public List getAttributes() { return attributes; } + public int getSamplingRatio() { return samplingRatio; } + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingLogProcessor.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingLogProcessor.java new file mode 100644 index 0000000000..5f5bec3f86 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingLogProcessor.java @@ -0,0 +1,71 @@ +package com.launchdarkly.observability.internal.sampling; + +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.LogRecordProcessor; +import io.opentelemetry.sdk.logs.ReadWriteLogRecord; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * A {@link LogRecordProcessor} that applies sampling logic before delegating + * to another processor. + * + *

{@code onEmit} runs on the thread that logged, usually a request thread, + * so a sampling failure (for example a regex that overflows the stack on a + * long message) is logged once and the record is kept rather than thrown + * into the caller.

+ */ +public final class SamplingLogProcessor implements LogRecordProcessor { + + private static final Logger log = Logger.getLogger(SamplingLogProcessor.class.getName()); + + private final LogRecordProcessor delegate; + private final CustomSampler sampler; + private final AtomicBoolean failureLogged = new AtomicBoolean(); + + public SamplingLogProcessor(LogRecordProcessor delegate, CustomSampler sampler) { + this.delegate = delegate; + this.sampler = sampler; + } + + @Override + public void onEmit(Context context, ReadWriteLogRecord logRecord) { + if (!sampler.isSamplingEnabled()) { + delegate.onEmit(context, logRecord); + return; + } + + CustomSampler.SamplingResult result; + try { + result = sampler.sampleLog(logRecord.toLogRecordData()); + } catch (RuntimeException | StackOverflowError e) { + if (failureLogged.compareAndSet(false, true)) { + log.log(Level.WARNING, "Log sampling failed; exporting records unsampled", e); + } + delegate.onEmit(context, logRecord); + return; + } + if (!result.isSampled()) { + return; + } + + if (result.getAttributes() != null) { + // Sets or overrides only the sampling attributes. + logRecord.setAllAttributes(result.getAttributes()); + } + delegate.onEmit(context, logRecord); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } + + @Override + public CompletableResultCode forceFlush() { + return delegate.forceFlush(); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporter.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporter.java new file mode 100644 index 0000000000..30937275f1 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporter.java @@ -0,0 +1,183 @@ +package com.launchdarkly.observability.internal.sampling; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.sdk.trace.data.LinkData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import io.opentelemetry.sdk.trace.export.SpanExporter; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * A {@link SpanExporter} that applies sampling logic before delegating to another exporter. + * + *

Sampled spans keep the sampling-ratio attributes returned by {@link CustomSampler}. + * Children of a dropped span are dropped with it, matching the other LaunchDarkly SDKs.

+ */ +public final class SamplingTraceExporter implements SpanExporter { + + private static final Logger log = Logger.getLogger(SamplingTraceExporter.class.getName()); + + private final SpanExporter delegate; + private final CustomSampler sampler; + private final AtomicBoolean failureLogged = new AtomicBoolean(); + + public SamplingTraceExporter(SpanExporter delegate, CustomSampler sampler) { + this.delegate = delegate; + this.sampler = sampler; + } + + @Override + public CompletableResultCode export(Collection spans) { + List sampled; + try { + sampled = sampleSpans(spans); + } catch (RuntimeException | StackOverflowError e) { + // Losing the batch would be worse than exporting it unsampled. + if (failureLogged.compareAndSet(false, true)) { + log.log(Level.WARNING, "Span sampling failed; exporting the batch unsampled", e); + } + sampled = new ArrayList<>(spans); + } + if (sampled.isEmpty()) { + return CompletableResultCode.ofSuccess(); + } + return delegate.export(sampled); + } + + private List sampleSpans(Collection spans) { + if (!sampler.isSamplingEnabled()) { + return new ArrayList<>(spans); + } + + ArrayDeque omittedSpanIds = new ArrayDeque<>(); + Map spanById = new HashMap<>(); + Map> childrenByParentId = new HashMap<>(); + + for (SpanData span : spans) { + String spanId = span.getSpanId(); + SpanContext parent = span.getParentSpanContext(); + if (parent != null && parent.isValid()) { + childrenByParentId + .computeIfAbsent(parent.getSpanId(), ignored -> new ArrayList<>()) + .add(spanId); + } + + CustomSampler.SamplingResult result = sampler.sampleSpan(span); + if (result.isSampled()) { + Attributes extra = result.getAttributes(); + if (extra != null && !extra.isEmpty()) { + spanById.put(spanId, new AttributedSpanData(span, extra)); + } else { + spanById.put(spanId, span); + } + } else { + omittedSpanIds.add(spanId); + } + } + + while (!omittedSpanIds.isEmpty()) { + String spanId = omittedSpanIds.removeFirst(); + List children = childrenByParentId.get(spanId); + if (children == null) { + continue; + } + for (String childId : children) { + spanById.remove(childId); + omittedSpanIds.add(childId); + } + } + + return new ArrayList<>(spanById.values()); + } + + @Override + public CompletableResultCode flush() { + return delegate.flush(); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } + + /** + * SpanData view that merges sampling attributes onto the original span. + */ + @SuppressWarnings("deprecation") + private static final class AttributedSpanData implements SpanData { + private final SpanData delegate; + private final Attributes attributes; + + AttributedSpanData(SpanData delegate, Attributes extra) { + this.delegate = delegate; + this.attributes = Attributes.builder() + .putAll(delegate.getAttributes()) + .putAll(extra) + .build(); + } + + @Override + public String getName() { return delegate.getName(); } + + @Override + public SpanKind getKind() { return delegate.getKind(); } + + @Override + public SpanContext getSpanContext() { return delegate.getSpanContext(); } + + @Override + public SpanContext getParentSpanContext() { return delegate.getParentSpanContext(); } + + @Override + public StatusData getStatus() { return delegate.getStatus(); } + + @Override + public long getStartEpochNanos() { return delegate.getStartEpochNanos(); } + + @Override + public Attributes getAttributes() { return attributes; } + + @Override + public List getEvents() { return delegate.getEvents(); } + + @Override + public List getLinks() { return delegate.getLinks(); } + + @Override + public long getEndEpochNanos() { return delegate.getEndEpochNanos(); } + + @Override + public boolean hasEnded() { return delegate.hasEnded(); } + + @Override + public int getTotalRecordedEvents() { return delegate.getTotalRecordedEvents(); } + + @Override + public int getTotalRecordedLinks() { return delegate.getTotalRecordedLinks(); } + + @Override + public int getTotalAttributeCount() { return attributes.size(); } + + @Override + public io.opentelemetry.sdk.common.InstrumentationLibraryInfo getInstrumentationLibraryInfo() { + return delegate.getInstrumentationLibraryInfo(); + } + + @Override + public Resource getResource() { return delegate.getResource(); } + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/LDObserveTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/LDObserveTest.java new file mode 100644 index 0000000000..e4363d5db2 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/LDObserveTest.java @@ -0,0 +1,74 @@ +package com.launchdarkly.observability; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.Span; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests that LDObserve methods are safe to call before initialization + * (they delegate to no-op OTel implementations). + */ +class LDObserveTest { + + @Test + void recordMetricBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordMetric("test.gauge", 1.0, Attributes.empty())); + } + + @Test + void recordCountBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordCount("test.count", 1, Attributes.empty())); + } + + @Test + void recordIncrBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordIncr("test.incr", Attributes.empty())); + } + + @Test + void recordHistogramBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordHistogram("test.histogram", 42.5, Attributes.empty())); + } + + @Test + void recordUpDownCounterBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordUpDownCounter("test.updown", 1, Attributes.empty())); + } + + @Test + void recordLogBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordLog("test message", Severity.INFO, Attributes.empty())); + } + + @Test + void recordErrorBeforeInitDoesNotThrow() { + assertDoesNotThrow(() -> + LDObserve.recordError(new RuntimeException("test"), Attributes.empty())); + } + + @Test + void startSpanBeforeInitReturnsNonNull() { + Span span = LDObserve.startSpan("test-span", Attributes.empty()); + assertNotNull(span); + span.end(); + } + + @Test + void flushBeforeInitDoesNotThrow() { + assertDoesNotThrow(LDObserve::flush); + } + + @Test + void shutdownBeforeInitDoesNotThrow() { + assertDoesNotThrow(LDObserve::shutdown); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java new file mode 100644 index 0000000000..7199109802 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java @@ -0,0 +1,128 @@ +package com.launchdarkly.observability; + +import com.launchdarkly.observability.internal.EvaluationTracingHook; +import com.launchdarkly.sdk.server.integrations.Hook; +import com.launchdarkly.sdk.server.integrations.PluginMetadata; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import org.junit.jupiter.api.Test; + +import java.net.Proxy; +import java.net.URI; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class ObservabilityPluginTest { + + @Test + void metadataReturnsCorrectName() { + ObservabilityOptions options = new ObservabilityOptions.Builder().build(); + ObservabilityPlugin plugin = new ObservabilityPlugin(options); + + PluginMetadata metadata = plugin.getMetadata(); + assertEquals("launchdarkly-observability", metadata.getName()); + } + + @Test + void getHooksReturnsTracingHook() { + ObservabilityOptions options = new ObservabilityOptions.Builder().build(); + ObservabilityPlugin plugin = new ObservabilityPlugin(options); + + List hooks = plugin.getHooks(null); + assertNotNull(hooks); + assertEquals(1, hooks.size()); + // The plugin's own hook, so evaluations export even when another SDK + // owns GlobalOpenTelemetry. + assertInstanceOf(EvaluationTracingHook.class, hooks.get(0)); + } + + @Test + void optionsBuilderDefaults() { + ObservabilityOptions options = new ObservabilityOptions.Builder().build(); + assertEquals("", options.getServiceName()); + assertEquals("", options.getServiceVersion()); + assertEquals("", options.getEnvironment()); + assertFalse(options.isDebug()); + assertFalse(options.isManualStart()); + assertTrue(options.isShutdownHook()); + assertEquals("https://otel.observability.app.launchdarkly.com:4318", options.getOtlpEndpoint()); + assertEquals("https://pub.observability.app.launchdarkly.com", options.getBackendUrl()); + assertTrue(options.isEnabled()); + assertEquals(Attributes.empty(), options.getResourceAttributes()); + assertTrue(options.getCustomHeaders().isEmpty()); + assertNull(options.getProxySelector()); + assertEquals(ObservabilityOptions.LogLevel.INFO, options.getLogsApiLevel()); + } + + @Test + void disabledPluginRegistersNoHooks() { + ObservabilityOptions options = new ObservabilityOptions.Builder().enabled(false).build(); + ObservabilityPlugin plugin = new ObservabilityPlugin(options); + + assertFalse(options.isEnabled()); + assertTrue(plugin.getHooks(null).isEmpty(), "a disabled plugin must not add a flag-evaluation hook"); + } + + @Test + void customHeadersAccumulateAndAreCopied() { + Map initial = new java.util.HashMap<>(); + initial.put("Authorization", "Bearer one"); + ObservabilityOptions.Builder builder = new ObservabilityOptions.Builder() + .customHeaders(initial) + .customHeader("X-Tenant", "acme"); + initial.put("X-Leaked", "later"); + ObservabilityOptions options = builder.build(); + + assertEquals(Map.of("Authorization", "Bearer one", "X-Tenant", "acme"), options.getCustomHeaders()); + assertThrows(UnsupportedOperationException.class, () -> options.getCustomHeaders().put("a", "b")); + } + + @Test + void proxyHostAndPortBuildsAnHttpProxySelector() { + ObservabilityOptions options = new ObservabilityOptions.Builder() + .proxyHostAndPort("proxy.example.test", 3128) + .build(); + + List proxies = options.getProxySelector().select(URI.create("https://otel.example.test/v1/traces")); + assertEquals(1, proxies.size()); + assertEquals(Proxy.Type.HTTP, proxies.get(0).type()); + assertEquals("proxy.example.test:3128", proxies.get(0).address().toString().replace("/", "")); + } + + @Test + void resourceAttributesAndLogLevelAreKept() { + Attributes attrs = Attributes.of(AttributeKey.stringKey("team"), "payments"); + ObservabilityOptions options = new ObservabilityOptions.Builder() + .resourceAttributes(attrs) + .logsApiLevel(ObservabilityOptions.LogLevel.WARN) + .build(); + + assertEquals(attrs, options.getResourceAttributes()); + assertEquals(ObservabilityOptions.LogLevel.WARN, options.getLogsApiLevel()); + assertEquals(13, ObservabilityOptions.LogLevel.WARN.getSeverityNumber()); + assertTrue(ObservabilityOptions.LogLevel.NONE.getSeverityNumber() > ObservabilityOptions.LogLevel.FATAL.getSeverityNumber()); + } + + @Test + void optionsBuilderCustomValues() { + ObservabilityOptions options = new ObservabilityOptions.Builder() + .serviceName("test-service") + .serviceVersion("2.0.0") + .environment("staging") + .otlpEndpoint("https://otel.example.test:4318") + .backendUrl("https://backend.example.test") + .debug(true) + .manualStart(true) + .build(); + + assertEquals("test-service", options.getServiceName()); + assertEquals("2.0.0", options.getServiceVersion()); + assertEquals("staging", options.getEnvironment()); + assertEquals("https://otel.example.test:4318", options.getOtlpEndpoint()); + assertEquals("https://backend.example.test", options.getBackendUrl()); + assertTrue(options.isDebug()); + assertTrue(options.isManualStart()); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java new file mode 100644 index 0000000000..87403a2a55 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/GraphQLClientTest.java @@ -0,0 +1,170 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.observability.internal.sampling.SamplingConfig; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.zip.GZIPInputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GraphQLClientTest { + + private static final String SAMPLING_RESPONSE = + "{\"data\":{\"sampling\":{\"spans\":[{\"name\":{\"matchValue\":\"health-check\"}," + + "\"attributes\":[],\"events\":[],\"samplingRatio\":0}],\"logs\":[]}}}"; + + private HttpServer server; + private final List bodies = new CopyOnWriteArrayList<>(); + private final List headerValues = new CopyOnWriteArrayList<>(); + private final List requestLines = new CopyOnWriteArrayList<>(); + private volatile int status = 200; + private volatile String response = SAMPLING_RESPONSE; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/", exchange -> { + requestLines.add(exchange.getRequestMethod() + " " + exchange.getRequestURI()); + String header = exchange.getRequestHeaders().getFirst("X-Test-Header"); + headerValues.add(header == null ? "" : header); + bodies.add(bodyAsString(exchange)); + byte[] out = response.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, out.length); + exchange.getResponseBody().write(out); + exchange.close(); + }); + server.start(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + @Test + void samplingQueryIsACompileTimeConstantAndIsSentWithVariables() throws Exception { + assertTrue(SamplingApiService.QUERY.contains("query GetSamplingConfig")); + + GraphQLClient client = new GraphQLClient(url("/graphql")); + SamplingConfig config = new SamplingApiService(client).getSamplingConfig("sdk-test"); + + assertNotNull(config, "sampling config should parse"); + assertEquals(1, bodies.size()); + assertTrue(bodies.get(0).contains("query GetSamplingConfig"), "query text was not sent"); + assertTrue(bodies.get(0).contains("\"organization_verbose_id\":\"sdk-test\""), "variables were not sent"); + assertEquals("", headerValues.get(0), "no custom header was configured"); + } + + @Test + void samplingPayloadWithJsonNullsStillParses() throws Exception { + // GraphQL returns null for unset optional fields. A single null must not + // abort the payload, or sampling silently never applies. + response = "{\"data\":{\"sampling\":{\"spans\":[" + + "{\"name\":null,\"attributes\":null,\"events\":null,\"samplingRatio\":null}," + + "{\"name\":{\"matchValue\":\"health-check\",\"regexValue\":null}," + + "\"attributes\":[{\"key\":{\"matchValue\":\"k\"},\"attribute\":null}]," + + "\"events\":[{\"name\":null,\"attributes\":null}],\"samplingRatio\":0}" + + "],\"logs\":[{\"message\":null,\"severityText\":{\"matchValue\":\"ERROR\"}," + + "\"attributes\":null,\"samplingRatio\":3}]}}}"; + GraphQLClient client = new GraphQLClient(url("/graphql")); + + SamplingConfig config = new SamplingApiService(client).getSamplingConfig("sdk-test"); + + assertNotNull(config, "nulls in optional fields must not abort parsing"); + assertEquals(2, config.getSpans().size()); + assertEquals(1, config.getSpans().get(0).getSamplingRatio(), "null ratio defaults to 1"); + assertEquals(0, config.getSpans().get(1).getSamplingRatio()); + assertEquals(1, config.getLogs().size()); + assertEquals(3, config.getLogs().get(0).getSamplingRatio()); + } + + @Test + void customHeadersAreSentWithEveryRequest() throws Exception { + GraphQLClient client = new GraphQLClient(url("/graphql"), Map.of("X-Test-Header", "abc"), null); + + client.execute(SamplingApiService.QUERY, Map.of("organization_verbose_id", "sdk-test")); + client.execute(SamplingApiService.QUERY, Map.of("organization_verbose_id", "sdk-test")); + + assertEquals(List.of("abc", "abc"), headerValues); + } + + @Test + void proxySelectorRoutesRequestThroughProxy() throws Exception { + // The local server acts as the HTTP proxy: a proxied plain-HTTP request + // carries the absolute target URI in its request line. + InetSocketAddress proxyAddress = server.getAddress(); + GraphQLClient client = new GraphQLClient( + "http".concat("://backend.invalid/sampling"), + Collections.emptyMap(), + new ProxySelectorForTest(proxyAddress)); + + LDValue data = client.execute(SamplingApiService.QUERY, Map.of("organization_verbose_id", "sdk-test")); + + assertNotNull(data, "the proxied request should succeed"); + assertEquals(1, requestLines.size()); + assertTrue(requestLines.get(0).contains("backend.invalid/sampling"), + "the proxy did not receive the absolute target URI: " + requestLines.get(0)); + } + + @Test + void nonSuccessStatusReturnsNull() throws Exception { + status = 503; + response = "unavailable"; + GraphQLClient client = new GraphQLClient(url("/graphql")); + + assertNull(client.execute(SamplingApiService.QUERY, Map.of("organization_verbose_id", "sdk-test"))); + assertFalse(bodies.isEmpty()); + } + + private String url(String path) { + return "http".concat("://") + server.getAddress().getHostString() + ":" + server.getAddress().getPort() + path; + } + + private static String bodyAsString(HttpExchange exchange) throws IOException { + byte[] raw = exchange.getRequestBody().readAllBytes(); + String encoding = exchange.getRequestHeaders().getFirst("Content-Encoding"); + if (encoding != null && encoding.toLowerCase().contains("gzip")) { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(raw))) { + raw = in.readAllBytes(); + } + } + return new String(raw, StandardCharsets.UTF_8); + } + + /** Sends everything through one HTTP proxy. */ + static final class ProxySelectorForTest extends java.net.ProxySelector { + private final List proxies; + + ProxySelectorForTest(InetSocketAddress address) { + this.proxies = List.of(new java.net.Proxy(java.net.Proxy.Type.HTTP, address)); + } + + @Override + public List select(java.net.URI uri) { + return proxies; + } + + @Override + public void connectFailed(java.net.URI uri, java.net.SocketAddress sa, IOException ioe) { + } + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/LDObserveArgumentsTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/LDObserveArgumentsTest.java new file mode 100644 index 0000000000..0fad5936de --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/LDObserveArgumentsTest.java @@ -0,0 +1,58 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.observability.LDObserve; +import com.launchdarkly.observability.ObservabilityOptions; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.Span; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * LDObserve sits on application request paths, so a null argument must not + * throw into the caller, before or after the providers start. + */ +class LDObserveArgumentsTest { + + @BeforeEach + @AfterEach + void reset() { + OtelManager.resetForTest(); + GlobalOpenTelemetry.resetForTest(); + } + + @ParameterizedTest(name = "initialized={0}") + @ValueSource(booleans = {false, true}) + void nullArgumentsDoNotThrow(boolean initialized) { + if (initialized) { + OtelManager.initialize("sdk-test", new ObservabilityOptions.Builder() + .otlpEndpoint("https://otel.example.test:4318") + .backendUrl("https://backend.example.test") + .build()); + } + + assertDoesNotThrow(() -> LDObserve.recordMetric(null, 1.0, null)); + assertDoesNotThrow(() -> LDObserve.recordCount(null, 1, null)); + assertDoesNotThrow(() -> LDObserve.recordIncr(null, null)); + assertDoesNotThrow(() -> LDObserve.recordHistogram(null, 1.0, null)); + assertDoesNotThrow(() -> LDObserve.recordUpDownCounter(null, 1, null)); + assertDoesNotThrow(() -> LDObserve.recordMetric("gauge", 1.0, null)); + assertDoesNotThrow(() -> LDObserve.recordCount("count", 1, null)); + assertDoesNotThrow(() -> LDObserve.recordHistogram("histogram", 1.0, null)); + assertDoesNotThrow(() -> LDObserve.recordUpDownCounter("updown", 1, null)); + assertDoesNotThrow(() -> LDObserve.recordLog(null, null, null)); + assertDoesNotThrow(() -> LDObserve.recordLog("message", Severity.WARN, null)); + assertDoesNotThrow(() -> LDObserve.recordError(null, null)); + assertDoesNotThrow(() -> LDObserve.recordError(new RuntimeException(), null)); + assertDoesNotThrow(() -> { + Span span = LDObserve.startSpan(null, null); + assertNotNull(span); + span.end(); + }); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/OtelManagerTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/OtelManagerTest.java new file mode 100644 index 0000000000..18268b5053 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/OtelManagerTest.java @@ -0,0 +1,195 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.internal.sampling.SamplingConfig; +import io.opentelemetry.api.GlobalOpenTelemetry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OtelManagerTest { + + @BeforeEach + @AfterEach + void reset() { + OtelManager.resetForTest(); + GlobalOpenTelemetry.resetForTest(); + } + + @Test + void samplingConfigArrivingBeforeManualStartIsApplied() { + OtelManager.setSamplingConfig(sampleConfig()); + assertFalse(OtelManager.isInitialized()); + + OtelManager.initialize("sdk-test", options()); + + assertTrue(OtelManager.isInitialized()); + assertTrue(OtelManager.isSamplingConfigured()); + } + + @Test + void concurrentInitializeBuildsOneManager() throws Exception { + int threads = 8; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger failures = new AtomicInteger(); + try { + for (int i = 0; i < threads; i++) { + pool.submit(() -> { + ready.countDown(); + try { + if (!start.await(5, TimeUnit.SECONDS)) { + failures.incrementAndGet(); + return; + } + OtelManager.initialize("sdk-test", options()); + } catch (Exception e) { + failures.incrementAndGet(); + } + }); + } + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + } finally { + pool.shutdown(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + } + + assertEqualsZero(failures.get()); + assertTrue(OtelManager.isInitialized()); + } + + @Test + void instrumentsAreCachedByNamePerProvider() { + OtelManager.initialize("sdk-test", options()); + + assertSame(OtelManager.getGauge("cpu"), OtelManager.getGauge("cpu")); + assertSame(OtelManager.getCounter("orders"), OtelManager.getCounter("orders")); + assertSame(OtelManager.getHistogram("latency"), OtelManager.getHistogram("latency")); + assertSame(OtelManager.getUpDownCounter("queue"), OtelManager.getUpDownCounter("queue")); + assertNotSame(OtelManager.getGauge("cpu"), OtelManager.getGauge("memory")); + + Object gaugeBeforeRestart = OtelManager.getGauge("cpu"); + OtelManager.shutdown(); + OtelManager.initialize("sdk-test", options()); + assertNotSame(gaugeBeforeRestart, OtelManager.getGauge("cpu"), + "a restarted provider must not reuse instruments from the old one"); + } + + @Test + void restartKeepsOneShutdownHook() { + OtelManager.initialize("sdk-test", options()); + Thread hook = OtelManager.shutdownHookForTest(); + assertNotNull(hook); + + OtelManager.shutdown(); + OtelManager.initialize("sdk-test", options()); + + assertSame(hook, OtelManager.shutdownHookForTest(), "a restart must not register another JVM shutdown hook"); + } + + @Test + void onlyTheFirstSdkKeyRegistersUntilShutdown() { + assertTrue(OtelManager.registerClient("sdk-a")); + assertTrue(OtelManager.registerClient("sdk-a"), "another client for the same key shares the providers"); + assertFalse(OtelManager.registerClient("sdk-b")); + + OtelManager.shutdown(); + assertTrue(OtelManager.registerClient("sdk-b"), "after shutdown a new key can register"); + } + + private static void assertEqualsZero(int failures) { + assertTrue(failures == 0, "concurrent initialize failures: " + failures); + } + + @Test + void shutdownHookIsRegisteredByDefault() { + OtelManager.initialize("sdk-test", options()); + assertNotNull(OtelManager.shutdownHookForTest()); + } + + @Test + void shutdownHookCanBeLeftToTheFramework() { + OtelManager.initialize("sdk-test", new ObservabilityOptions.Builder() + .otlpEndpoint("https://otel.example.test:4318") + .shutdownHook(false) + .build()); + + assertTrue(OtelManager.isInitialized()); + assertNull(OtelManager.shutdownHookForTest(), + "with shutdownHook(false) the application stops the plugin; a JVM hook would race it"); + } + + @Test + void shutdownAgainstAHungEndpointIsBounded() throws Exception { + // Accepts connections and never answers, like a blackholed collector. + try (java.net.ServerSocket hung = new java.net.ServerSocket(0, 50, java.net.InetAddress.getLoopbackAddress())) { + List held = new java.util.concurrent.CopyOnWriteArrayList<>(); + Thread acceptor = new Thread(() -> { + try { + while (true) { + held.add(hung.accept()); + } + } catch (java.io.IOException closed) { + // Test over. + } + }); + acceptor.setDaemon(true); + acceptor.start(); + + OtelManager.initialize("sdk-test", new ObservabilityOptions.Builder() + .otlpEndpoint("http://127.0.0.1:" + hung.getLocalPort()) + .shutdownHook(false) + .build()); + OtelManager.getTracer().spanBuilder("pending").startSpan().end(); + OtelManager.getLogger().logRecordBuilder().setBody("pending").emit(); + OtelManager.getCounter("pending").add(1); + + long started = System.nanoTime(); + OtelManager.shutdown(); + long seconds = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - started); + + assertTrue(seconds <= OtelManager.SHUTDOWN_TIMEOUT_SECONDS + 2, + "shutdown took " + seconds + " s against a hung endpoint"); + for (java.net.Socket socket : held) { + socket.close(); + } + } + } + + private static ObservabilityOptions options() { + return new ObservabilityOptions.Builder() + .serviceName("test-service") + .otlpEndpoint("https://otel.example.test:4318") + .backendUrl("https://backend.example.test") + .build(); + } + + private static SamplingConfig sampleConfig() { + return new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofValue("health-check"), + Collections.emptyList(), + Collections.emptyList(), + 1 + )), + Collections.emptyList() + ); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginOptionsFlowTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginOptionsFlowTest.java new file mode 100644 index 0000000000..278df195ab --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginOptionsFlowTest.java @@ -0,0 +1,260 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.observability.LDObserve; +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.ObservabilityPlugin; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.zip.GZIPInputStream; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises the server-oriented options (headers, proxy, resource + * attributes, log level, enabled) through a real LDClient against a local + * collector that records every request it receives. + */ +class PluginOptionsFlowTest { + + private static final String SAMPLING_RESPONSE = "{\"data\":{\"sampling\":{\"spans\":[],\"logs\":[]}}}"; + + /** One recorded HTTP request: its request-line URI, a header of interest and the body. */ + private static final class Received { + final String uri; + final String path; + final String testHeader; + final String body; + + Received(String uri, String path, String testHeader, String body) { + this.uri = uri; + this.path = path; + this.testHeader = testHeader; + this.body = body; + } + } + + private HttpServer server; + private final List received = new CopyOnWriteArrayList<>(); + + @BeforeEach + void startCollector() throws IOException { + OtelManager.resetForTest(); + GlobalOpenTelemetry.resetForTest(); + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + // One root context so the server also works as an HTTP proxy, where the + // request line carries an absolute URI. + server.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + String header = exchange.getRequestHeaders().getFirst("X-Test-Header"); + received.add(new Received(exchange.getRequestURI().toString(), path, + header == null ? "" : header, bodyAsString(exchange))); + if (path.endsWith("/sampling")) { + respond(exchange, "application/json", SAMPLING_RESPONSE.getBytes(StandardCharsets.UTF_8)); + } else { + respond(exchange, "application/x-protobuf", new byte[0]); + } + }); + server.start(); + } + + @AfterEach + void tearDown() { + OtelManager.resetForTest(); + GlobalOpenTelemetry.resetForTest(); + server.stop(0); + } + + @Test + void customHeadersAndResourceAttributesReachExportsAndSamplingRequest() throws Exception { + ObservabilityOptions options = builder() + .customHeader("X-Test-Header", "abc") + .resourceAttributes(Attributes.of(AttributeKey.stringKey("team"), "payments-team")) + .build(); + + try (LDClient client = newClient(options)) { + assertTrue(client.isInitialized()); + assertTrue(waitFor("/sampling"), "sampling config was not requested"); + LDObserve.startSpan("checkout", Attributes.empty()).end(); + LDObserve.recordLog("checkout done", Severity.INFO, Attributes.empty()); + LDObserve.recordCount("orders", 1, Attributes.empty()); + LDObserve.flush(); + } + + assertTrue(waitFor("/v1/traces"), "no trace export"); + assertTrue(waitFor("/v1/logs"), "no log export"); + assertTrue(waitFor("/v1/metrics"), "no metric export"); + for (String path : List.of("/sampling", "/v1/traces", "/v1/logs", "/v1/metrics")) { + assertTrue(all(path).stream().allMatch(r -> "abc".equals(r.testHeader)), + "custom header missing on " + path); + } + assertTrue(anyBodyContains("/v1/traces", "payments-team"), "resource attribute missing on spans"); + assertTrue(anyBodyContains("/v1/logs", "payments-team"), "resource attribute missing on logs"); + assertTrue(anyBodyContains("/v1/metrics", "payments-team"), "resource attribute missing on metrics"); + } + + @Test + void proxySelectorRoutesExportsAndSamplingRequestThroughProxy() throws Exception { + InetSocketAddress proxy = server.getAddress(); + ObservabilityOptions options = new ObservabilityOptions.Builder() + .serviceName("orders-service") + .otlpEndpoint("http".concat("://otlp.invalid:4318")) + .backendUrl("http".concat("://backend.invalid/sampling")) + .proxyHostAndPort(proxy.getHostString(), proxy.getPort()) + .build(); + + try (LDClient client = newClient(options)) { + assertTrue(client.isInitialized()); + assertTrue(waitFor("/sampling"), "sampling request did not go through the proxy"); + LDObserve.startSpan("checkout", Attributes.empty()).end(); + LDObserve.flush(); + } + + assertTrue(waitFor("/v1/traces"), "trace export did not go through the proxy"); + assertTrue(all("/sampling").stream().allMatch(r -> r.uri.contains("backend.invalid")), + "proxy did not see the absolute sampling URI"); + assertTrue(all("/v1/traces").stream().allMatch(r -> r.uri.contains("otlp.invalid")), + "proxy did not see the absolute OTLP URI"); + } + + @Test + void logsBelowConfiguredLevelAreNotExported() throws Exception { + ObservabilityOptions options = builder().logsApiLevel(ObservabilityOptions.LogLevel.WARN).build(); + + try (LDClient client = newClient(options)) { + assertTrue(client.isInitialized()); + LDObserve.recordLog("debug-noise", Severity.DEBUG, Attributes.empty()); + LDObserve.recordLog("info-noise", Severity.INFO, Attributes.empty()); + LDObserve.recordLog("warn-signal", Severity.WARN, Attributes.empty()); + LDObserve.recordLog("error-signal", Severity.ERROR, Attributes.empty()); + LDObserve.flush(); + } + + assertTrue(waitFor("/v1/logs"), "no log export"); + assertTrue(anyBodyContains("/v1/logs", "warn-signal")); + assertTrue(anyBodyContains("/v1/logs", "error-signal")); + assertTrue(anyBodyContains("/v1/logs", "WARN"), "severity text missing on exported log"); + assertTrue(anyBodyContains("/v1/logs", "ERROR"), "severity text missing on exported log"); + assertFalse(anyBodyContains("/v1/logs", "debug-noise"), "DEBUG record exported below WARN threshold"); + assertFalse(anyBodyContains("/v1/logs", "info-noise"), "INFO record exported below WARN threshold"); + } + + @Test + void logLevelNoneExportsNoLogsButKeepsTraces() throws Exception { + ObservabilityOptions options = builder().logsApiLevel(ObservabilityOptions.LogLevel.NONE).build(); + + try (LDClient client = newClient(options)) { + assertTrue(client.isInitialized()); + LDObserve.recordLog("fatal-but-disabled", Severity.FATAL, Attributes.empty()); + LDObserve.startSpan("checkout", Attributes.empty()).end(); + LDObserve.flush(); + } + + assertTrue(waitFor("/v1/traces"), "no trace export"); + assertFalse(anyBodyContains("/v1/logs", "fatal-but-disabled"), "log exported with level NONE"); + } + + @Test + void disabledPluginStartsNothingAndStaysNoop() throws Exception { + ObservabilityOptions options = builder().enabled(false).build(); + + try (LDClient client = newClient(options)) { + assertTrue(client.isInitialized()); + assertFalse(OtelManager.isInitialized(), "disabled plugin started providers"); + client.boolVariation("checkout-enabled", com.launchdarkly.sdk.LDContext.create("u"), false); + LDObserve.startSpan("checkout", Attributes.empty()).end(); + LDObserve.recordLog("ignored", Severity.ERROR, Attributes.empty()); + LDObserve.flush(); + Thread.sleep(500); + } + + assertTrue(received.isEmpty(), "disabled plugin made HTTP requests: " + summarize()); + assertFalse(OtelManager.isInitialized()); + } + + private ObservabilityOptions.Builder builder() { + String base = "http".concat("://") + server.getAddress().getHostString() + ":" + server.getAddress().getPort(); + return new ObservabilityOptions.Builder() + .serviceName("orders-service") + .serviceVersion("1.2.3") + .environment("test") + .otlpEndpoint(base) + .backendUrl(base + "/sampling"); + } + + private static LDClient newClient(ObservabilityOptions options) { + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options)))) + .build(); + return new LDClient("sdk-test", config); + } + + private List all(String path) { + List out = new ArrayList<>(); + for (Received r : received) { + if (r.path.endsWith(path)) { + out.add(r); + } + } + return out; + } + + private boolean anyBodyContains(String path, String needle) { + return all(path).stream().anyMatch(r -> r.body.contains(needle)); + } + + private boolean waitFor(String path) throws InterruptedException { + for (int i = 0; i < 100 && all(path).isEmpty(); i++) { + Thread.sleep(100); + } + return !all(path).isEmpty(); + } + + private String summarize() { + StringBuilder sb = new StringBuilder(); + for (Received r : received) { + sb.append(r.uri).append(' '); + } + return sb.toString(); + } + + private static String bodyAsString(HttpExchange exchange) throws IOException { + byte[] raw = exchange.getRequestBody().readAllBytes(); + String encoding = exchange.getRequestHeaders().getFirst("Content-Encoding"); + if (encoding != null && encoding.toLowerCase().contains("gzip")) { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(raw))) { + raw = in.readAllBytes(); + } + } + return new String(raw, StandardCharsets.UTF_8); + } + + private static void respond(HttpExchange exchange, String contentType, byte[] body) throws IOException { + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginStartupTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginStartupTest.java new file mode 100644 index 0000000000..42b3eea7b0 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginStartupTest.java @@ -0,0 +1,163 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.observability.LDObserve; +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.ObservabilityPlugin; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.Span; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * How the plugin starts, fails to start and coexists with other clients in + * one process. + */ +class PluginStartupTest { + + private final List records = new CopyOnWriteArrayList<>(); + private final Handler handler = new Handler() { + @Override public void publish(LogRecord record) { records.add(record); } + @Override public void flush() {} + @Override public void close() {} + }; + private final Logger pluginLogger = Logger.getLogger("com.launchdarkly.observability"); + + @BeforeEach + void captureLogs() { + OtelManager.resetForTest(); + GlobalOpenTelemetry.resetForTest(); + pluginLogger.addHandler(handler); + } + + @AfterEach + void reset() { + pluginLogger.removeHandler(handler); + OtelManager.resetForTest(); + GlobalOpenTelemetry.resetForTest(); + } + + @Test + void badOptionIsLoggedAndDoesNotStopTheClient() { + ObservabilityOptions options = new ObservabilityOptions.Builder() + .otlpEndpoint("otel.example.test:4318") + .build(); + + LDClient client = assertDoesNotThrow(() -> client("sdk-bad-endpoint", options)); + try { + assertFalse(OtelManager.isInitialized()); + assertFalse(client.boolVariation("flag", LDContext.create("user"), false)); + assertDoesNotThrow(() -> LDObserve.recordLog("ignored", Severity.INFO, Attributes.empty())); + } finally { + assertDoesNotThrow(client::close); + } + + LogRecord severe = records.stream() + .filter(r -> r.getLevel() == Level.SEVERE) + .findFirst() + .orElseThrow(() -> new AssertionError("expected a SEVERE log explaining why the plugin is off")); + assertTrue(severe.getMessage().contains("could not start"), severe.getMessage()); + assertTrue(severe.getMessage().contains("otel.example.test:4318"), + "the log must carry the cause so the user can fix it: " + severe.getMessage()); + assertFalse(severe.getMessage().contains("needs 1.38"), + "a configuration error is not a version problem: " + severe.getMessage()); + } + + @Test + void openTelemetryViewFollowsThePluginLifecycle() throws Exception { + OpenTelemetry otel = LDObserve.getOpenTelemetry(); + assertFalse(otel.getTracer("before").spanBuilder("s").startSpan().isRecording(), + "before the plugin starts the view hands out no-ops"); + + // Someone else owns the global, as with the Java agent. + GlobalOpenTelemetry.set(OpenTelemetry.noop()); + LDClient client = client("sdk-view", new ObservabilityOptions.Builder() + .otlpEndpoint("http://127.0.0.1:9") + .build()); + try { + assertSame(otel, LDObserve.getOpenTelemetry()); + Span span = otel.getTracer("app").spanBuilder("request").startSpan(); + assertTrue(span.isRecording(), "the view must reach the plugin's providers, not the global"); + span.end(); + assertFalse(otel instanceof AutoCloseable, + "frameworks infer close() as a destroy method; the view must not expose one"); + } finally { + client.close(); + LDObserve.shutdown(); + } + assertFalse(otel.getTracer("after").spanBuilder("s").startSpan().isRecording()); + } + + @Test + void unreachableSamplingBackendLogsOneLine() { + SamplingApiService service = new SamplingApiService(new GraphQLClient("http://127.0.0.1:9")); + + assertNull(service.getSamplingConfig("sdk-offline")); + + // Sampling threads from earlier tests may log here too; every + // warning from the service must have the same one-line shape. + List warnings = records.stream() + .filter(r -> r.getLevel() == Level.WARNING) + .filter(r -> SamplingApiService.class.getName().equals(r.getLoggerName())) + .collect(java.util.stream.Collectors.toList()); + assertFalse(warnings.isEmpty(), "a failed fetch must be reported"); + for (LogRecord warning : warnings) { + assertNull(warning.getThrown(), "the stack trace belongs at FINE, not in every server's startup log"); + assertTrue(warning.getMessage().contains("unsampled"), warning.getMessage()); + } + } + + @Test + void startupSaysWhereTelemetryGoesAndWarnsWithoutAServiceName() throws Exception { + try (LDClient client = client("sdk-unnamed", new ObservabilityOptions.Builder() + .otlpEndpoint("http://127.0.0.1:9").backendUrl("http://127.0.0.1:9").build())) { + assertTrue(records.stream().anyMatch(r -> r.getLevel() == Level.INFO + && r.getMessage().contains("exporting to http://127.0.0.1:9 as service 'unknown_service:java'"))); + assertTrue(records.stream().anyMatch(r -> r.getLevel() == Level.WARNING + && r.getMessage().contains("serviceName"))); + } + } + + @Test + void namedServiceStartsWithoutAWarning() throws Exception { + try (LDClient client = client("sdk-named", new ObservabilityOptions.Builder() + .serviceName("checkout").otlpEndpoint("http://127.0.0.1:9").backendUrl("http://127.0.0.1:9").build())) { + assertTrue(records.stream().anyMatch(r -> r.getMessage().contains("as service 'checkout'"))); + assertFalse(records.stream().anyMatch(r -> r.getLevel() == Level.WARNING + && r.getMessage().contains("serviceName"))); + } + } + + @Test + void sdkVersionReportsTheClasspathVersion() { + assertTrue(OtelManager.sdkVersion().matches("\\d+\\.\\d+\\.\\d+.*"), OtelManager.sdkVersion()); + } + + private static LDClient client(String sdkKey, ObservabilityOptions options) { + LDConfig config = new LDConfig.Builder() + .offline(true) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options)))) + .build(); + return new LDClient(sdkKey, config); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java new file mode 100644 index 0000000000..7c01717ac8 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java @@ -0,0 +1,506 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.observability.LDObserve; +import com.launchdarkly.observability.ObservabilityOptions; +import com.launchdarkly.observability.ObservabilityPlugin; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.zip.GZIPInputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises the plugin the way a server app does: an LDClient with the + * observability plugin, a local sampling endpoint, and a local OTLP collector. + */ +class PluginUserFlowTest { + + private static final String SAMPLING_RESPONSE = + "{\"data\":{\"sampling\":{\"spans\":[{\"name\":{\"matchValue\":\"health-check\"}," + + "\"attributes\":[],\"events\":[],\"samplingRatio\":0}],\"logs\":[]}}}"; + + private HttpServer server; + private final List traces = new CopyOnWriteArrayList<>(); + private final List rawTraces = new CopyOnWriteArrayList<>(); + private final List logs = new CopyOnWriteArrayList<>(); + private final List metrics = new CopyOnWriteArrayList<>(); + private final List samplingQueries = new CopyOnWriteArrayList<>(); + + @AfterEach + void tearDown() { + OtelManager.resetForTest(); + GlobalOpenTelemetry.resetForTest(); + if (server != null) { + server.stop(0); + } + } + + @Test + void pluginExportsFlagEvaluationAndHonorsSamplingRules() throws Exception { + startCollector(); + ObservabilityOptions options = options(false); + + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options)))) + .build(); + + try (LDClient client = new LDClient("sdk-test", config)) { + assertTrue(client.isInitialized()); + assertTrue(waitForSamplingQuery(), "plugin did not fetch sampling config"); + assertTrue(waitForSamplingApplied(), "sampling config was not applied"); + + client.boolVariation("checkout-enabled", LDContext.create("user-key-test"), false); + LDObserve.recordLog("user signed in", Severity.INFO, Attributes.empty()); + LDObserve.recordCount("orders.placed", 1, Attributes.empty()); + LDObserve.startSpan("user-checkout", Attributes.empty()).end(); + LDObserve.flush(); + + assertFalse(traces.isEmpty(), "no OTLP traces were received"); + assertTrue(payloadContains(traces, "checkout-enabled"), + "flag evaluation was not exported: " + summarize(traces)); + assertTrue(payloadContains(traces, "user-checkout"), "manual span was not exported"); + assertTrue(payloadContains(logs, "user signed in"), "log was not exported"); + assertTrue(payloadContains(metrics, "orders.placed"), "metric was not exported"); + + assertTrue(waitUntilHealthCheckDropped(), "sampling rule did not drop health-check spans"); + LDObserve.startSpan("user-checkout", Attributes.empty()).end(); + LDObserve.flush(); + assertTrue(payloadContains(traces, "user-checkout"), "unmatched spans should still export"); + + // The exports above made the OTLP exporters count their own + // work; those counters must not reach the customer's metrics. + LDObserve.flush(); + assertFalse(payloadContains(metrics, "otlp.exporter"), + "exporter self-metrics were exported: " + summarize(metrics)); + } + } + + @Test + void secondClientWithAnotherSdkKeySharesTheFirstClientsProviders() throws Exception { + startCollector(); + LDConfig first = new LDConfig.Builder() + .offline(true) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options(false))))) + .build(); + LDConfig second = new LDConfig.Builder() + .offline(true) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options(false))))) + .build(); + + try (LDClient a = new LDClient("sdk-test", first)) { + assertTrue(waitForSamplingQuery(), "first client did not fetch sampling config"); + try (LDClient b = new LDClient("sdk-other", second)) { + b.boolVariation("second-client-flag", LDContext.create("user-b"), false); + LDObserve.flush(); + assertTrue(payloadContains(traces, "second-client-flag"), + "the second client's evaluations should still export on the shared providers"); + assertTrue(payloadContains(traces, "sdk-test"), "exports should carry the first SDK key"); + assertFalse(payloadContains(traces, "sdk-other"), "the second SDK key must not leak into exports"); + Thread.sleep(300); + assertEquals(1, samplingQueries.size(), + "the second client must not replace the first client's sampling config"); + } + } + } + + @Test + void manualStartKeepsSamplingConfigFetchedBeforeProvidersStart() throws Exception { + startCollector(); + ObservabilityOptions options = options(true); + + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options)))) + .build(); + + try (LDClient client = new LDClient("sdk-test", config)) { + assertTrue(client.isInitialized()); + assertFalse(OtelManager.isInitialized()); + assertTrue(waitForSamplingQuery(), "plugin did not fetch sampling config"); + LDObserve.start("sdk-test", options); + assertTrue(waitForSamplingApplied(), "manual start lost the sampling config"); + assertTrue(waitUntilHealthCheckDropped(), "manual start did not apply the sampling rule"); + } + assertEquals(1, samplingQueries.size(), "manual start must not fetch the sampling config again"); + } + + @Test + void standaloneStartFetchesAndAppliesSamplingConfig() throws Exception { + startCollector(); + + LDObserve.start("sdk-test", options(true)); + + assertTrue(waitForSamplingQuery(), "standalone start did not fetch sampling config"); + assertTrue(waitForSamplingApplied(), "standalone start did not apply the sampling config"); + assertTrue(waitUntilHealthCheckDropped(), "standalone start did not apply the sampling rule"); + } + + @Test + void preInitCallDoesNotStickGlobalOnNoopAndLaterSpanExports() throws Exception { + OtelManager.resetForTest(); + GlobalOpenTelemetry.resetForTest(); + LDObserve.startSpan("before-init", Attributes.empty()).end(); + LDObserve.recordLog("before init", Severity.INFO, Attributes.empty()); + + startCollector(); + ObservabilityOptions options = options(false); + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options)))) + .build(); + + try (LDClient client = new LDClient("sdk-test", config)) { + assertNotSame(OpenTelemetry.noop(), GlobalOpenTelemetry.get()); + client.boolVariation("checkout-enabled", LDContext.create("user-key-test"), false); + LDObserve.startSpan("after-init", Attributes.empty()).end(); + LDObserve.flush(); + assertTrue(payloadContains(traces, "after-init"), + "span started after a pre-init call was not exported: " + summarize(traces)); + assertTrue(payloadContains(traces, "checkout-enabled"), + "flag evaluation was not exported after a pre-init call: " + summarize(traces)); + } + } + + @Test + void flagEvaluationExportsWhenGlobalWasClaimedFirst() throws Exception { + // A library that calls GlobalOpenTelemetry.get() before the LDClient is + // built locks the global onto a no-op, so the plugin cannot register. + GlobalOpenTelemetry.get(); + + startCollector(); + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options(false))))) + .build(); + + try (LDClient client = new LDClient("sdk-test", config)) { + client.boolVariation("checkout-enabled", LDContext.create("user-key-test"), false); + LDObserve.flush(); + assertTrue(payloadContains(traces, "checkout-enabled"), + "flag evaluation was not exported with a pre-claimed global: " + summarize(traces)); + assertTrue(payloadContains(traces, "feature_flag.key")); + } + } + + @Test + void recordErrorInsidePluginSpanAddsExceptionToThatSpan() throws Exception { + startCollector(); + LDObserve.start("sdk-test", options(true)); + + Span request = LDObserve.startSpan("GET /checkout", Attributes.empty()); + try (Scope ignored = request.makeCurrent()) { + LDObserve.recordError(new IllegalStateException("payment declined"), + Attributes.of(AttributeKey.stringKey("order.id"), "order-42")); + } finally { + request.end(); + } + LDObserve.flush(); + + assertTrue(payloadContains(traces, "GET /checkout"), "request span was not exported: " + summarize(traces)); + assertTrue(payloadContains(traces, "payment declined"), "exception was not recorded: " + summarize(traces)); + assertTrue(payloadContains(traces, "order-42"), "attributes were not recorded on the exception event"); + assertFalse(payloadContains(traces, Constants.ERROR_SPAN_NAME), + "recordError started a separate error span instead of using the current span"); + } + + @Test + void recordErrorInsideForeignSpanExportsErrorSpanInSameTrace() throws Exception { + // The application (or the OpenTelemetry Java agent) owns the current + // span; an exception recorded on it would never reach LaunchDarkly. + InMemorySpanExporter appExporter = InMemorySpanExporter.create(); + SdkTracerProvider appTracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(appExporter)) + .build(); + OpenTelemetrySdk appSdk = OpenTelemetrySdk.builder() + .setTracerProvider(appTracerProvider) + .buildAndRegisterGlobal(); + + startCollector(); + LDObserve.start("sdk-test", options(true)); + + try { + Span request = appSdk.getTracer("app").spanBuilder("GET /checkout").startSpan(); + try (Scope ignored = request.makeCurrent()) { + LDObserve.recordError(new IllegalStateException("payment declined"), Attributes.empty()); + } finally { + request.end(); + } + LDObserve.flush(); + + assertTrue(payloadContains(traces, Constants.ERROR_SPAN_NAME), + "error span was not exported to LaunchDarkly: " + summarize(traces)); + assertTrue(payloadContains(traces, "payment declined")); + assertTrue(rawPayloadContains(rawTraces, request.getSpanContext().getTraceIdBytes()), + "error span is not in the application's trace"); + assertTrue(appExporter.getFinishedSpanItems().get(0).getEvents().isEmpty(), + "the exception went to the application's span instead of LaunchDarkly"); + } finally { + appTracerProvider.shutdown(); + } + } + + @Test + void flagEvaluationExportsUnderApplicationOwnedGlobal() throws Exception { + // The application (or the OpenTelemetry Java agent) owns the global. + InMemorySpanExporter appExporter = InMemorySpanExporter.create(); + SdkTracerProvider appTracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(appExporter)) + .build(); + OpenTelemetrySdk appSdk = OpenTelemetrySdk.builder() + .setTracerProvider(appTracerProvider) + .buildAndRegisterGlobal(); + + startCollector(); + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options(false))))) + .build(); + + try (LDClient client = new LDClient("sdk-test", config)) { + Span request = appSdk.getTracer("app").spanBuilder("GET /checkout").startSpan(); + try (Scope ignored = request.makeCurrent()) { + client.boolVariation("checkout-enabled", LDContext.create("user-key-test"), false); + } finally { + request.end(); + } + LDObserve.flush(); + + assertTrue(payloadContains(traces, "checkout-enabled"), + "flag evaluation was not exported under an application-owned global: " + summarize(traces)); + assertTrue(rawPayloadContains(rawTraces, request.getSpanContext().getTraceIdBytes()), + "evaluation span is not in the application's trace"); + + // The feature_flag event still lands on the caller's current span. + List appSpans = appExporter.getFinishedSpanItems(); + assertEquals(1, appSpans.size()); + assertEquals(1, appSpans.get(0).getEvents().size()); + assertEquals("feature_flag", appSpans.get(0).getEvents().get(0).getName()); + assertEquals("checkout-enabled", appSpans.get(0).getEvents().get(0).getAttributes() + .get(AttributeKey.stringKey("feature_flag.key"))); + } finally { + appTracerProvider.shutdown(); + } + } + + @Test + void flagEvaluationExportsUnderUnsampledForeignParent() throws Exception { + // The application's SDK (or the agent) samples this request out. + SdkTracerProvider appTracerProvider = SdkTracerProvider.builder() + .setSampler(Sampler.alwaysOff()) + .build(); + OpenTelemetrySdk appSdk = OpenTelemetrySdk.builder() + .setTracerProvider(appTracerProvider) + .buildAndRegisterGlobal(); + + startCollector(); + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options(false))))) + .build(); + + try (LDClient client = new LDClient("sdk-test", config)) { + Span request = appSdk.getTracer("app").spanBuilder("GET /checkout").startSpan(); + assertFalse(request.getSpanContext().isSampled()); + try (Scope ignored = request.makeCurrent()) { + client.boolVariation("checkout-enabled", LDContext.create("user-key-test"), false); + LDObserve.startSpan("user-checkout", Attributes.empty()).end(); + } finally { + request.end(); + } + LDObserve.flush(); + + assertTrue(payloadContains(traces, "checkout-enabled"), + "flag evaluation under an unsampled parent was dropped: " + summarize(traces)); + assertTrue(payloadContains(traces, "user-checkout"), + "LDObserve span under an unsampled parent was dropped"); + } finally { + appTracerProvider.shutdown(); + } + } + + @Test + void flagEvaluationExportsAfterManualStart() throws Exception { + startCollector(); + ObservabilityOptions options = options(true); + LDConfig config = new LDConfig.Builder() + .offline(true) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options)))) + .build(); + + try (LDClient client = new LDClient("sdk-test", config)) { + LDObserve.start("sdk-test", options); + client.boolVariation("checkout-enabled", LDContext.create("user-key-test"), false); + LDObserve.flush(); + assertTrue(payloadContains(traces, "checkout-enabled"), + "flag evaluation was not exported after manual start: " + summarize(traces)); + } + } + + private ObservabilityOptions options(boolean manualStart) { + String base = "http".concat("://") + server.getAddress().getHostString() + ":" + server.getAddress().getPort(); + return new ObservabilityOptions.Builder() + .serviceName("orders-service") + .serviceVersion("1.2.3") + .environment("test") + .otlpEndpoint(base) + .backendUrl(base + "/sampling") + .manualStart(manualStart) + .build(); + } + + private void startCollector() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/sampling", exchange -> { + samplingQueries.add(bodyAsString(exchange)); + respond(exchange, 200, "application/json", SAMPLING_RESPONSE.getBytes(StandardCharsets.UTF_8)); + }); + server.createContext("/v1/traces", exchange -> { + byte[] body = bodyBytes(exchange); + rawTraces.add(body); + traces.add(new String(body, StandardCharsets.UTF_8)); + respond(exchange, 200, "application/x-protobuf", new byte[0]); + }); + server.createContext("/v1/logs", exchange -> { + logs.add(bodyAsString(exchange)); + respond(exchange, 200, "application/x-protobuf", new byte[0]); + }); + server.createContext("/v1/metrics", exchange -> { + metrics.add(bodyAsString(exchange)); + respond(exchange, 200, "application/x-protobuf", new byte[0]); + }); + server.start(); + } + + private boolean waitForSamplingQuery() throws InterruptedException { + for (int i = 0; i < 50 && samplingQueries.isEmpty(); i++) { + Thread.sleep(100); + } + return !samplingQueries.isEmpty(); + } + + private boolean waitForSamplingApplied() throws InterruptedException { + for (int i = 0; i < 50 && !OtelManager.isSamplingConfigured(); i++) { + Thread.sleep(100); + } + return OtelManager.isSamplingConfigured(); + } + + private boolean waitUntilHealthCheckDropped() throws InterruptedException { + for (int i = 0; i < 50; i++) { + int before = traces.size(); + LDObserve.startSpan("health-check", Attributes.empty()).end(); + LDObserve.startSpan("keep-alive", Attributes.empty()).end(); + LDObserve.flush(); + if (newPayloadsContain(traces, before, "keep-alive") + && !newPayloadsContain(traces, before, "health-check")) { + return true; + } + Thread.sleep(100); + } + return false; + } + + private static String summarize(List payloads) { + StringBuilder text = new StringBuilder(); + for (String payload : payloads) { + for (int i = 0; i < payload.length(); i++) { + char c = payload.charAt(i); + text.append(c >= 32 && c < 127 ? c : ' '); + } + text.append('\n'); + } + String rendered = text.toString(); + return rendered.length() > 1500 ? rendered.substring(0, 1500) : rendered; + } + + private static boolean payloadContains(List payloads, String needle) { + return newPayloadsContain(payloads, 0, needle); + } + + private static boolean newPayloadsContain(List payloads, int from, String needle) { + List snapshot = new ArrayList<>(payloads); + for (int i = from; i < snapshot.size(); i++) { + if (snapshot.get(i).contains(needle)) { + return true; + } + } + return false; + } + + private static boolean rawPayloadContains(List payloads, byte[] needle) { + for (byte[] payload : payloads) { + outer: + for (int i = 0; i + needle.length <= payload.length; i++) { + for (int j = 0; j < needle.length; j++) { + if (payload[i + j] != needle[j]) { + continue outer; + } + } + return true; + } + } + return false; + } + + private static String bodyAsString(HttpExchange exchange) throws IOException { + return new String(bodyBytes(exchange), StandardCharsets.UTF_8); + } + + private static byte[] bodyBytes(HttpExchange exchange) throws IOException { + byte[] raw = exchange.getRequestBody().readAllBytes(); + String encoding = exchange.getRequestHeaders().getFirst("Content-Encoding"); + if (encoding != null && encoding.toLowerCase().contains("gzip")) { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(raw))) { + raw = in.readAllBytes(); + } + } + return raw; + } + + private static void respond(HttpExchange exchange, int status, String contentType, byte[] body) throws IOException { + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/SamplingConfigParsingTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/SamplingConfigParsingTest.java new file mode 100644 index 0000000000..22d32f405d --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/SamplingConfigParsingTest.java @@ -0,0 +1,59 @@ +package com.launchdarkly.observability.internal; + +import com.launchdarkly.observability.internal.sampling.SamplingConfig; +import com.launchdarkly.observability.internal.sampling.SamplingConfig.MatchConfig; +import com.launchdarkly.sdk.LDValue; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SamplingConfigParsingTest { + + @Test + void matchValuesKeepTheirJsonType() { + SamplingConfig config = parse("{\"spans\":[{\"attributes\":[" + + "{\"key\":{\"matchValue\":\"status\"},\"attribute\":{\"matchValue\":500}}," + + "{\"key\":{\"matchValue\":\"retry\"},\"attribute\":{\"matchValue\":true}}," + + "{\"key\":{\"matchValue\":\"route\"},\"attribute\":{\"matchValue\":\"/health\"}}" + + "],\"samplingRatio\":10}]}"); + + SamplingConfig.SpanSamplingConfig rule = config.getSpans().get(0); + assertEquals(500.0, rule.getAttributes().get(0).getAttribute().getValue()); + assertEquals(true, rule.getAttributes().get(1).getAttribute().getValue()); + assertEquals("/health", rule.getAttributes().get(2).getAttribute().getValue()); + assertTrue(rule.getAttributes().get(0).getAttribute().matches(500L)); + } + + @Test + void unsupportedMatchValueNeverMatchesAndKeepsTheRestOfThePayload() { + SamplingConfig config = parse("{\"spans\":[" + + "{\"name\":{\"matchValue\":[\"a\",\"b\"]},\"samplingRatio\":0}," + + "{\"name\":{\"matchValue\":\"health\"},\"samplingRatio\":0}" + + "],\"logs\":[{\"message\":{\"matchValue\":{\"nested\":1}},\"samplingRatio\":0}]}"); + + assertEquals(2, config.getSpans().size()); + assertFalse(config.getSpans().get(0).getName().matches("a")); + assertTrue(config.getSpans().get(1).getName().matches("health")); + assertEquals(1, config.getLogs().size()); + } + + @Test + void exactValueWinsOverRegexAndEmptyRegexIsNoFilter() { + SamplingConfig config = parse("{\"spans\":[" + + "{\"name\":{\"matchValue\":\"exact\",\"regexValue\":\".*\"},\"samplingRatio\":0}," + + "{\"name\":{\"matchValue\":null,\"regexValue\":\"\"},\"samplingRatio\":0}" + + "]}"); + + MatchConfig both = config.getSpans().get(0).getName(); + assertFalse(both.isRegex()); + assertFalse(both.matches("other")); + assertNull(config.getSpans().get(1).getName(), "an empty regex is treated as unset"); + } + + private static SamplingConfig parse(String sampling) { + return SamplingApiService.parseSamplingConfig(LDValue.parse(sampling)); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/SamplingScenariosTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/SamplingScenariosTest.java new file mode 100644 index 0000000000..125af6e371 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/SamplingScenariosTest.java @@ -0,0 +1,191 @@ +package com.launchdarkly.observability.internal; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.launchdarkly.observability.internal.sampling.CustomSampler; +import com.launchdarkly.observability.internal.sampling.SamplingConfig; +import com.launchdarkly.sdk.LDValue; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.common.Value; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanId; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceId; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.testing.logs.TestLogRecordData; +import io.opentelemetry.sdk.testing.trace.TestSpanData; +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Runs the sampling scenarios shared with the Go, .NET, Python and Node SDKs. + * + *

The fixtures in {@code src/test/resources/sampling} are copies of + * {@code go/internal/otel/*-test-scenarios.json}. Each scenario's + * {@code samplingConfig} goes through the same parser as the backend + * response, so parsing and matching are both covered.

+ */ +class SamplingScenariosTest { + + @TestFactory + List spanScenarios() throws IOException { + List tests = new ArrayList<>(); + for (JsonElement el : loadScenarios("sampling/span-test-scenarios.json")) { + JsonObject scenario = el.getAsJsonObject(); + SamplingConfig config = SamplingApiService.parseSamplingConfig(LDValue.parse(scenario.get("samplingConfig").toString())); + SpanData span = span(scenario.getAsJsonObject("inputSpan")); + for (JsonElement c : scenario.getAsJsonArray("samplerFunctionCases")) { + JsonObject samplerCase = c.getAsJsonObject(); + tests.add(DynamicTest.dynamicTest( + scenario.get("description").getAsString() + " [" + samplerCase.get("type").getAsString() + "]", + () -> { + CustomSampler sampler = sampler(samplerCase); + sampler.setConfig(config); + assertResult(samplerCase.getAsJsonObject("expected_result"), sampler.sampleSpan(span)); + })); + } + } + assertTrue(tests.size() > 0, "no span scenarios loaded"); + return tests; + } + + @TestFactory + List logScenarios() throws IOException { + List tests = new ArrayList<>(); + for (JsonElement el : loadScenarios("sampling/log-test-scenarios.json")) { + JsonObject scenario = el.getAsJsonObject(); + SamplingConfig config = SamplingApiService.parseSamplingConfig(LDValue.parse(scenario.get("samplingConfig").toString())); + LogRecordData log = log(scenario.getAsJsonObject("inputLog")); + for (JsonElement c : scenario.getAsJsonArray("samplerFunctionCases")) { + JsonObject samplerCase = c.getAsJsonObject(); + tests.add(DynamicTest.dynamicTest( + scenario.get("description").getAsString() + " [" + samplerCase.get("type").getAsString() + "]", + () -> { + CustomSampler sampler = sampler(samplerCase); + sampler.setConfig(config); + assertResult(samplerCase.getAsJsonObject("expected_result"), sampler.sampleLog(log)); + })); + } + } + assertTrue(tests.size() > 0, "no log scenarios loaded"); + return tests; + } + + private static CustomSampler sampler(JsonObject samplerCase) { + String type = samplerCase.get("type").getAsString(); + switch (type) { + case "always": + return new CustomSampler(ratio -> true); + case "never": + return new CustomSampler(ratio -> false); + default: + throw new IllegalArgumentException("unknown sampler type " + type); + } + } + + private static void assertResult(JsonObject expected, CustomSampler.SamplingResult actual) { + assertEquals(expected.get("sample").getAsBoolean(), actual.isSampled(), "sample"); + JsonElement expectedAttributes = expected.get("attributes"); + if (expectedAttributes == null || expectedAttributes.isJsonNull()) { + assertNull(actual.getAttributes(), "attributes"); + return; + } + assertNotNull(actual.getAttributes(), "attributes"); + assertEquals(attributes(expectedAttributes.getAsJsonObject()), actual.getAttributes(), "attributes"); + } + + private static SpanData span(JsonObject input) { + List events = new ArrayList<>(); + JsonElement eventsJson = input.get("events"); + if (eventsJson != null && eventsJson.isJsonArray()) { + for (JsonElement e : eventsJson.getAsJsonArray()) { + JsonObject event = e.getAsJsonObject(); + events.add(EventData.create(0, event.get("name").getAsString(), attributes(event.getAsJsonObject("attributes")))); + } + } + return TestSpanData.builder() + .setName(input.get("name").getAsString()) + .setKind(SpanKind.INTERNAL) + .setSpanContext(SpanContext.create( + TraceId.fromLongs(1, 1), SpanId.fromLong(1), + TraceFlags.getSampled(), TraceState.getDefault())) + .setStatus(StatusData.unset()) + .setHasEnded(true) + .setStartEpochNanos(0) + .setEndEpochNanos(1) + .setAttributes(attributes(input.getAsJsonObject("attributes"))) + .setEvents(events) + .setTotalRecordedEvents(events.size()) + .build(); + } + + private static LogRecordData log(JsonObject input) { + TestLogRecordData.Builder builder = TestLogRecordData.builder() + .setAttributes(attributes(input.getAsJsonObject("attributes"))); + JsonElement message = input.get("message"); + if (message != null && !message.isJsonNull()) { + builder.setBodyValue(Value.of(message.getAsString())); + } + JsonElement severityText = input.get("severityText"); + if (severityText != null && !severityText.isJsonNull()) { + builder.setSeverityText(severityText.getAsString()); + } + return builder.build(); + } + + /** JSON strings, integers, decimals and booleans become the matching OTel attribute types. */ + private static Attributes attributes(JsonObject json) { + if (json == null) { + return Attributes.empty(); + } + AttributesBuilder builder = Attributes.builder(); + for (Map.Entry entry : json.entrySet()) { + JsonPrimitive value = entry.getValue().getAsJsonPrimitive(); + if (value.isBoolean()) { + builder.put(AttributeKey.booleanKey(entry.getKey()), value.getAsBoolean()); + } else if (value.isNumber()) { + double number = value.getAsDouble(); + if (number == Math.rint(number) && !value.getAsString().contains(".")) { + builder.put(AttributeKey.longKey(entry.getKey()), value.getAsLong()); + } else { + builder.put(AttributeKey.doubleKey(entry.getKey()), number); + } + } else { + builder.put(AttributeKey.stringKey(entry.getKey()), value.getAsString()); + } + } + return builder.build(); + } + + private static JsonElement[] loadScenarios(String resource) throws IOException { + try (InputStream in = SamplingScenariosTest.class.getClassLoader().getResourceAsStream(resource)) { + assertNotNull(in, "missing test resource " + resource); + List scenarios = new ArrayList<>(); + JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)) + .getAsJsonArray().forEach(scenarios::add); + return scenarios.toArray(new JsonElement[0]); + } + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java new file mode 100644 index 0000000000..b21ac5beca --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java @@ -0,0 +1,396 @@ +package com.launchdarkly.observability.internal.sampling; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.Value; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.testing.logs.TestLogRecordData; +import io.opentelemetry.sdk.testing.trace.TestSpanData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.*; + +class CustomSamplerTest { + + // Deterministic sampler that always samples (ratio >= 1) + private static final CustomSampler ALWAYS_SAMPLE = new CustomSampler(ratio -> true); + // Deterministic sampler that never samples (used when ratio config matches) + private static final CustomSampler NEVER_SAMPLE = new CustomSampler(ratio -> false); + + @Test + void noConfigAlwaysSamples() { + CustomSampler sampler = new CustomSampler(); + SpanData span = createTestSpan("test-span", Attributes.empty()); + CustomSampler.SamplingResult result = sampler.sampleSpan(span); + assertTrue(result.isSampled()); + } + + @Test + void spanNameValueMatchFilters() { + SamplingConfig config = new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofValue("health-check"), + Collections.emptyList(), + Collections.emptyList(), + 1 + )), + Collections.emptyList() + ); + + NEVER_SAMPLE.setConfig(config); + + // Matching span should be filtered (NEVER_SAMPLE says no) + SpanData healthSpan = createTestSpan("health-check", Attributes.empty()); + CustomSampler.SamplingResult result = NEVER_SAMPLE.sampleSpan(healthSpan); + assertFalse(result.isSampled()); + + // Non-matching span should pass through (no config match = always sample) + SpanData otherSpan = createTestSpan("user-request", Attributes.empty()); + result = NEVER_SAMPLE.sampleSpan(otherSpan); + assertTrue(result.isSampled()); + } + + @Test + void spanNameRegexMatchFilters() { + SamplingConfig config = new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofRegex("health.*"), + Collections.emptyList(), + Collections.emptyList(), + 1 + )), + Collections.emptyList() + ); + + NEVER_SAMPLE.setConfig(config); + + SpanData healthSpan = createTestSpan("health-check", Attributes.empty()); + assertFalse(NEVER_SAMPLE.sampleSpan(healthSpan).isSampled()); + + SpanData otherSpan = createTestSpan("user-request", Attributes.empty()); + assertTrue(NEVER_SAMPLE.sampleSpan(otherSpan).isSampled()); + } + + @Test + void spanAttributeMatchFilters() { + SamplingConfig config = new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + null, + List.of(new SamplingConfig.AttributeMatchConfig( + SamplingConfig.MatchConfig.ofValue("http.route"), + SamplingConfig.MatchConfig.ofValue("/health") + )), + Collections.emptyList(), + 1 + )), + Collections.emptyList() + ); + + NEVER_SAMPLE.setConfig(config); + + Attributes matchAttrs = Attributes.of(AttributeKey.stringKey("http.route"), "/health"); + assertFalse(NEVER_SAMPLE.sampleSpan(createTestSpan("request", matchAttrs)).isSampled()); + + Attributes noMatchAttrs = Attributes.of(AttributeKey.stringKey("http.route"), "/api/users"); + assertTrue(NEVER_SAMPLE.sampleSpan(createTestSpan("request", noMatchAttrs)).isSampled()); + } + + @Test + void matchConfigAlwaysSampleReturnsAttributes() { + SamplingConfig config = new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofValue("my-span"), + Collections.emptyList(), + Collections.emptyList(), + 5 + )), + Collections.emptyList() + ); + + ALWAYS_SAMPLE.setConfig(config); + SpanData span = createTestSpan("my-span", Attributes.empty()); + CustomSampler.SamplingResult result = ALWAYS_SAMPLE.sampleSpan(span); + assertTrue(result.isSampled()); + assertNotNull(result.getAttributes()); + assertEquals(5L, result.getAttributes().get(AttributeKey.longKey("launchdarkly.sampling.ratio"))); + } + + @Test + void matchesValueHelperExactMatch() { + assertTrue(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofValue("hello"), "hello")); + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofValue("hello"), "world")); + } + + @Test + void matchesValueHelperRegex() { + assertTrue(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("hel.*"), "hello")); + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("^world$"), "hello")); + } + + @Test + void matchesValueNullConfigAlwaysMatches() { + assertTrue(CustomSampler.matchesValue(null, "anything")); + } + + @Test + void matchesValueNullActualNeverMatches() { + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofValue("test"), null)); + } + + @Test + void regexMatchesSubstringLikeOtherSdks() { + assertTrue(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("health"), "GET /health-check")); + assertTrue(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("check$"), "health-check")); + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("^check"), "health-check")); + } + + @Test + void validRegexIsCompiledAsSent() { + // Constructs outside a character allowlist still compile and match. + assertTrue(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("(?i)^HEALTH\\b"), "health check")); + assertTrue(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("(a+)+b"), "aaab")); + } + + @Test + void invalidRegexIsSkippedWithWarning() { + Logger logger = Logger.getLogger(SamplingConfig.MatchConfig.class.getName()); + List records = new CopyOnWriteArrayList<>(); + Handler handler = new Handler() { + @Override public void publish(LogRecord record) { records.add(record); } + @Override public void flush() {} + @Override public void close() {} + }; + logger.addHandler(handler); + try { + SamplingConfig.MatchConfig invalid = + assertDoesNotThrow(() -> SamplingConfig.MatchConfig.ofRegex("(unclosed")); + assertFalse(CustomSampler.matchesValue(invalid, "(unclosed")); + } finally { + logger.removeHandler(handler); + } + assertEquals(1, records.size()); + assertEquals(Level.WARNING, records.get(0).getLevel()); + assertTrue(records.get(0).getMessage().contains("(unclosed")); + } + + @Test + void invalidRegexRuleIsSkippedAndOtherRulesStillApply() { + SamplingConfig config = new SamplingConfig( + List.of( + new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofRegex("[broken"), + Collections.emptyList(), + Collections.emptyList(), + 0), + new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofRegex("health"), + Collections.emptyList(), + Collections.emptyList(), + 0) + ), + Collections.emptyList() + ); + CustomSampler sampler = new CustomSampler(); + sampler.setConfig(config); + + assertTrue(sampler.sampleSpan(createTestSpan("[broken", Attributes.empty())).isSampled()); + assertFalse(sampler.sampleSpan(createTestSpan("health-check", Attributes.empty())).isSampled()); + } + + @Test + void sampleLogWithoutConfigAlwaysSamples() { + CustomSampler.SamplingResult result = new CustomSampler().sampleLog(createTestLog("anything", "INFO", Attributes.empty())); + assertTrue(result.isSampled()); + assertNull(result.getAttributes()); + } + + @Test + void sampleLogMatchesSeverityTextField() { + CustomSampler sampler = new CustomSampler(ratio -> false); + sampler.setConfig(logConfig(new SamplingConfig.LogSamplingConfig( + null, SamplingConfig.MatchConfig.ofValue("error"), Collections.emptyList(), 7))); + + CustomSampler.SamplingResult matched = sampler.sampleLog(createTestLog("boom", "error", Attributes.empty())); + assertFalse(matched.isSampled()); + assertEquals(7L, matched.getAttributes().get(AttributeKey.longKey("launchdarkly.sampling.ratio"))); + + // The rule reads severityText, not the Severity enum: a log with + // only the ERROR number and no text does not match "error". + LogRecordData numberOnly = TestLogRecordData.builder() + .setSeverity(Severity.ERROR) + .setBodyValue(Value.of("boom")) + .build(); + assertTrue(sampler.sampleLog(numberOnly).isSampled()); + assertTrue(sampler.sampleLog(createTestLog("boom", "info", Attributes.empty())).isSampled()); + } + + @Test + void sampleLogMatchesMessageByValueAndSubstringRegex() { + CustomSampler sampler = new CustomSampler(ratio -> false); + sampler.setConfig(new SamplingConfig(Collections.emptyList(), List.of( + new SamplingConfig.LogSamplingConfig( + SamplingConfig.MatchConfig.ofValue("exact message"), null, Collections.emptyList(), 2), + new SamplingConfig.LogSamplingConfig( + SamplingConfig.MatchConfig.ofRegex("timed out"), null, Collections.emptyList(), 3)))); + + assertEquals(2L, sampler.sampleLog(createTestLog("exact message", null, Attributes.empty())) + .getAttributes().get(AttributeKey.longKey("launchdarkly.sampling.ratio"))); + assertEquals(3L, sampler.sampleLog(createTestLog("Error: request timed out after 30s", null, Attributes.empty())) + .getAttributes().get(AttributeKey.longKey("launchdarkly.sampling.ratio"))); + assertTrue(sampler.sampleLog(createTestLog("exact message, but longer", null, Attributes.empty())).isSampled()); + assertTrue(sampler.sampleLog(createTestLog(null, null, Attributes.empty())).isSampled()); + } + + @Test + void sampleLogMatchesTypedAttributes() { + CustomSampler sampler = new CustomSampler(ratio -> false); + sampler.setConfig(logConfig(new SamplingConfig.LogSamplingConfig(null, null, List.of( + new SamplingConfig.AttributeMatchConfig( + SamplingConfig.MatchConfig.ofValue("http.status_code"), SamplingConfig.MatchConfig.ofValue("503")), + new SamplingConfig.AttributeMatchConfig( + SamplingConfig.MatchConfig.ofValue("retry.enabled"), SamplingConfig.MatchConfig.ofValue("true"))), + 4))); + + Attributes both = Attributes.of( + AttributeKey.longKey("http.status_code"), 503L, + AttributeKey.booleanKey("retry.enabled"), true); + assertFalse(sampler.sampleLog(createTestLog("x", null, both)).isSampled()); + + Attributes onlyOne = Attributes.of(AttributeKey.longKey("http.status_code"), 503L); + assertTrue(sampler.sampleLog(createTestLog("x", null, onlyOne)).isSampled(), + "every attribute rule must match"); + } + + @Test + void sampleLogFirstMatchingRuleWins() { + CustomSampler sampler = new CustomSampler(ratio -> ratio == 1); + sampler.setConfig(new SamplingConfig(Collections.emptyList(), List.of( + new SamplingConfig.LogSamplingConfig(null, SamplingConfig.MatchConfig.ofValue("debug"), Collections.emptyList(), 1), + new SamplingConfig.LogSamplingConfig(null, null, Collections.emptyList(), 100)))); + + CustomSampler.SamplingResult debug = sampler.sampleLog(createTestLog("x", "debug", Attributes.empty())); + assertTrue(debug.isSampled()); + assertEquals(1L, debug.getAttributes().get(AttributeKey.longKey("launchdarkly.sampling.ratio"))); + + CustomSampler.SamplingResult other = sampler.sampleLog(createTestLog("x", "info", Attributes.empty())); + assertFalse(other.isSampled()); + assertEquals(100L, other.getAttributes().get(AttributeKey.longKey("launchdarkly.sampling.ratio"))); + } + + @Test + void matchValuesCompareByTypeLikeGoAndDotNet() { + SamplingConfig.MatchConfig number = SamplingConfig.MatchConfig.ofValue(2.0); + assertTrue(number.matches(2L), "a JSON number matches a long attribute of equal value"); + assertTrue(number.matches(2.0d)); + assertFalse(number.matches(3L)); + assertFalse(number.matches("2"), "a number does not match a string"); + + SamplingConfig.MatchConfig bool = SamplingConfig.MatchConfig.ofValue(true); + assertTrue(bool.matches(true)); + assertFalse(bool.matches(false)); + assertFalse(bool.matches("true"), "a boolean does not match a string"); + + SamplingConfig.MatchConfig text = SamplingConfig.MatchConfig.ofValue("503"); + assertTrue(text.matches("503")); + assertTrue(text.matches(503L), "a string matches the value's text"); + + SamplingConfig.MatchConfig regex = SamplingConfig.MatchConfig.ofRegex("^5"); + assertTrue(regex.matches("503")); + assertFalse(regex.matches(503L), "a regex only matches string values"); + + assertFalse(SamplingConfig.MatchConfig.ofValue(List.of("a")).matches("a"), + "an unsupported value type never matches"); + } + + @Test + void numericRuleMatchesDoubleAttributeOfIntegralValue() { + CustomSampler sampler = new CustomSampler(ratio -> false); + sampler.setConfig(new SamplingConfig(List.of(new SamplingConfig.SpanSamplingConfig(null, + List.of(new SamplingConfig.AttributeMatchConfig( + SamplingConfig.MatchConfig.ofValue("load"), SamplingConfig.MatchConfig.ofValue(2.0))), + Collections.emptyList(), 0)), Collections.emptyList())); + + assertFalse(sampler.sampleSpan(createTestSpan("s", Attributes.of(AttributeKey.doubleKey("load"), 2.0))) + .isSampled()); + assertFalse(sampler.sampleSpan(createTestSpan("s", Attributes.of(AttributeKey.longKey("load"), 2L))) + .isSampled()); + assertTrue(sampler.sampleSpan(createTestSpan("s", Attributes.of(AttributeKey.doubleKey("load"), 2.5))) + .isSampled()); + } + + private static SamplingConfig logConfig(SamplingConfig.LogSamplingConfig rule) { + return new SamplingConfig(Collections.emptyList(), List.of(rule)); + } + + private static LogRecordData createTestLog(String message, String severityText, Attributes attributes) { + TestLogRecordData.Builder builder = TestLogRecordData.builder().setAttributes(attributes); + if (message != null) { + builder.setBodyValue(Value.of(message)); + } + if (severityText != null) { + builder.setSeverityText(severityText); + } + return builder.build(); + } + + @Test + void defaultSamplerBehavior() { + assertFalse(CustomSampler.defaultSampler(0)); + assertTrue(CustomSampler.defaultSampler(1)); + // ratio of 2 should sample ~50% (not deterministic, but at least doesn't throw) + CustomSampler.defaultSampler(2); + } + + // --- Helper to create a minimal SpanData using SDK testing utilities --- + + private static SpanData createTestSpan(String name, Attributes attributes) { + return TestSpanData.builder() + .setName(name) + .setKind(SpanKind.INTERNAL) + .setSpanContext(SpanContext.create( + traceId(), + spanId('1'), + TraceFlags.getSampled(), + TraceState.getDefault() + )) + .setStatus(StatusData.unset()) + .setHasEnded(true) + .setStartEpochNanos(0) + .setEndEpochNanos(1) + .setAttributes(attributes) + .build(); + } + + private static String traceId() { + return "0".repeat(31) + "1"; + } + + private static String spanId(char tail) { + return "0".repeat(15) + tail; + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/SamplingFailureTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/SamplingFailureTest.java new file mode 100644 index 0000000000..1c4cb51b65 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/SamplingFailureTest.java @@ -0,0 +1,87 @@ +package com.launchdarkly.observability.internal.sampling; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceId; +import io.opentelemetry.api.trace.SpanId; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.logs.export.SimpleLogRecordProcessor; +import io.opentelemetry.sdk.testing.exporter.InMemoryLogRecordExporter; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.testing.trace.TestSpanData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Sampling runs on application threads (logs) and in the export path + * (spans). A failure there must keep the telemetry instead of throwing into + * the caller or losing the batch. + */ +class SamplingFailureTest { + + @Test + void logSamplingThatOverflowsTheStackKeepsTheRecordAndDoesNotThrow() { + // (a|b)* recurses once per character in java.util.regex. + CustomSampler sampler = new CustomSampler(ratio -> false); + sampler.setConfig(new SamplingConfig(Collections.emptyList(), List.of( + new SamplingConfig.LogSamplingConfig( + SamplingConfig.MatchConfig.ofRegex("^(a|b)*c"), null, Collections.emptyList(), 0)))); + InMemoryLogRecordExporter exported = InMemoryLogRecordExporter.create(); + SdkLoggerProvider provider = SdkLoggerProvider.builder() + .addLogRecordProcessor(new SamplingLogProcessor(SimpleLogRecordProcessor.create(exported), sampler)) + .build(); + Logger logger = provider.get("test"); + + String longMessage = "ab".repeat(200_000); + assertDoesNotThrow(() -> logger.logRecordBuilder().setBody(longMessage).emit()); + logger.logRecordBuilder().setBody("abc").emit(); + + assertEquals(1, exported.getFinishedLogRecordItems().stream() + .filter(r -> r.getBodyValue().asString().equals(longMessage)).count(), + "a record the sampler could not evaluate is exported unsampled"); + assertEquals(1, exported.getFinishedLogRecordItems().size(), "a matching record is still dropped"); + provider.close(); + } + + @Test + void spanSamplingFailureExportsTheBatchUnsampled() { + CustomSampler sampler = new CustomSampler(ratio -> { + throw new IllegalStateException("sampler bug"); + }); + sampler.setConfig(new SamplingConfig(List.of(new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofValue("checkout"), Collections.emptyList(), Collections.emptyList(), 2)), + Collections.emptyList())); + InMemorySpanExporter exported = InMemorySpanExporter.create(); + SamplingTraceExporter exporter = new SamplingTraceExporter(exported, sampler); + + assertDoesNotThrow(() -> exporter.export(List.of(span("checkout"), span("other")))); + + assertEquals(2, exported.getFinishedSpanItems().size()); + } + + private static SpanData span(String name) { + return TestSpanData.builder() + .setName(name) + .setKind(SpanKind.INTERNAL) + .setSpanContext(SpanContext.create( + TraceId.fromLongs(0, 1), SpanId.fromLong(name.hashCode() | 1L), + TraceFlags.getSampled(), TraceState.getDefault())) + .setStatus(StatusData.unset()) + .setHasEnded(true) + .setStartEpochNanos(0) + .setEndEpochNanos(1) + .setAttributes(Attributes.empty()) + .build(); + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporterTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporterTest.java new file mode 100644 index 0000000000..a4a8181b43 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporterTest.java @@ -0,0 +1,121 @@ +package com.launchdarkly.observability.internal.sampling; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.testing.trace.TestSpanData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SamplingTraceExporterTest { + + @Test + void exportKeepsSamplingRatioOnSpan() { + CustomSampler sampler = new CustomSampler(ratio -> true); + sampler.setConfig(new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofValue("checkout"), + Collections.emptyList(), + Collections.emptyList(), + 5 + )), + Collections.emptyList() + )); + CapturingExporter capture = new CapturingExporter(); + SamplingTraceExporter exporter = new SamplingTraceExporter(capture, sampler); + + Attributes original = Attributes.of(AttributeKey.stringKey("http.route"), "/checkout"); + exporter.export(List.of(span("checkout", spanId('1'), null, original))); + + assertEquals(1, capture.exported.size()); + Attributes attributes = capture.exported.get(0).getAttributes(); + assertEquals("/checkout", attributes.get(AttributeKey.stringKey("http.route"))); + assertEquals(5L, attributes.get(AttributeKey.longKey("launchdarkly.sampling.ratio"))); + } + + @Test + void exportDropsChildrenOfUnsampledSpans() { + CustomSampler sampler = new CustomSampler(ratio -> false); + sampler.setConfig(new SamplingConfig( + List.of(new SamplingConfig.SpanSamplingConfig( + SamplingConfig.MatchConfig.ofValue("health-check"), + Collections.emptyList(), + Collections.emptyList(), + 1 + )), + Collections.emptyList() + )); + CapturingExporter capture = new CapturingExporter(); + SamplingTraceExporter exporter = new SamplingTraceExporter(capture, sampler); + + SpanContext parent = context(spanId('1')); + SpanData root = span("health-check", spanId('1'), null, Attributes.empty()); + SpanData child = span("db-query", spanId('2'), parent, Attributes.empty()); + + exporter.export(List.of(root, child)); + + assertTrue(capture.exported.isEmpty()); + } + + private static SpanData span(String name, String spanId, SpanContext parent, Attributes attributes) { + TestSpanData.Builder builder = TestSpanData.builder() + .setName(name) + .setKind(SpanKind.INTERNAL) + .setSpanContext(context(spanId)) + .setStatus(StatusData.unset()) + .setHasEnded(true) + .setStartEpochNanos(0) + .setEndEpochNanos(1) + .setAttributes(attributes); + if (parent != null) { + builder.setParentSpanContext(parent); + } + return builder.build(); + } + + private static SpanContext context(String spanId) { + return SpanContext.create(traceId(), spanId, TraceFlags.getSampled(), TraceState.getDefault()); + } + + private static String traceId() { + return "0".repeat(31) + "a"; + } + + private static String spanId(char tail) { + return "0".repeat(15) + tail; + } + + private static final class CapturingExporter implements SpanExporter { + private final List exported = new ArrayList<>(); + + @Override + public CompletableResultCode export(Collection spans) { + exported.addAll(spans); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + } +} diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/resources/sampling/log-test-scenarios.json b/sdk/@launchdarkly/observability-java/lib/src/test/resources/sampling/log-test-scenarios.json new file mode 100644 index 0000000000..f72530e506 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/resources/sampling/log-test-scenarios.json @@ -0,0 +1,521 @@ +[ + { + "description": "Should match a log based on severity", + "samplingConfig": { + "logs": [ + { + "severityText": { + "matchValue": "error" + }, + "samplingRatio": 42 + } + ] + }, + "inputLog": { + "severityText": "error" + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 42 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 42 + } + } + } + ] + }, + { + "description": "Should match a log based on message content with exact attribute", + "samplingConfig": { + "logs": [ + { + "message": { + "matchValue": "Connection failed" + }, + "samplingRatio": 42 + } + ] + }, + "inputLog": { + "message": "Connection failed" + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 42 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 42 + } + } + } + ] + }, + { + "description": "Should not match a log when severity does not match", + "samplingConfig": { + "logs": [ + { + "severityText": { + "matchValue": "error" + }, + "samplingRatio": 42 + } + ] + }, + "inputLog": { + "severityText": "info" + }, + "samplerFunctionCases": [ + { + "type": "never", + "expected_result": { + "sample": true, + "attributes": null + } + } + ] + }, + { + "description": "Should match a log based on message content with regex", + "samplingConfig": { + "logs": [ + { + "message": { + "regexValue": "Error: .*" + }, + "samplingRatio": 42 + } + ] + }, + "inputLog": { + "message": "Error: Connection timed out" + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 42 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 42 + } + } + } + ] + }, + { + "description": "Should exact match a log based on string attribute value", + "samplingConfig": { + "logs": [ + { + "attributes": [ + { + "key": { + "matchValue": "service.name" + }, + "attribute": { + "matchValue": "api-gateway" + } + } + ], + "samplingRatio": 75 + } + ] + }, + "inputLog": { + "attributes": { + "service.name": "api-gateway", + "environment": "production" + } + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 75 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 75 + } + } + } + ] + }, + { + "description": "Should not match a log when the attributes do not exist", + "samplingConfig": { + "logs": [ + { + "attributes": [ + { + "key": { + "matchValue": "service.name" + }, + "attribute": { + "matchValue": "api-gateway" + } + } + ], + "samplingRatio": 75 + } + ] + }, + "inputLog": { + "message": "Connection failed" + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": null + } + } + ] + }, + { + "description": "Should regex match a log based on string attribute value", + "samplingConfig": { + "logs": [ + { + "attributes": [ + { + "key": { + "matchValue": "service.name" + }, + "attribute": { + "regexValue": "api-.*" + } + } + ], + "samplingRatio": 75 + } + ] + }, + "inputLog": { + "attributes": { + "service.name": "api-gateway", + "environment": "production" + } + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 75 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 75 + } + } + } + ] + }, + { + "description": "Should match a log based on boolean attribute value", + "samplingConfig": { + "logs": [ + { + "attributes": [ + { + "key": { + "matchValue": "error.fatal" + }, + "attribute": { + "matchValue": true + } + } + ], + "samplingRatio": 100 + } + ] + }, + "inputLog": { + "attributes": { + "error.fatal": true, + "error.message": "Critical system failure" + } + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 100 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 100 + } + } + } + ] + }, + { + "description": "Should match a log based on numeric attribute value", + "samplingConfig": { + "logs": [ + { + "attributes": [ + { + "key": { + "matchValue": "http.status_code" + }, + "attribute": { + "matchValue": 500 + } + } + ], + "samplingRatio": 50 + } + ] + }, + "inputLog": { + "attributes": { + "http.status_code": 500, + "http.method": "POST" + } + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 50 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 50 + } + } + } + ] + }, + { + "description": "Should match a log based on message, severity, and multiple attribute types", + "samplingConfig": { + "logs": [ + { + "message": { + "regexValue": "Database connection .*" + }, + "severityText": { + "matchValue": "error" + }, + "attributes": [ + { + "key": { + "regexValue": "service.*" + }, + "attribute": { + "regexValue": "db-.*" + } + }, + { + "key": { + "matchValue": "retry.enabled" + }, + "attribute": { + "matchValue": true + } + }, + { + "key": { + "matchValue": "retry.count" + }, + "attribute": { + "matchValue": 3 + } + } + ], + "samplingRatio": 90 + } + ] + }, + "inputLog": { + "message": "Database connection failed: timeout", + "severityText": "error", + "attributes": { + "service.name": "db-connector", + "retry.enabled": true, + "retry.count": 3, + "database.name": "user-profiles", + "error.type": "CONNECTION_TIMEOUT" + } + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 90 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 90 + } + } + } + ] + }, + { + "description": "Should not match an empty log when message and severity are defined", + "samplingConfig": { + "logs": [ + { + "message": { + "regexValue": "Database connection .*" + }, + "severityText": { + "matchValue": "error" + }, + "samplingRatio": 90 + } + ] + }, + "inputLog": {}, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true + } + }, + { + "type": "never", + "expected_result": { + "sample": true + } + } + ] + }, + { + "description": "Should not match a log with a message when message and severity are defined", + "samplingConfig": { + "logs": [ + { + "message": { + "regexValue": "Database connection .*" + }, + "severityText": { + "matchValue": "error" + }, + "samplingRatio": 90 + } + ] + }, + "inputLog": { + "message": "Database connection failed: timeout" + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true + } + }, + { + "type": "never", + "expected_result": { + "sample": true + } + } + ] + }, + { + "description": "Should not match a log with only severity when message and severity are defined", + "samplingConfig": { + "logs": [ + { + "message": { + "regexValue": "Database connection .*" + }, + "severityText": { + "matchValue": "error" + }, + "samplingRatio": 90 + } + ] + }, + "inputLog": { + "severityText": "error" + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true + } + }, + { + "type": "never", + "expected_result": { + "sample": true + } + } + ] + } +] diff --git a/sdk/@launchdarkly/observability-java/lib/src/test/resources/sampling/span-test-scenarios.json b/sdk/@launchdarkly/observability-java/lib/src/test/resources/sampling/span-test-scenarios.json new file mode 100644 index 0000000000..298a922785 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/resources/sampling/span-test-scenarios.json @@ -0,0 +1,568 @@ +[ + { + "description": "Should match all spans when the span sampling config has no match criteria", + "samplingConfig": { + "spans": [ + { + "samplingRatio": 10 + } + ] + }, + "inputSpan": { + "name": "test-span", + "attributes": {} + }, + "samplerFunctionCases": [ + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 10 + } + } + } + ] + }, + { + "description": "Should match a span based on name with exact match", + "samplingConfig": { + "spans": [ + { + "name": { + "matchValue": "test-span" + }, + "samplingRatio": 42 + } + ] + }, + "inputSpan": { + "name": "test-span", + "attributes": {} + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 42 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 42 + } + } + } + ] + }, + { + "description": "Should always sample a span when the span name does not match", + "samplingConfig": { + "spans": [ + { + "name": { + "matchValue": "test-span" + }, + "samplingRatio": 42 + } + ] + }, + "inputSpan": { + "name": "other-span", + "attributes": {} + }, + "samplerFunctionCases": [ + { + "type": "never", + "expected_result": { + "sample": true, + "attributes": null + } + } + ] + }, + { + "description": "Should match a span name based on regex", + "samplingConfig": { + "spans": [ + { + "name": { + "regexValue": "test-span-\\d+" + }, + "samplingRatio": 42 + } + ] + }, + "inputSpan": { + "name": "test-span-123", + "attributes": {} + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 42 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 42 + } + } + } + ] + }, + { + "description": "Should match a span based on event name with exact match", + "samplingConfig": { + "spans": [ + { + "events": [ + { + "name": { + "matchValue": "test-event" + } + } + ], + "samplingRatio": 42 + } + ] + }, + "inputSpan": { + "name": "test-span", + "events": [ + { + "name": "test-event" + } + ] + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 42 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 42 + } + } + } + ] + }, + { + "description": "Should match a span based on string attribute value", + "samplingConfig": { + "spans": [ + { + "attributes": [ + { + "key": { + "matchValue": "http.method" + }, + "attribute": { + "matchValue": "POST" + } + } + ], + "samplingRatio": 75 + } + ] + }, + "inputSpan": { + "name": "http-request", + "attributes": { + "http.method": "POST", + "http.url": "https://api.example.com/data" + } + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 75 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 75 + } + } + } + ] + }, + { + "description": "Should match a span based on regex attribute value", + "samplingConfig": { + "spans": [ + { + "attributes": [ + { + "key": { + "matchValue": "http.url" + }, + "attribute": { + "regexValue": "https://api\\.example\\.com/.*" + } + } + ], + "samplingRatio": 25 + } + ] + }, + "inputSpan": { + "name": "http-request", + "attributes": { + "http.method": "GET", + "http.url": "https://api.example.com/users" + } + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 25 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 25 + } + } + } + ] + }, + { + "description": "Should match a span based on numeric attribute value", + "samplingConfig": { + "spans": [ + { + "attributes": [ + { + "key": { + "matchValue": "http.status_code" + }, + "attribute": { + "matchValue": 500 + } + } + ], + "samplingRatio": 100 + } + ] + }, + "inputSpan": { + "name": "http-response", + "attributes": { + "http.status_code": 500, + "http.method": "POST" + } + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 100 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 100 + } + } + } + ] + }, + { + "description": "Should match a span based on event name with regex match", + "samplingConfig": { + "spans": [ + { + "events": [ + { + "name": { + "regexValue": "test-event-\\d+" + } + } + ], + "samplingRatio": 33 + } + ] + }, + "inputSpan": { + "name": "test-span", + "events": [ + { + "name": "test-event-123" + } + ] + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 33 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 33 + } + } + } + ] + }, + { + "description": "Should match a span based on a combination of span name and multiple attributes with regex matching", + "samplingConfig": { + "spans": [ + { + "name": { + "regexValue": "complex-span-\\d+" + }, + "attributes": [ + { + "key": { + "matchValue": "http.method" + }, + "attribute": { + "matchValue": "POST" + } + }, + { + "key": { + "regexValue": "http\\.status.*" + }, + "attribute": { + "matchValue": 500 + } + }, + { + "key": { + "matchValue": "url" + }, + "attribute": { + "regexValue": "https://api\\.example\\.com/.*" + } + }, + { + "key": { + "matchValue": "retry" + }, + "attribute": { + "matchValue": true + } + } + ], + "samplingRatio": 50 + } + ] + }, + "inputSpan": { + "name": "complex-span-123", + "attributes": { + "http.method": "POST", + "http.status_code": 500, + "url": "https://api.example.com/users", + "retry": true, + "other_attr": "value" + } + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 50 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 50 + } + } + } + ] + }, + { + "description": "Should match a span based on event name and event attributes", + "samplingConfig": { + "spans": [ + { + "events": [ + { + "name": { + "matchValue": "test-event" + }, + "attributes": [ + { + "key": { + "matchValue": "event.type" + }, + "attribute": { + "matchValue": "click" + } + }, + { + "key": { + "matchValue": "event.status" + }, + "attribute": { + "matchValue": "success" + } + } + ] + } + ], + "samplingRatio": 60 + } + ] + }, + "inputSpan": { + "name": "ui-interaction", + "events": [ + { + "name": "test-event", + "attributes": { + "event.type": "click", + "event.status": "success", + "event.target": "button" + } + } + ] + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 60 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 60 + } + } + } + ] + }, + { + "description": "Should match a span based only on event attributes without matching event name", + "samplingConfig": { + "spans": [ + { + "events": [ + { + "attributes": [ + { + "key": { + "matchValue": "error.type" + }, + "attribute": { + "matchValue": "network" + } + }, + { + "key": { + "matchValue": "error.code" + }, + "attribute": { + "matchValue": 503 + } + } + ] + } + ], + "samplingRatio": 85 + } + ] + }, + "inputSpan": { + "name": "api-request", + "events": [ + { + "name": "error-event", + "attributes": { + "error.type": "network", + "error.code": 503, + "error.message": "Service unavailable" + } + } + ] + }, + "samplerFunctionCases": [ + { + "type": "always", + "expected_result": { + "sample": true, + "attributes": { + "launchdarkly.sampling.ratio": 85 + } + } + }, + { + "type": "never", + "expected_result": { + "sample": false, + "attributes": { + "launchdarkly.sampling.ratio": 85 + } + } + } + ] + } +] diff --git a/sdk/@launchdarkly/observability-java/settings.gradle b/sdk/@launchdarkly/observability-java/settings.gradle new file mode 100644 index 0000000000..f668193d38 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'observability-java' +include 'lib'