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..e0aa139c3d --- /dev/null +++ b/.github/actions/publish-java-sdk/action.yml @@ -0,0 +1,76 @@ +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' + +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." 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..37bbc35b7b --- /dev/null +++ b/.github/workflows/java-observability.yml @@ -0,0 +1,95 @@ +name: 'Java Observability' + +on: + push: + branches: ['main'] + paths: + - 'sdk/@launchdarkly/observability-java/**' + - 'e2e/java-plugin/**' + - 'e2e/java-plugin-spring-boot/**' + - '.github/workflows/java-observability.yml' + pull_request: + types: [opened, synchronize] + paths: + - 'sdk/@launchdarkly/observability-java/**' + - 'e2e/java-plugin/**' + - 'e2e/java-plugin-spring-boot/**' + - '.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 + + - 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..75b4b60c7f --- /dev/null +++ b/.github/workflows/java-plugin-e2e.yml @@ -0,0 +1,80 @@ +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' + + - 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" + + - 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..6d44502643 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -26,6 +26,10 @@ 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 release_launchdarkly_dotnet: description: 'Should release @launchdarkly/observability-dotnet package.' type: boolean @@ -143,6 +147,24 @@ 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 + 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-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..a4132ba23e 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,24 @@ 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' }} + 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-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 680e7c116d..3614a53513 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -4,6 +4,7 @@ "sdk/@launchdarkly/observability": "1.2.1", "sdk/@launchdarkly/observability-next": "0.3.1", "sdk/@launchdarkly/observability-android": "0.69.0", + "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..f8b92c3aa0 --- /dev/null +++ b/e2e/java-plugin-spring-boot/.gitignore @@ -0,0 +1,2 @@ +.gradle +build 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..c0373906d4 --- /dev/null +++ b/e2e/java-plugin-spring-boot/Makefile @@ -0,0 +1,23 @@ +.PHONY: build run test 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) + +# 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..540fa7196a --- /dev/null +++ b/e2e/java-plugin-spring-boot/README.md @@ -0,0 +1,71 @@ +# 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`. | + +`make build` produces the executable jar in `build/libs/`, and `make test` +runs the tests plus a guard that fails if the plugin leaks OkHttp, Okio or +Kotlin onto the application's classpath. + +## 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..f0f145cc0b --- /dev/null +++ b/e2e/java-plugin-spring-boot/build.gradle @@ -0,0 +1,64 @@ +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() +} + +java { + // Spring Boot 3 needs Java 17. The plugin itself targets Java 11. + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_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') + +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' +} + +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/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..745eac33b2 --- /dev/null +++ b/e2e/java-plugin-spring-boot/src/main/java/com/launchdarkly/observability/example/springboot/LaunchDarklyConfiguration.java @@ -0,0 +1,79 @@ +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) { + 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); + 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 config = new LDConfig.Builder() + .offline(offline) + .startWait(Duration.ofSeconds(5)) + .plugins(Components.plugins().setPlugins(List.of(new ObservabilityPlugin(options.build())))) + .build(); + LDClient client = new LDClient(offline ? "offline" : sdkKey, config); + 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/.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..2cf591dd2c --- /dev/null +++ b/e2e/java-plugin/Makefile @@ -0,0 +1,16 @@ +.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. +run check: + $(SDK_GRADLE) -p $(CURDIR) run $(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 $(GRADLE_ARGS) -PuseMavenLocal diff --git a/e2e/java-plugin/README.md b/e2e/java-plugin/README.md new file mode 100644 index 0000000000..c120717315 --- /dev/null +++ b/e2e/java-plugin/README.md @@ -0,0 +1,35 @@ +# 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 +``` + +A passing run prints one `RESULT` line. `checkout-enabled` is the +flag-evaluation span from `TracingHook`. `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..d272e88ac3 --- /dev/null +++ b/e2e/java-plugin/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'application' +} + +def useMavenLocal = project.hasProperty('useMavenLocal') + +repositories { + if (useMavenLocal) { + mavenLocal() + } + mavenCentral() +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_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') + +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. +} + +application { + mainClass = 'com.launchdarkly.observability.example.PluginExample' +} + +// 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..c69448182d --- /dev/null +++ b/e2e/java-plugin/src/main/java/com/launchdarkly/observability/example/PluginExample.java @@ -0,0 +1,186 @@ +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 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; + +/** + * Runs the Java observability plugin against a local collector and exits + * non-zero when a required signal is missing. + */ +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 { + int status = new PluginExample().run(); + if (status != 0) { + System.exit(status); + } + } + + private int run() throws Exception { + GlobalOpenTelemetry.resetForTest(); + 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"); + } + + client.boolVariation("checkout-enabled", LDContext.create("user-key-test"), false); + 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; 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" + + " flag-evaluation=" + (flagArrived ? "checkout-enabled" : "missing") + + " kept=" + (kept ? "kept-checkout" : "missing") + + " dropped=" + (dropped ? "health-check" : "still-exported") + + " sampling-ratio=" + (ratioArrived ? "present" : "missing") + + " global=" + (flagArrived ? "sdk" : "noop")); + } finally { + LDObserve.shutdown(); + server.stop(0); + } + + if (!failures.isEmpty()) { + for (String failure : failures) { + System.err.println("FAIL " + failure); + } + return 1; + } + return 0; + } + + 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..8185498ef8 100644 --- a/e2e/tests/src/app_runner.py +++ b/e2e/tests/src/app_runner.py @@ -179,6 +179,7 @@ def main(): "dotnet", "dotnet4", "go", + "java-plugin-spring-boot", "python", "ruby", ], 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..675510e0f8 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/README.md @@ -0,0 +1,160 @@ +# 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.12.0+ + +## 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 + +``` + +### 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, the LaunchDarkly OTel tracing hook and Gson are runtime dependencies. + +## 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 `TracingHook` that adds span events for each flag evaluation, enabling guarded rollouts and observability. +3. Fetches sampling configuration from the backend to control telemetry volume. + +## 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 +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)` | + +### 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, or if the generated POM does not expose `opentelemetry-api` at compile scope. + +## 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..16d2fe4f09 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/build.gradle @@ -0,0 +1,172 @@ +plugins { + id 'java-library' + id 'maven-publish' + id 'signing' +} + +repositories { + mavenCentral() +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + withJavadocJar() + withSourcesJar() +} + +ext { + otelVersion = '1.51.0' + ldServerSdkVersion = '7.12.0' +} + +dependencies { + // LaunchDarkly SDK - compileOnly so users provide their own version + compileOnly "com.launchdarkly:launchdarkly-java-server-sdk:${ldServerSdkVersion}" + + // LaunchDarkly OTel tracing hook + implementation 'com.launchdarkly:launchdarkly-java-server-sdk-otel:0.2.0' + + // 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}" + + // JSON parsing + implementation 'com.google.code.gson:gson:2.11.0' + + // 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}" +} + +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 carries no OkHttp.' + 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') + } + 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') + } + } +} + +tasks.named('check') { + dependsOn 'checkRuntimeClasspath', 'checkPublishedPom' +} + +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..93be5a623f --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/LDObserve.java @@ -0,0 +1,140 @@ +package com.launchdarkly.observability; + +import com.launchdarkly.observability.internal.Constants; +import com.launchdarkly.observability.internal.OtelManager; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.logs.Severity; +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.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; + +import java.io.PrintWriter; +import java.io.StringWriter; + +/** + * 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.

+ */ +public final class LDObserve { + private LDObserve() {} + + /** + * Records a gauge metric. + */ + public static void recordMetric(String name, double value, Attributes attributes) { + Meter meter = OtelManager.getMeter(); + DoubleGauge gauge = meter.gaugeBuilder(name).build(); + gauge.set(value, attributes); + } + + /** + * Records a counter metric. + */ + public static void recordCount(String name, long value, Attributes attributes) { + Meter meter = OtelManager.getMeter(); + LongCounter counter = meter.counterBuilder(name).build(); + counter.add(value, 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) { + Meter meter = OtelManager.getMeter(); + DoubleHistogram histogram = meter.histogramBuilder(name).build(); + histogram.record(value, attributes); + } + + /** + * Records an up/down counter metric. + */ + public static void recordUpDownCounter(String name, long value, Attributes attributes) { + Meter meter = OtelManager.getMeter(); + LongUpDownCounter counter = meter.upDownCounterBuilder(name).build(); + counter.add(value, attributes); + } + + /** + * Records a log message. + */ + public static void recordLog(String message, Severity severity, Attributes attributes) { + Logger logger = OtelManager.getLogger(); + // Severity number and text are separate OTLP fields; the backend UI + // and severityText sampling rules read the text. + logger.logRecordBuilder() + .setSeverity(severity) + .setSeverityText(severity.name()) + .setBody(message) + .setAllAttributes(attributes) + .emit(); + } + + /** + * Records an error as a span with exception details. + */ + public static void recordError(Throwable throwable, Attributes attributes) { + 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); + + StringWriter sw = new StringWriter(); + throwable.printStackTrace(new PrintWriter(sw)); + span.setAttribute("exception.stacktrace", sw.toString()); + } 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(); + } + + /** + * Manually starts the OTLP providers. Only needed when + * {@link ObservabilityOptions.Builder#manualStart(boolean)} is true. + */ + public static void start(String sdkKey, ObservabilityOptions options) { + OtelManager.initialize(sdkKey, options); + } + + /** + * Flushes all pending telemetry data. + */ + public static void flush() { + OtelManager.flush(); + } + + /** + * Flushes all pending data and shuts down the providers. + */ + public static void shutdown() { + OtelManager.shutdown(); + } +} 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..565dbcf564 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityOptions.java @@ -0,0 +1,274 @@ +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 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; + } + + /** 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; } + + /** + * 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; + + 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; + } + + 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..70cbd99db4 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/ObservabilityPlugin.java @@ -0,0 +1,121 @@ +package com.launchdarkly.observability; + +import com.launchdarkly.integrations.TracingHook; +import com.launchdarkly.observability.internal.GraphQLClient; +import com.launchdarkly.observability.internal.OtelManager; +import com.launchdarkly.observability.internal.SamplingApiService; +import com.launchdarkly.observability.internal.sampling.SamplingConfig; +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.Level; +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 (!options.isManualStart()) { + OtelManager.initialize(sdkKey, options); + } + + // Fetch sampling config in background + Thread samplingThread = new Thread(() -> { + try { + GraphQLClient graphQLClient = new GraphQLClient( + options.getBackendUrl(), options.getCustomHeaders(), options.getProxySelector()); + SamplingApiService samplingService = new SamplingApiService(graphQLClient); + SamplingConfig config = samplingService.getSamplingConfig(sdkKey); + if (config != null) { + OtelManager.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(); + } + + @Override + public List getHooks(EnvironmentMetadata metadata) { + if (!options.isEnabled()) { + return Collections.emptyList(); + } + TracingHook hook = new TracingHook.Builder() + .withSpans() + .withValue() + .build(); + return Collections.singletonList(hook); + } +} 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..b2f31f5806 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/Constants.java @@ -0,0 +1,30 @@ +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 + + 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/GraphQLClient.java b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java new file mode 100644 index 0000000000..4a94893961 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/GraphQLClient.java @@ -0,0 +1,120 @@ +package com.launchdarkly.observability.internal; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +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) and Gson. + * + *

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 static final Gson GSON = new Gson(); + + 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" JSON element from the response, or null on error + */ + public JsonElement execute(String query, Map variables) throws IOException { + JsonObject body = new JsonObject(); + body.addProperty("query", query); + body.add("variables", GSON.toJsonTree(variables)); + + byte[] payload = gzip(GSON.toJson(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; + } + + JsonObject envelope = GSON.fromJson(response.body(), JsonObject.class); + if (envelope == null) { + log.warning("GraphQL response had no body"); + return null; + } + if (envelope.has("errors") && !envelope.getAsJsonArray("errors").isEmpty()) { + log.warning("GraphQL errors: " + envelope.getAsJsonArray("errors")); + } + return envelope.has("data") ? envelope.get("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..2b9772f96a --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/OtelManager.java @@ -0,0 +1,349 @@ +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.Attributes; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Tracer; +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.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.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +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(); + private static Thread shutdownHook; + + private final SdkTracerProvider tracerProvider; + private final SdkLoggerProvider loggerProvider; + private final SdkMeterProvider meterProvider; + private final OpenTelemetrySdk openTelemetrySdk; + private final Tracer tracer; + private final Logger logger; + private final Meter meter; + private final CustomSampler customSampler; + + private OtelManager( + SdkTracerProvider tracerProvider, + SdkLoggerProvider loggerProvider, + SdkMeterProvider meterProvider, + OpenTelemetrySdk openTelemetrySdk, + CustomSampler customSampler + ) { + this.tracerProvider = tracerProvider; + this.loggerProvider = loggerProvider; + this.meterProvider = meterProvider; + this.openTelemetrySdk = openTelemetrySdk; + 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.

+ */ + public static void initialize(String sdkKey, ObservabilityOptions options) { + synchronized (INIT_LOCK) { + if (INSTANCE.get() != null) { + log.fine("OtelManager already initialized, skipping"); + return; + } + OtelManager manager = create(sdkKey, options); + INSTANCE.set(manager); + SamplingConfig pending = PENDING_SAMPLING_CONFIG.get(); + if (pending != null) { + manager.customSampler.setConfig(pending); + } + shutdownHook = new Thread(() -> { + OtelManager current = INSTANCE.get(); + if (current != null) { + current.shutdownProviders(); + } + }, "ld-observability-shutdown"); + Runtime.getRuntime().addShutdownHook(shutdownHook); + } + } + + 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(); + + // Trace exporter with sampling + OtlpHttpSpanExporterBuilder spanExporterBuilder = OtlpHttpSpanExporter.builder() + .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); + + SdkTracerProvider tracerProvider = SdkTracerProvider.builder() + .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() + .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); + 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 (e.g., by user's own config). + // Build without registering globally; TracingHook will use whatever + // is globally registered. + log.log(Level.WARNING, + "GlobalOpenTelemetry already set. Building SDK without global registration.", e); + sdk = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setLoggerProvider(loggerProvider) + .setMeterProvider(meterProvider) + .setPropagators(ContextPropagators.create(propagator)) + .build(); + } + + OtelManager manager = new OtelManager( + tracerProvider, loggerProvider, meterProvider, sdk, customSampler); + + if (options.isDebug()) { + log.info("OtelManager initialized with endpoint: " + endpoint); + } + 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); + } + } + + 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, and TracingHook would never see the plugin's exporters. + return OpenTelemetry.noop().getTracer(Constants.INSTRUMENTATION_SCOPE_NAME); + } + + 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 OpenTelemetry.noop().getMeter(Constants.INSTRUMENTATION_SCOPE_NAME); + } + + /** + * 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); + } + if (manager != null) { + manager.shutdownProviders(); + } + } + + 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); + if (shutdownHook != null) { + try { + Runtime.getRuntime().removeShutdownHook(shutdownHook); + } catch (IllegalStateException ignored) { + // JVM shutdown is already in progress. + } + shutdownHook = null; + } + } + if (manager != null) { + manager.shutdownProviders(); + } + } + + private void flushProviders() { + tracerProvider.forceFlush().join(10, TimeUnit.SECONDS); + loggerProvider.forceFlush().join(10, TimeUnit.SECONDS); + meterProvider.forceFlush().join(10, TimeUnit.SECONDS); + } + + private void shutdownProviders() { + flushProviders(); + tracerProvider.shutdown().join(10, TimeUnit.SECONDS); + loggerProvider.shutdown().join(10, TimeUnit.SECONDS); + meterProvider.shutdown().join(10, TimeUnit.SECONDS); + } + + 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..0b5dc20475 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/SamplingApiService.java @@ -0,0 +1,224 @@ +package com.launchdarkly.observability.internal; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +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 java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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 { + Map variables = new HashMap<>(); + variables.put("organization_verbose_id", sdkKey); + JsonElement data = client.execute(QUERY, variables); + if (data == null || !data.isJsonObject()) { + return null; + } + JsonObject sampling = objectOrNull(data.getAsJsonObject(), "sampling"); + if (sampling == null) { + return null; + } + return parseSamplingConfig(sampling); + } catch (Exception e) { + log.log(Level.WARNING, "Failed to fetch sampling config", e); + return null; + } + } + + private SamplingConfig parseSamplingConfig(JsonObject sampling) { + List spans = new ArrayList<>(); + List logs = new ArrayList<>(); + + JsonArray spansArray = arrayOrNull(sampling, "spans"); + if (spansArray != null) { + for (JsonElement el : spansArray) { + if (el.isJsonObject()) { + spans.add(parseSpanConfig(el.getAsJsonObject())); + } + } + } + + JsonArray logsArray = arrayOrNull(sampling, "logs"); + if (logsArray != null) { + for (JsonElement el : logsArray) { + if (el.isJsonObject()) { + logs.add(parseLogConfig(el.getAsJsonObject())); + } + } + } + + return new SamplingConfig(spans, logs); + } + + private SpanSamplingConfig parseSpanConfig(JsonObject obj) { + MatchConfig name = parseMatchConfig(objectOrNull(obj, "name")); + List attributes = parseAttributeConfigs(arrayOrNull(obj, "attributes")); + List events = new ArrayList<>(); + + JsonArray eventsArray = arrayOrNull(obj, "events"); + if (eventsArray != null) { + for (JsonElement el : eventsArray) { + if (!el.isJsonObject()) { + continue; + } + JsonObject eventObj = el.getAsJsonObject(); + events.add(new SpanEventMatchConfig( + parseMatchConfig(objectOrNull(eventObj, "name")), + parseAttributeConfigs(arrayOrNull(eventObj, "attributes")) + )); + } + } + + return new SpanSamplingConfig(name, attributes, events, ratioOrDefault(obj)); + } + + private LogSamplingConfig parseLogConfig(JsonObject obj) { + MatchConfig message = parseMatchConfig(objectOrNull(obj, "message")); + MatchConfig severityText = parseMatchConfig(objectOrNull(obj, "severityText")); + List attributes = parseAttributeConfigs(arrayOrNull(obj, "attributes")); + return new LogSamplingConfig(message, severityText, attributes, ratioOrDefault(obj)); + } + + private List parseAttributeConfigs(JsonArray array) { + if (array == null) return Collections.emptyList(); + List result = new ArrayList<>(); + for (JsonElement el : array) { + if (!el.isJsonObject()) { + continue; + } + JsonObject obj = el.getAsJsonObject(); + result.add(new AttributeMatchConfig( + parseMatchConfig(objectOrNull(obj, "key")), + parseMatchConfig(objectOrNull(obj, "attribute")) + )); + } + return result; + } + + /** + * GraphQL returns {@code null} for optional fields that are unset, and + * Gson's {@code getAsJsonObject} / {@code getAsJsonArray} throw on + * {@code JsonNull}. One null field must not abort the whole payload, so + * every lookup goes through these and treats null like absent. + */ + private static JsonObject objectOrNull(JsonObject parent, String key) { + JsonElement el = parent.get(key); + return el != null && el.isJsonObject() ? el.getAsJsonObject() : null; + } + + private static JsonArray arrayOrNull(JsonObject parent, String key) { + JsonElement el = parent.get(key); + return el != null && el.isJsonArray() ? el.getAsJsonArray() : null; + } + + private static int ratioOrDefault(JsonObject obj) { + JsonElement el = obj.get("samplingRatio"); + if (el == null || !el.isJsonPrimitive() || !el.getAsJsonPrimitive().isNumber()) { + return 1; + } + return el.getAsInt(); + } + + private MatchConfig parseMatchConfig(JsonObject obj) { + if (obj == null) return null; + if (obj.has("regexValue") && !obj.get("regexValue").isJsonNull()) { + return MatchConfig.ofRegex(obj.get("regexValue").getAsString()); + } + if (obj.has("matchValue") && !obj.get("matchValue").isJsonNull()) { + return MatchConfig.ofValue(obj.get("matchValue").getAsString()); + } + return null; + } +} 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..9325e8a41e --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/CustomSampler.java @@ -0,0 +1,193 @@ +package com.launchdarkly.observability.internal.sampling; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +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) { + String severity = log.getSeverity() != null ? log.getSeverity().name() : null; + if (!matchesValue(config.getSeverityText(), severity)) { + return false; + } + } + if (config.getMessage() != null) { + String body = log.getBody() != null ? log.getBody().asString() : null; + if (!matchesValue(config.getMessage(), body)) { + 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) { + for (SamplingConfig.AttributeMatchConfig attrConfig : configs) { + boolean found = false; + Map, Object> attrMap = attributes.asMap(); + for (Map.Entry, Object> entry : attrMap.entrySet()) { + String key = entry.getKey().getKey(); + String value = entry.getValue() != null ? entry.getValue().toString() : null; + if (matchesValue(attrConfig.getKey(), key) && matchesValue(attrConfig.getAttribute(), value)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; + } + + static boolean matchesValue(SamplingConfig.MatchConfig config, String 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..c88c2dd69c --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingConfig.java @@ -0,0 +1,265 @@ +package com.launchdarkly.observability.internal.sampling; + +import java.util.Collections; +import java.util.List; +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. + */ + public static final class MatchConfig { + private static final int MAX_REGEX_LENGTH = 128; + private static final String ALLOWED_REGEX_CHARS = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + + ".*+?^$|()[]{}\\-_:/ @#,"; + + private final String value; + private final String regexPattern; + private final Pattern compiledRegex; + + private MatchConfig(String value, String regexPattern, Pattern compiledRegex) { + this.value = value; + this.regexPattern = regexPattern; + this.compiledRegex = compiledRegex; + } + + public static MatchConfig ofValue(String value) { + return new MatchConfig(value, null, null); + } + + public static MatchConfig ofRegex(String pattern) { + return new MatchConfig(null, pattern, compileBounded(pattern)); + } + + /** + * Compiles a sampling regex once, or returns null when the rule must + * not be used. The pattern comes from LaunchDarkly sampling config + * (or a custom backend URL). A bad pattern never throws and never + * matches. Only an allowlisted copy is compiled, length is capped, + * and nested quantifiers and backreferences are rejected so a rule + * cannot ReDoS the export path. + */ + private static Pattern compileBounded(String pattern) { + if (pattern == null || pattern.isEmpty() || pattern.length() > MAX_REGEX_LENGTH) { + return null; + } + if (isCatastrophic(pattern)) { + return null; + } + String allowlisted = allowlistedCopy(pattern); + if (allowlisted == null) { + return null; + } + try { + // codeql[java/regex-injection]: allowlisted copy of sampling config, length-capped, nested quantifiers rejected + return Pattern.compile(allowlisted); + } catch (PatternSyntaxException ignored) { + return null; + } + } + + private static String allowlistedCopy(String pattern) { + StringBuilder copy = new StringBuilder(pattern.length()); + for (int i = 0; i < pattern.length(); i++) { + int index = ALLOWED_REGEX_CHARS.indexOf(pattern.charAt(i)); + if (index < 0) { + return null; + } + copy.append(ALLOWED_REGEX_CHARS.charAt(index)); + } + return copy.toString(); + } + + private static boolean isCatastrophic(String pattern) { + boolean[] groupHasQuantifier = new boolean[pattern.length() + 1]; + int depth = 0; + boolean prevWasQuantifier = false; + boolean prevWasGroup = false; + boolean prevGroupHadQuantifier = false; + for (int i = 0; i < pattern.length(); i++) { + char c = pattern.charAt(i); + if (c == '\\') { + if (i + 1 >= pattern.length()) { + return true; + } + char escaped = pattern.charAt(++i); + if (escaped >= '1' && escaped <= '9') { + return true; + } + prevWasQuantifier = false; + prevWasGroup = false; + continue; + } + if (c == '(') { + if (i + 1 < pattern.length() && pattern.charAt(i + 1) == '?') { + i++; + if (i + 1 < pattern.length()) { + char flag = pattern.charAt(i + 1); + if (flag == ':' || flag == '=' || flag == '!') { + i++; + } + } + } + if (++depth >= groupHasQuantifier.length) { + return true; + } + groupHasQuantifier[depth] = false; + prevWasQuantifier = false; + prevWasGroup = false; + continue; + } + if (c == ')') { + if (depth <= 0) { + return true; + } + boolean closedHadQuantifier = groupHasQuantifier[depth]; + depth--; + if (closedHadQuantifier && depth > 0) { + groupHasQuantifier[depth] = true; + } + prevGroupHadQuantifier = closedHadQuantifier; + prevWasGroup = true; + prevWasQuantifier = false; + continue; + } + if (c == '?' && prevWasQuantifier) { + prevWasQuantifier = false; + continue; + } + if (c == '*' || c == '+' || c == '?' || c == '{') { + if (prevWasQuantifier || (prevWasGroup && prevGroupHadQuantifier)) { + return true; + } + if (c == '{') { + int end = pattern.indexOf('}', i); + if (end < 0) { + return true; + } + i = end; + } + if (depth > 0) { + groupHasQuantifier[depth] = true; + } + prevWasQuantifier = true; + prevWasGroup = false; + continue; + } + prevWasQuantifier = false; + prevWasGroup = false; + } + return false; + } + + public boolean isRegex() { return regexPattern != null; } + public String getValue() { return value; } + public String getRegexPattern() { return regexPattern; } + + public boolean matches(String actual) { + if (actual == null) { + return false; + } + if (regexPattern != null) { + return compiledRegex != null && compiledRegex.matcher(actual).matches(); + } + return value != null && value.equals(actual); + } + } + + 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..e864f54710 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingLogProcessor.java @@ -0,0 +1,54 @@ +package com.launchdarkly.observability.internal.sampling; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.LogRecordProcessor; +import io.opentelemetry.sdk.logs.ReadWriteLogRecord; + +/** + * A {@link LogRecordProcessor} that applies sampling logic before delegating + * to another processor. + */ +public final class SamplingLogProcessor implements LogRecordProcessor { + + private final LogRecordProcessor delegate; + private final CustomSampler sampler; + + 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 = sampler.sampleLog(logRecord.toLogRecordData()); + if (!result.isSampled()) { + return; + } + + if (result.getAttributes() != null) { + Attributes merged = Attributes.builder() + .putAll(logRecord.toLogRecordData().getAttributes()) + .putAll(result.getAttributes()) + .build(); + logRecord.setAllAttributes(merged); + } + 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..da08237723 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/main/java/com/launchdarkly/observability/internal/sampling/SamplingTraceExporter.java @@ -0,0 +1,168 @@ +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; + +/** + * 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 final SpanExporter delegate; + private final CustomSampler sampler; + + public SamplingTraceExporter(SpanExporter delegate, CustomSampler sampler) { + this.delegate = delegate; + this.sampler = sampler; + } + + @Override + public CompletableResultCode export(Collection spans) { + List sampled = sampleSpans(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..f0c0f64f83 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/ObservabilityPluginTest.java @@ -0,0 +1,125 @@ +package com.launchdarkly.observability; + +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 hook should be a TracingHook instance + assertNotNull(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()); + 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..493ffee72b --- /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.google.gson.JsonElement; +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)); + + JsonElement 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/OtelManagerTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/OtelManagerTest.java new file mode 100644 index 0000000000..33ff8337a1 --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/OtelManagerTest.java @@ -0,0 +1,97 @@ +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.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()); + } + + private static void assertEqualsZero(int failures) { + assertTrue(failures == 0, "concurrent initialize failures: " + failures); + } + + 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/PluginUserFlowTest.java b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java new file mode 100644 index 0000000000..fb37a5736f --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/PluginUserFlowTest.java @@ -0,0 +1,249 @@ +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.Attributes; +import io.opentelemetry.api.logs.Severity; +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.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 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"); + } + } + + @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"); + } + } + + @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)); + } + } + + 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 -> { + traces.add(bodyAsString(exchange)); + 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 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, 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/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..ca110cad3b --- /dev/null +++ b/sdk/@launchdarkly/observability-java/lib/src/test/java/com/launchdarkly/observability/internal/sampling/CustomSamplerTest.java @@ -0,0 +1,202 @@ +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.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.*; + +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 unsafeRegexNeverMatchesAndDoesNotThrow() { + assertDoesNotThrow(() -> SamplingConfig.MatchConfig.ofRegex("(a+)+")); + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("(a+)+"), "aaaa")); + assertDoesNotThrow(() -> SamplingConfig.MatchConfig.ofRegex("((a+))+")); + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("((a+))+"), "aaaa")); + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("("), "x")); + assertFalse(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("a\\1"), "a")); + assertTrue(CustomSampler.matchesValue( + SamplingConfig.MatchConfig.ofRegex("health.*"), "health-check")); + } + + @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/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/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'