Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/java-observability.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: 'Java Observability'

on:
push:
branches: ['main']
paths:
- 'sdk/@launchdarkly/observability-java/**'
- '.github/workflows/java-observability.yml'
pull_request:
types: [opened, synchronize]
paths:
- 'sdk/@launchdarkly/observability-java/**'
- '.github/workflows/java-observability.yml'
Comment thread
cursor[bot] marked this conversation as resolved.

permissions:
contents: read

concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
test:
name: Unit Tests
runs-on: ubuntu-22.04-8core-32gb
defaults:
run:
working-directory: ./sdk/@launchdarkly/observability-java
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

- name: Build and test
run: ./gradlew :lib:build

- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: java-unit-test-results
path: sdk/@launchdarkly/observability-java/**/build/test-results/**/TEST-*.xml
retention-days: 7
40 changes: 40 additions & 0 deletions .github/workflows/java-plugin-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Java Observability Plugin E2E

on:
push:
branches: ['main']
paths:
- 'e2e/java-plugin/**'
- 'sdk/@launchdarkly/observability-java/**'
- '.github/workflows/java-plugin-e2e.yml'
pull_request:
types: [opened, synchronize]
paths:
- 'e2e/java-plugin/**'
- '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

- name: Run the plugin example
run: make run
4 changes: 4 additions & 0 deletions .github/workflows/turbo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,18 @@ jobs:
- '!sdk/@launchdarkly/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/**'
- '!.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'

Expand Down
1 change: 1 addition & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"sdk/@launchdarkly/observability": "1.1.23",
"sdk/@launchdarkly/observability-next": "0.2.9",
"sdk/@launchdarkly/observability-android": "0.69.0",
"sdk/@launchdarkly/observability-java": "0.1.0",
"sdk/@launchdarkly/flutter/packages/observability": "1.0.1",
"sdk/@launchdarkly/observability-dotnet": "1.2.1",
"sdk/@launchdarkly/observability-node": "1.3.0",
Expand Down
2 changes: 2 additions & 0 deletions e2e/java-plugin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.gradle
build
6 changes: 6 additions & 0 deletions e2e/java-plugin/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.PHONY: run

SDK_GRADLE := ../../sdk/@launchdarkly/observability-java/gradlew

run:
$(SDK_GRADLE) -p $(CURDIR) run --no-daemon --console=plain
15 changes: 15 additions & 0 deletions e2e/java-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Java observability plugin example

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 spans.

## Run

From this directory:

```bash
make run
```

That uses the Java SDK's Gradle wrapper and the local `observability-java` project, so the example does not need a published artifact.

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.
22 changes: 22 additions & 0 deletions e2e/java-plugin/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
plugins {
id 'application'
}

repositories {
mavenCentral()
}

java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}

dependencies {
implementation 'com.launchdarkly:launchdarkly-observability-java:0.1.0'
implementation 'com.launchdarkly:launchdarkly-java-server-sdk:7.12.0'
implementation 'io.opentelemetry:opentelemetry-api:1.51.0'
}

application {
mainClass = 'com.launchdarkly.observability.example.PluginExample'
}
7 changes: 7 additions & 0 deletions e2e/java-plugin/settings.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
rootProject.name = 'java-plugin-example'

includeBuild('../../sdk/@launchdarkly/observability-java') {
dependencySubstitution {
substitute module('com.launchdarkly:launchdarkly-observability-java') using project(':lib')
}
}
Original file line number Diff line number Diff line change
@@ -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<String> traces = new CopyOnWriteArrayList<>();
private final List<String> samplingQueries = new CopyOnWriteArrayList<>();
private final List<String> 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<String> payloads, int from, String needle) {
List<String> 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();
}
}
8 changes: 8 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
"include-component-in-tag": true,
"extra-files": ["gradle.properties"]
},
"sdk/@launchdarkly/observability-java": {
"package-name": "launchdarkly-observability-java",
"release-type": "simple",
"versioning": "default",
"include-v-in-tag": false,
"include-component-in-tag": true,
"extra-files": ["gradle.properties"]
},
"sdk/@launchdarkly/flutter/packages/observability": {
"package-name": "launchdarkly_flutter_observability",
"release-type": "dart",
Expand Down
5 changes: 5 additions & 0 deletions sdk/@launchdarkly/observability-java/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Ignore Gradle project-specific cache directory
.gradle

# Ignore Gradle build output directory
build
1 change: 1 addition & 0 deletions sdk/@launchdarkly/observability-java/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
10 changes: 10 additions & 0 deletions sdk/@launchdarkly/observability-java/Makefile
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading