From 3496bc4b289536719bc78695d65ee4f16120ebbc Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 18:13:16 -0700 Subject: [PATCH 01/57] basic project structure --- .circleci/config.yml | 155 +++++++++++++++ .github/ISSUE_TEMPLATE/bug_report.md | 37 ++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 ++ .github/pull_request_template.md | 21 ++ .ldrelease/config.yml | 20 ++ .ldrelease/publish.sh | 7 + CHANGELOG.md | 3 + CONTRIBUTING.md | 74 +++++++ LICENSE | 13 ++ README.md | 28 +++ build-android.gradle | 76 +++++++ build.gradle.kts | 104 ++++++++++ buildSrc/build.gradle.kts | 20 ++ buildSrc/src/main/kotlin/Dependencies.kt | 66 +++++++ buildSrc/src/main/kotlin/ProjectValues.kt | 15 ++ .../src/main/kotlin/TestCoverageOverrides.kt | 24 +++ buildSrc/src/main/kotlin/helpers/Idea.kt | 16 ++ buildSrc/src/main/kotlin/helpers/Jacoco.kt | 52 +++++ buildSrc/src/main/kotlin/helpers/Javadoc.kt | 21 ++ buildSrc/src/main/kotlin/helpers/Pom.kt | 31 +++ buildSrc/src/main/kotlin/helpers/Test.kt | 34 ++++ checkstyle.xml | 15 ++ gradle.properties | 5 + gradle.properties.example | 8 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 185 ++++++++++++++++++ gradlew.bat | 89 +++++++++ settings.gradle.kts | 1 + src/androidTest/AndroidManifest.xml | 14 ++ .../java/com/launchdarkly/sdk/BaseTest.java | 12 ++ src/main/AndroidManifest.xml | 6 + .../sdk/internal/Placeholder.java | 4 + .../sdk/internal/PlaceholderTest.java | 8 + 34 files changed, 1189 insertions(+) create mode 100644 .circleci/config.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 .ldrelease/config.yml create mode 100755 .ldrelease/publish.sh create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build-android.gradle create mode 100644 build.gradle.kts create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/kotlin/Dependencies.kt create mode 100644 buildSrc/src/main/kotlin/ProjectValues.kt create mode 100644 buildSrc/src/main/kotlin/TestCoverageOverrides.kt create mode 100644 buildSrc/src/main/kotlin/helpers/Idea.kt create mode 100644 buildSrc/src/main/kotlin/helpers/Jacoco.kt create mode 100644 buildSrc/src/main/kotlin/helpers/Javadoc.kt create mode 100644 buildSrc/src/main/kotlin/helpers/Pom.kt create mode 100644 buildSrc/src/main/kotlin/helpers/Test.kt create mode 100644 checkstyle.xml create mode 100644 gradle.properties create mode 100644 gradle.properties.example create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 src/androidTest/AndroidManifest.xml create mode 100644 src/androidTest/java/com/launchdarkly/sdk/BaseTest.java create mode 100644 src/main/AndroidManifest.xml create mode 100644 src/main/java/com/launchdarkly/sdk/internal/Placeholder.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/PlaceholderTest.java diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..2cb624d --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,155 @@ +version: 2.1 + +orbs: + win: circleci/windows@1.0.0 + android: circleci/android@1.0 + +workflows: + test: + jobs: + - build-linux + - test-linux: + name: Java 8 - Linux - OpenJDK + docker-image: cimg/openjdk:8.0 + requires: + - build-linux + - test-linux: + name: Java 11 - Linux - OpenJDK + docker-image: cimg/openjdk:11.0 + requires: + - build-linux + - test-linux: + name: Java 17 - Linux - OpenJDK + docker-image: cimg/openjdk:17.0 + with-coverage: true + requires: + - build-linux + - build-test-windows: + name: Java 11 - Windows - OpenJDK + openjdk-version: 11.0.2.01 + - build-test-windows: + name: Java 17 - Windows - OpenJDK + openjdk-version: 17.0.1 + - build-test-android: + name: Android + +jobs: + build-linux: + docker: + - image: cimg/openjdk:8.0 + steps: + - checkout + - run: cp gradle.properties.example gradle.properties + - run: java -version + - run: ./gradlew dependencies + - run: ./gradlew jar + - run: ./gradlew javadoc + - run: ./gradlew checkstyleMain + - persist_to_workspace: + root: build + paths: + - classes + + test-linux: + parameters: + docker-image: + type: string + with-coverage: + type: boolean + default: false + docker: + - image: <> + steps: + - checkout + - run: cp gradle.properties.example gradle.properties + - attach_workspace: + at: build + - run: java -version + - run: ./gradlew test + - when: + condition: <> + steps: + - run: + name: Generate test coverage report + command: | + ./gradlew jacocoTestReport + mkdir -p coverage/ + cp -r build/reports/jacoco/test/* ./coverage + - run: + name: Enforce test coverage + command: ./gradlew jacocoTestCoverageVerification + - run: + name: Save test results + command: | + mkdir -p ~/junit/; + find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ \; + when: always + - store_test_results: + path: ~/junit + - store_artifacts: + path: ~/junit + - when: + condition: <> + steps: + - store_artifacts: + path: coverage + + build-test-windows: + parameters: + openjdk-version: + type: string + executor: + name: win/vs2019 + shell: powershell.exe + steps: + - checkout + - run: + name: uninstall previous openjdk + command: choco uninstall openjdk + - run: + name: install OpenJDK + command: choco install openjdk --version <> + - run: + name: build and test + command: | + cp gradle.properties.example gradle.properties + .\gradlew.bat --no-daemon test # must use --no-daemon because CircleCI in Windows will hang if there's a daemon running + - run: + name: save test results + command: | + mkdir .\junit + cp build/test-results/test/*.xml junit + - store_test_results: + path: .\junit + - store_artifacts: + path: .\junit + + build-test-android: + executor: + name: android/android-machine + resource-class: large + + steps: + - checkout + + # What we want to do here is somewhat unusual: we want Android to run all of our tests from + # src/test/java, but run them in the Android emulator (to prove that we're only using Java + # APIs that our minimum Android API version supports). Normally, only tests in + # src/androidTest/java would be run that way. Also, Android needs a different JUnit test + # runner annotation on all of the test classes. So we can't just run the test code as-is. + # + # This step copies all the code from src/test/java into src/androidTest/java, except for the + # base class BaseTest.java, which is already defined in src/androidTest/java to provide the + # necessary test runner annotation. + - run: + name: Copy tests + command: rsync -r ./src/test/java/ ./src/androidTest/java/ --exclude='BaseTest.java' + + - android/start-emulator-and-run-tests: + system-image: system-images;android-21;default;x86 + max-tries: 1 + post-emulator-launch-assemble-command: ./gradlew -b build-android.gradle :assembleAndroidTest + test-command: ./gradlew -b build-android.gradle :connectedAndroidTest + + - store_test_results: + path: ./build/outputs/androidTest-results diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..6b1d30d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,37 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Is this a support request?** +This issue tracker is maintained by LaunchDarkly SDK developers and is intended for feedback on the SDK code. If you're not sure whether the problem you are having is specifically related to the SDK, or to the LaunchDarkly service overall, it may be more appropriate to contact the LaunchDarkly support team; they can help to investigate the problem and will consult the SDK team if necessary. You can submit a support request by going [here](https://support.launchdarkly.com/) and clicking "submit a request", or by emailing support@launchdarkly.com. + +Note that issues filed on this issue tracker are publicly accessible. Do not provide any private account information on your issues. If your problem is specific to your account, you should submit a support request as described above. + +**Describe the bug** +A clear and concise description of what the bug is. + +**To reproduce** +Steps to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +If applicable, add any log output related to your problem. + +**SDK version** +The version of this SDK that you are using. + +**Language version, developer tools** +For instance, Go 1.11 or Ruby 2.5.3. If you are using a language that requires a separate compiler, such as C, please include the name and version of the compiler too. + +**OS/platform** +For instance, Ubuntu 16.04, Windows 10, or Android 4.0.3. If your code is running in a browser, please also include the browser type and version. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..3f7d5bf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I would love to see the SDK [...does something new...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context about the feature request here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1980676 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +**Requirements** + +- [ ] I have added test coverage for new or changed functionality +- [ ] I have followed the repository's [pull request submission guidelines](../blob/master/CONTRIBUTING.md#submitting-pull-requests) +- [ ] I have validated my changes against all supported platform versions + +**Related issues** + +Provide links to any issues in this repository or elsewhere relating to this pull request. + +**Describe the solution you've provided** + +Provide a clear and concise description of what you expect to happen. + +**Describe alternatives you've considered** + +Provide a clear and concise description of any alternative solutions or features you've considered. + +**Additional context** + +Add any other context about the pull request here. diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml new file mode 100644 index 0000000..9e85d61 --- /dev/null +++ b/.ldrelease/config.yml @@ -0,0 +1,20 @@ +version: 2 + +repo: + public: java-sdk-internal + private: java-sdk-internal-private + +publications: + - url: https://oss.sonatype.org/content/groups/public/com/launchdarkly/launchdarkly-java-sdk-internal/ + description: Sonatype + - url: https://javadoc.io/doc/com.launchdarkly/launchdarkly-java-sdk-internal + description: documentation (javadoc.io) + +jobs: + - docker: + image: gradle:6.8.3-jdk11 + template: + name: gradle + +documentation: + gitHubPages: true diff --git a/.ldrelease/publish.sh b/.ldrelease/publish.sh new file mode 100755 index 0000000..a2e9637 --- /dev/null +++ b/.ldrelease/publish.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -ue + +# Publish to Sonatype +echo "Publishing to Sonatype" +./gradlew publishToSonatype closeAndReleaseRepository || { echo "Gradle publish/release failed" >&2; exit 1; } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..22477f5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Change log + +All notable changes to the project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f15c8d6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,74 @@ +# Contributing to the LaunchDarkly SDK Java Internal Common Code + +LaunchDarkly has published an [SDK contributor's guide](https://docs.launchdarkly.com/sdk/concepts/contributors-guide) that provides a detailed explanation of how our SDKs work. See below for additional information on how to contribute to this project. + +## Submitting bug reports and feature requests + +The LaunchDarkly SDK team monitors the [issue tracker](https://github.com/launchdarkly/java-sdk-internal/issues) in the GitHub repository. Bug reports and feature requests specific to this project should be filed in this issue tracker. The SDK team will respond to all newly filed issues within two business days. + +## Submitting pull requests + +We encourage pull requests and other contributions from the community. Before submitting pull requests, ensure that all temporary or unintended code is removed. Don't worry about adding reviewers to the pull request; the LaunchDarkly SDK team will add themselves. The SDK team will acknowledge all pull requests within two business days. + +## Access modifiers + +The types in this library are meant to be consumed by our SDK code, and never seen by application developers. However, for any type that will be referenced directly from SDK code (as opposed to an implementation detail that is only referenced from within the `java-sdk-internal` code), the access modifier must be `public`. These types cannot be package-private, because we need to be able to access them from SDK code in multiple packages (e.g. `com.launchdarkly.sdk.server` versus `com.launchdarkly.sdk.android`). + +That means it is technically possible for application code to see these types; the compiler will not stop a developer from referencing them. However: + +1. We are explicitly declaring all APIs in this library to be unsupported for customer use, so any such use is at the developer's own risk. +2. Generated Javadoc documentation for the SDKs will not show these types, since they are in a dependency of the SDK rather than in the main SDK jar (and, in the case of the server-side Java SDK, these classes are obfuscated via shading). + +## Versioning + +The semantic versioning of this package refers to how the package is used from the point of view of internal SDK code. This is intentionally decoupled from the versioning of the SDKs themselves. + +If a feature is added for the SDKs to use, such as a new helper class or a new overload of an existing method, then a minor version increment is appropriate. That does _not_ mean that the SDKs themselves would have a minor version increment, unless they are exposing some new functionality for application code to use. + +If a change is made that is not backward-compatible, so SDK code will need to be modified to be able to use the new release, then a major version increment is appropriate. Again, that does _not_ mean that the SDKs themselves would have a major version increment, unless they have a breaking change from the point of view of application code. + +## Build instructions + +### Prerequisites + +The project builds with [Gradle](https://gradle.org/) and should be built against Java 8. + +### Building + +To build the project without running any tests: +``` +./gradlew jar +``` + +If you wish to clean your working directory between builds, you can clean it by running: +``` +./gradlew clean +``` + +If you wish to use your generated SDK artifact by another Maven/Gradle project such as [java-server-sdk](https://github.com/launchdarkly/java-server-sdk), you will likely want to publish the artifact to your local Maven repository so that your other project can access it. +``` +./gradlew publishToMavenLocal +``` + +### Testing + +To build the project and run all unit tests: +``` +./gradlew test +``` + +## Note on Java version and Android support + +This project can be used both in server-side Java and in Android. Its minimum Java version is 8, but not all Java 8 APIs and syntax are supported in Android. The CI jobs for this project include an Android job that runs all of the unit tests in Android, to verify that no unsupported APIs are being used. + +## Code coverage + +It is important to keep unit test coverage as close to 100% as possible in this project, since the SDK projects will not exercise every `java-sdk-internal` method in their own unit tests. + +You can view the latest code coverage report in CircleCI, as `coverage/html/index.html` in the artifacts for the "Java 11 - Linux - OpenJDK" job. You can also run the report locally with `./gradlew jacocoTestCoverage` and view `./build/reports/jacoco/test`. + +Sometimes a gap in coverage is unavoidable, usually because the compiler requires us to provide a code path for some condition that in practice can't happen and can't be tested, or because of a known issue with the code coverage tool. Please handle all such cases as follows: + +* Mark the code with an explanatory comment beginning with "COVERAGE:". +* Run the code coverage task with `./gradlew jacocoTestCoverageVerification`. It should fail and indicate how many lines of missed coverage exist in the method you modified. +* Add an item in the `knownMissedLinesForMethods` map in `build.gradle` that specifies that number of missed lines for that method signature. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d238a2b --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2022 Catamorphic, Co. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..18d6561 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# LaunchDarkly SDK Java Internal Common Code + +[![Circle CI](https://circleci.com/gh/launchdarkly/java-sdk-internal.svg?style=shield)](https://circleci.com/gh/launchdarkly/java-sdk-internal) +[![Javadocs](http://javadoc.io/badge/com.launchdarkly/launchdarkly-java-sdk-internal.svg)](http://javadoc.io/doc/com.launchdarkly/launchdarkly-java-sdk-internal) + +This project contains Java classes and interfaces that are shared between the LaunchDarkly Java and Android SDKs. These are internal implementation details that are not part of the supported SDK APIs and should not be used by application code; no types in this package should be exposed directly in the public APIs. Code that is specific to one or the other SDK is in [java-server-sdk](https://github.com/launchdarkly/java-server-sdk) or [android-client-sdk](https://github.com/launchdarkly/android-client-sdk), and public APIs that are common to both are in [java-sdk-common](https://github.com/launchdarkly/java-sdk-common). + +## Supported Java versions + +This version of the library works with Java 8 and above, or Android. + +## Contributing + +See [Contributing](./CONTRIBUTING.md). + +## About LaunchDarkly + +* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. +* Explore LaunchDarkly + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation + * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates diff --git a/build-android.gradle b/build-android.gradle new file mode 100644 index 0000000..a6f0ca9 --- /dev/null +++ b/build-android.gradle @@ -0,0 +1,76 @@ +apply plugin: 'com.android.library' +//apply plugin: 'com.github.dcendents.android-maven' + +buildscript { + repositories { + mavenCentral() + mavenLocal() + google() + } + dependencies { + classpath 'com.android.tools.build:gradle:4.2.0' + } +} +// This Gradle script is used only when we are running tests in an Android environment to verify +// that the project is Android-compatible. We do not publish an Android build - that is done in +// the android-client-sdk project. + +repositories { + mavenLocal() + // Before LaunchDarkly release artifacts get synced to Maven Central they are here along with snapshots: + maven { url "https://oss.sonatype.org/content/groups/public/" } + mavenCentral() + google() +} + +allprojects { + group = ProjectValues.groupId + version = "${version}" // from gradle.properties + archivesBaseName = ProjectValues.artifactId +} + +android { + compileSdkVersion 26 + buildToolsVersion '28.0.3' + + defaultConfig { + minSdkVersion 16 + targetSdkVersion 26 + versionCode 1 + versionName version + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + consumerProguardFiles 'consumer-proguard-rules.pro' + + // The following argument makes the Android Test Orchestrator run its + // "pm clear" command after each test invocation. This command ensures + // that the app's state is completely cleared between tests. + testInstrumentationRunnerArguments clearPackageData: 'true' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + packagingOptions { + exclude 'META-INF/**' + exclude 'META-INF/**' + } + + dexOptions { + javaMaxHeapSize "4g" + } +} + +dependencies { // see Dependencies.kt in buildSrc + // See note in Dependencies.kt in buildSrc on the purpose of "privateImplementation". + // The Android Gradle plugin doesn't seem to have a good way to customize the classpath + // of the compile and test tasks, but since we're not actually publishing any Android + // artifacts from this project (we use this build only for CI testing), we can simply + // copy the dependencies from "privateImplementation" into the standard "implementation" + // for the Android build. + Libs.implementation.each { implementation(it) } + Libs.privateImplementation.each { implementation(it) } + Libs.javaTestImplementation.each { testImplementation(it) } + Libs.androidTestImplementation.each { androidTestImplementation(it) } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..76d9abf --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,104 @@ +import java.time.Duration + +// These values come from gradle.properties +val ossrhUsername: String by project +val ossrhPassword: String by project + +buildscript { + repositories { + mavenCentral() + mavenLocal() + } +} + +plugins { // see Dependencies.kt in buildSrc + Libs.javaBuiltInGradlePlugins.forEach { id(it) } + Libs.javaExtGradlePlugins.forEach { (n, v) -> id(n) version v } +} + +repositories { + mavenLocal() + // Before LaunchDarkly release artifacts get synced to Maven Central they are here along with snapshots: + maven { url = uri("https://oss.sonatype.org/content/groups/public/") } + mavenCentral() +} + +configurations.all { + // check for updates every build for dependencies with: 'changing: true' + resolutionStrategy.cacheChangingModulesFor(0, "seconds") +} + +base { + group = ProjectValues.groupId + archivesBaseName = ProjectValues.artifactId + version = version +} + +java { + withJavadocJar() + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +// See Dependencies.kt in buildSrc for the purpose of "privateImplementation" +val privateImplementation by configurations.creating + +dependencies { // see Dependencies.kt in buildSrc + Libs.implementation.forEach { implementation(it)} + Libs.privateImplementation.forEach { privateImplementation(it)} + Libs.javaTestImplementation.forEach { testImplementation(it) } +} + +checkstyle { + configFile = file("${project.rootDir}/checkstyle.xml") +} + +tasks.compileJava { + // See note in Dependencies.kt in buildSrc on "privateImplementation" + classpath = configurations["privateImplementation"] +} + +helpers.Javadoc.configureTask(tasks.javadoc, configurations["privateImplementation"]) // see Javadoc.kt in buildSrc + +helpers.Test.configureTask(tasks.compileTestJava, tasks.test, + configurations["privateImplementation"]) // see Test.kt in buildSrc + +helpers.Jacoco.configureTasks( // see Jacoco.kt in buildSrc + tasks.jacocoTestReport, + tasks.jacocoTestCoverageVerification +) + +helpers.Idea.configure(idea) + +publishing { + publications { + create("mavenJava") { + from(components["java"]) + + helpers.Pom.standardPom(pom) // see Pom.kt in buildSrc + } + } + repositories { + mavenLocal() + } +} + +nexusStaging { + packageGroup = ProjectValues.groupId + numberOfRetries = 40 // we've seen extremely long delays in closing repositories +} + +nexusPublishing { + clientTimeout.set(Duration.ofMinutes(2)) // we've seen extremely long delays in creating repositories + repositories { + sonatype { + username.set(ossrhUsername) + password.set(ossrhPassword) + } + } +} + +signing { + sign(publishing.publications["mavenJava"]) +} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..8fe36d0 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,20 @@ + +// This build script controls the building of the shared Gradle code in +// buildSrc. Putting code under buildSrc allows us to break it up for better +// clarity, leaving a much simpler build script at the top level of the repo. + +// For the java-sdk-common project, this also allows us to share some values +// between build.gradle.kts and build-android.gradle in a clearer way than +// the old method of including a shared build script. + +// Things that are specific to this project, like dependencies, are in +// buildSrc/src/main/kotlin. Reusable helper code that isn't specific to this +// project is in buildSrc/src/main/kotlin/helpers. + +plugins { + `kotlin-dsl` +} + +repositories { + gradlePluginPortal() +} diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt new file mode 100644 index 0000000..c305af7 --- /dev/null +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -0,0 +1,66 @@ + +// Centralize dependencies here instead of writing them out in the top-level +// build script(s). + +object Versions { + const val gson = "2.8.9" + const val guava = "30.1-jre" + const val launchdarklyJavaSdkCommon = "1.3.0" + const val okhttp = "4.9.1" + const val slf4j = "1.7.21" +} + +object PluginVersions { + const val nexusPublish = "0.3.0" + const val nexusStaging = "0.30.0" +} + +object Libs { + val implementation = listOf( + // We would put anything here that we want to go into the Gradle "implementation" + // configuration, if and only if we want those things to show up in pom.xml. + "com.google.code.gson:gson:${Versions.gson}", + "com.google.guava:guava:${Versions.guava}", + "com.launchdarkly:launchdarkly-java-sdk-common:${Versions.launchdarklyJavaSdkCommon}", + "com.squareup.okhttp3:okhttp:${Versions.okhttp}", + "org.slf4j:slf4j-api:${Versions.slf4j}" + ) + + val javaTestImplementation = listOf( + "org.hamcrest:hamcrest-library:1.3", + "junit:junit:4.12", + "com.launchdarkly:test-helpers:1.1.0" + ) + + val androidTestImplementation = javaTestImplementation + listOf( + "com.android.support.test:runner:1.0.2" + ) + + val privateImplementation = listOf( + // These will be used in the compile-time classpath, but they should *not* be put in + // the usual Gradle "implementation" configuration, because we don't want them to be + // visible at all in the module's published dependencies - not even in "runtime" scope. + // + // While java-sdk-internal does need Gson in order to work, the LaunchDarkly SDKs that + // use java-sdk-internal have different strategies for packaging Gson. The Android SDK + // exposes it as a regular dependency; the Java server-side SDK embeds and shades Gson + // and does not expose it as a dependency. So we are leaving it up to the SDK to + // provide Gson in some way. + "com.google.code.gson:gson:${Versions.gson}" + ) + + val javaBuiltInGradlePlugins = listOf( + "java", + "java-library", + "checkstyle", + "signing", + "maven-publish", + "idea", + "jacoco" + ) + + val javaExtGradlePlugins = mapOf( + "de.marcphilipp.nexus-publish" to PluginVersions.nexusPublish, + "io.codearte.nexus-staging" to PluginVersions.nexusStaging + ) +} diff --git a/buildSrc/src/main/kotlin/ProjectValues.kt b/buildSrc/src/main/kotlin/ProjectValues.kt new file mode 100644 index 0000000..ec724a4 --- /dev/null +++ b/buildSrc/src/main/kotlin/ProjectValues.kt @@ -0,0 +1,15 @@ + +// This file defines basic properties of the project that are used in the +// build script and the helper code. + +object ProjectValues { + const val groupId = "com.launchdarkly" + const val artifactId = "launchdarkly-java-sdk-internal" + const val description = "LaunchDarkly SDK Java Shared Implementation" + const val githubRepo = "launchdarkly/java-sdk-internal" + + const val sdkBasePackage = "com.launchdarkly.sdk" + + const val pomDeveloperName = "LaunchDarkly SDK Team" + const val pomDeveloperEmail = "sdks@launchdarkly.com" +} diff --git a/buildSrc/src/main/kotlin/TestCoverageOverrides.kt b/buildSrc/src/main/kotlin/TestCoverageOverrides.kt new file mode 100644 index 0000000..b1e365b --- /dev/null +++ b/buildSrc/src/main/kotlin/TestCoverageOverrides.kt @@ -0,0 +1,24 @@ + +// See notes in CONTRIBUTING.md on code coverage. Unfortunately we can't configure +// line-by-line code coverage overrides within the source code itself, because Jacoco +// operates on bytecode. + +// These values are used by helpers/Jacoco.kt. + +object TestCoverageOverrides { + val prefixForAllMethodSignatures = ProjectValues.sdkBasePackage + "." + + // // Each entry in methodsWithMissedLineCount is an override to tell the Jacoco plugin + // // that we're aware of a gap in our test coverage and are OK with it. In each entry, + // // the key is the method signature and the value is the number of lines that we + // // expect Jacoco to report as missed. + // val methodsWithMissedLineCount = mapOf( + + // ).mapKeys { prefixForAllMethodSignatures + it.key } + + // // Each entry in methodsToSkip is an override to tell the Jacoco plugin to ignore + // // code coverage in the method with the specified signature. + // val methodsToSkip = listOf( + + // ).map { prefixForAllMethodSignatures + it } +} diff --git a/buildSrc/src/main/kotlin/helpers/Idea.kt b/buildSrc/src/main/kotlin/helpers/Idea.kt new file mode 100644 index 0000000..c2f85d5 --- /dev/null +++ b/buildSrc/src/main/kotlin/helpers/Idea.kt @@ -0,0 +1,16 @@ +package helpers + +import org.gradle.api.tasks.TaskProvider +import org.gradle.plugins.ide.idea.model.IdeaModel + +// Idea.configure provides reusable configuration logic for the Idea +// behavior we normally use. + +object Idea { + fun configure(ideaModel: IdeaModel) { + ideaModel.module { + isDownloadJavadoc = true + isDownloadSources = true + } + } +} diff --git a/buildSrc/src/main/kotlin/helpers/Jacoco.kt b/buildSrc/src/main/kotlin/helpers/Jacoco.kt new file mode 100644 index 0000000..d1dfbb3 --- /dev/null +++ b/buildSrc/src/main/kotlin/helpers/Jacoco.kt @@ -0,0 +1,52 @@ +package helpers + +import org.gradle.api.tasks.TaskProvider +import org.gradle.testing.jacoco.tasks.JacocoReport +import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification + +// Jacoco.configureTasks provides reusable configuration logic for using the Jacoco +// test coverage plugin in a Java project. See also: TestCoverageOverrides.kt + +object Jacoco { + fun configureTasks(reportTask: TaskProvider, + verificationTask: TaskProvider) { + reportTask.configure { + reports { + xml.isEnabled = true + csv.isEnabled = true + html.isEnabled = true + } + } + + verificationTask.configure { + // See notes in CONTRIBUTING.md on code coverage. Unfortunately we can't configure line-by-line code + // coverage overrides within the source code itself, because Jacoco operates on bytecode. + violationRules { + // TestCoverageOverrides.methodsWithMissedLineCount.forEach { signature, maxMissedLines -> + // rule { + // element = "METHOD" + // includes = listOf(signature) + // limit { + // counter = "LINE" + // value = "MISSEDCOUNT" + // maximum = maxMissedLines.toBigDecimal() + // } + // } + // } + + // General rule that we should expect 100% test coverage; exclude any methods that + // have overrides in TestCoverageOverrides. + rule { + element = "METHOD" + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = 0.toBigDecimal() + } + // excludes = TestCoverageOverrides.methodsWithMissedLineCount.map { it.key } + + // TestCoverageOverrides.methodsToSkip + } + } + } + } +} diff --git a/buildSrc/src/main/kotlin/helpers/Javadoc.kt b/buildSrc/src/main/kotlin/helpers/Javadoc.kt new file mode 100644 index 0000000..2549ebc --- /dev/null +++ b/buildSrc/src/main/kotlin/helpers/Javadoc.kt @@ -0,0 +1,21 @@ +package helpers + +import org.gradle.api.artifacts.Configuration +import org.gradle.api.tasks.TaskProvider +import org.gradle.api.tasks.javadoc.Javadoc +import org.gradle.external.javadoc.CoreJavadocOptions + +object Javadoc { + fun configureTask(javadocTask: TaskProvider, classpathConfig: Configuration?) { + javadocTask.configure { + // Force the Javadoc build to fail if there are any Javadoc warnings. See: https://discuss.gradle.org/t/javadoc-fail-on-warning/18141/3 + // See JDK-8200363 (https://bugs.openjdk.java.net/browse/JDK-8200363) + // for information about the -Xwerror option. + (options as CoreJavadocOptions).addStringOption("Xwerror") + + if (classpathConfig != null) { + classpath += classpathConfig + } + } + } +} diff --git a/buildSrc/src/main/kotlin/helpers/Pom.kt b/buildSrc/src/main/kotlin/helpers/Pom.kt new file mode 100644 index 0000000..ac9906e --- /dev/null +++ b/buildSrc/src/main/kotlin/helpers/Pom.kt @@ -0,0 +1,31 @@ +package helpers + +import org.gradle.api.publish.maven.MavenPom + +// Pom.standardPom provides reusable logic for setting the pom.xml properties +// of LaunchDarkly packages. It gets its values from ProjectValues.kt. + +object Pom { + fun standardPom(pom: MavenPom) { + pom.name.set(ProjectValues.artifactId) + pom.description.set(ProjectValues.description) + pom.url.set("https://github.com/${ProjectValues.githubRepo}") + pom.licenses { + license { + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + name.set("The Apache License, Version 2.0") + } + } + pom.developers { + developer { + name.set(ProjectValues.pomDeveloperName) + email.set(ProjectValues.pomDeveloperEmail) + } + } + pom.scm { + connection.set("scm:git:git://github.com/${ProjectValues.githubRepo}.git") + developerConnection.set("scm:git:ssh:git@github.com:${ProjectValues.githubRepo}.git") + url.set("https://github.com/${ProjectValues.githubRepo}") + } + } +} diff --git a/buildSrc/src/main/kotlin/helpers/Test.kt b/buildSrc/src/main/kotlin/helpers/Test.kt new file mode 100644 index 0000000..c213db5 --- /dev/null +++ b/buildSrc/src/main/kotlin/helpers/Test.kt @@ -0,0 +1,34 @@ +package helpers + +import org.gradle.api.artifacts.Configuration +import org.gradle.api.tasks.TaskProvider +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.api.tasks.testing.Test +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + +// Test.configureTask provides reusable configuration logic for the Java test +// behavior we normally use. + +object Test { + fun configureTask(compileTestTask: TaskProvider, testTask: TaskProvider, + classpathConfig: Configuration?) { + + compileTestTask.configure { + if (classpathConfig != null) { + classpath += classpathConfig + } + } + + testTask.configure { + testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + exceptionFormat = TestExceptionFormat.FULL + } + + if (classpathConfig != null) { + classpath += classpathConfig + } + } + } +} diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..0b201f9 --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..62092fd --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +version=1.0.0 +# The following empty ossrh properties are used by LaunchDarkly's internal integration testing framework +# and should not be needed for typical development purposes (including by third-party developers). +ossrhUsername= +ossrhPassword= diff --git a/gradle.properties.example b/gradle.properties.example new file mode 100644 index 0000000..058697d --- /dev/null +++ b/gradle.properties.example @@ -0,0 +1,8 @@ +# To release a version of this SDK, copy this file to ~/.gradle/gradle.properties and fill in the values. +githubUser = YOUR_GITHUB_USERNAME +githubPassword = YOUR_GITHUB_PASSWORD +signing.keyId = 5669D902 +signing.password = SIGNING_PASSWORD +signing.secretKeyRingFile = SECRET_RING_FILE +ossrhUsername = launchdarkly +ossrhPassword = OSSHR_PASSWORD diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2e6e589 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or 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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# 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"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# 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 + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# 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" + which java >/dev/null 2>&1 || 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 + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@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 + +@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=. +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%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="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! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..58750cc --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "launchdarkly-java-sdk-internal" diff --git a/src/androidTest/AndroidManifest.xml b/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..1ef6096 --- /dev/null +++ b/src/androidTest/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/androidTest/java/com/launchdarkly/sdk/BaseTest.java b/src/androidTest/java/com/launchdarkly/sdk/BaseTest.java new file mode 100644 index 0000000..7334fae --- /dev/null +++ b/src/androidTest/java/com/launchdarkly/sdk/BaseTest.java @@ -0,0 +1,12 @@ +package com.launchdarkly.sdk; + +import android.support.test.runner.AndroidJUnit4; +import org.junit.runner.RunWith; + +/** + * When running our unit tests in Android, we substitute this version of BaseTest which provides + * the correct test runner. + */ +@RunWith(AndroidJUnit4.class) +public abstract class BaseTest { +} diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml new file mode 100644 index 0000000..03982d1 --- /dev/null +++ b/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/java/com/launchdarkly/sdk/internal/Placeholder.java b/src/main/java/com/launchdarkly/sdk/internal/Placeholder.java new file mode 100644 index 0000000..5b14df5 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/Placeholder.java @@ -0,0 +1,4 @@ +package com.launchdarkly.sdk.internal; + +public class Placeholder { +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/PlaceholderTest.java b/src/test/java/com/launchdarkly/sdk/internal/PlaceholderTest.java new file mode 100644 index 0000000..8d1a6e9 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/PlaceholderTest.java @@ -0,0 +1,8 @@ +package com.launchdarkly.sdk.internal; + +import org.junit.Test; + +public class PlaceholderTest { + @Test + public void placeholder() {} +} From a4134195c982a8637c093cdedd4b8d60ac856993 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 18:24:50 -0700 Subject: [PATCH 02/57] javadoc --- .../java/com/launchdarkly/sdk/internal/package-info.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/java/com/launchdarkly/sdk/internal/package-info.java diff --git a/src/main/java/com/launchdarkly/sdk/internal/package-info.java b/src/main/java/com/launchdarkly/sdk/internal/package-info.java new file mode 100644 index 0000000..11767f4 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/package-info.java @@ -0,0 +1,9 @@ +/** + * General-purpose helper types and methods for use in LaunchDarkly SDK code. + *

+ * All symbols in this package are for internal SDK use only. They are subject to change + * without notice, and any direct reference to them in application code is unsupported. + * They have public scope only because they need to be available to LaunchDarkly SDK + * code in other packages. + */ +package com.launchdarkly.sdk.internal; From a22fc02c839841dd5dd4e419937c042e6207715b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 18:33:25 -0700 Subject: [PATCH 03/57] misc build fixes --- buildSrc/src/main/kotlin/ProjectValues.kt | 2 +- .../src/main/kotlin/TestCoverageOverrides.kt | 24 +++++++------- buildSrc/src/main/kotlin/helpers/Jacoco.kt | 32 +++++++++---------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectValues.kt b/buildSrc/src/main/kotlin/ProjectValues.kt index ec724a4..74f518b 100644 --- a/buildSrc/src/main/kotlin/ProjectValues.kt +++ b/buildSrc/src/main/kotlin/ProjectValues.kt @@ -8,7 +8,7 @@ object ProjectValues { const val description = "LaunchDarkly SDK Java Shared Implementation" const val githubRepo = "launchdarkly/java-sdk-internal" - const val sdkBasePackage = "com.launchdarkly.sdk" + const val sdkBasePackage = "com.launchdarkly.sdk.internal" const val pomDeveloperName = "LaunchDarkly SDK Team" const val pomDeveloperEmail = "sdks@launchdarkly.com" diff --git a/buildSrc/src/main/kotlin/TestCoverageOverrides.kt b/buildSrc/src/main/kotlin/TestCoverageOverrides.kt index b1e365b..b72c873 100644 --- a/buildSrc/src/main/kotlin/TestCoverageOverrides.kt +++ b/buildSrc/src/main/kotlin/TestCoverageOverrides.kt @@ -8,17 +8,17 @@ object TestCoverageOverrides { val prefixForAllMethodSignatures = ProjectValues.sdkBasePackage + "." - // // Each entry in methodsWithMissedLineCount is an override to tell the Jacoco plugin - // // that we're aware of a gap in our test coverage and are OK with it. In each entry, - // // the key is the method signature and the value is the number of lines that we - // // expect Jacoco to report as missed. - // val methodsWithMissedLineCount = mapOf( + // Each entry in methodsWithMissedLineCount is an override to tell the Jacoco plugin + // that we're aware of a gap in our test coverage and are OK with it. In each entry, + // the key is the method signature and the value is the number of lines that we + // expect Jacoco to report as missed. + val methodsWithMissedLineCount = mapOf( + "Placeholder.Placeholder()" to 1 + ).mapKeys { prefixForAllMethodSignatures + it.key } - // ).mapKeys { prefixForAllMethodSignatures + it.key } - - // // Each entry in methodsToSkip is an override to tell the Jacoco plugin to ignore - // // code coverage in the method with the specified signature. - // val methodsToSkip = listOf( - - // ).map { prefixForAllMethodSignatures + it } + // Each entry in methodsToSkip is an override to tell the Jacoco plugin to ignore + // code coverage in the method with the specified signature. + val methodsToSkip = listOf( + "" + ).map { prefixForAllMethodSignatures + it } } diff --git a/buildSrc/src/main/kotlin/helpers/Jacoco.kt b/buildSrc/src/main/kotlin/helpers/Jacoco.kt index d1dfbb3..a824418 100644 --- a/buildSrc/src/main/kotlin/helpers/Jacoco.kt +++ b/buildSrc/src/main/kotlin/helpers/Jacoco.kt @@ -12,9 +12,9 @@ object Jacoco { verificationTask: TaskProvider) { reportTask.configure { reports { - xml.isEnabled = true - csv.isEnabled = true - html.isEnabled = true + xml.required.set(true) + csv.required.set(true) + html.required.set(true) } } @@ -22,17 +22,17 @@ object Jacoco { // See notes in CONTRIBUTING.md on code coverage. Unfortunately we can't configure line-by-line code // coverage overrides within the source code itself, because Jacoco operates on bytecode. violationRules { - // TestCoverageOverrides.methodsWithMissedLineCount.forEach { signature, maxMissedLines -> - // rule { - // element = "METHOD" - // includes = listOf(signature) - // limit { - // counter = "LINE" - // value = "MISSEDCOUNT" - // maximum = maxMissedLines.toBigDecimal() - // } - // } - // } + TestCoverageOverrides.methodsWithMissedLineCount.forEach { signature, maxMissedLines -> + rule { + element = "METHOD" + includes = listOf(signature) + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = maxMissedLines.toBigDecimal() + } + } + } // General rule that we should expect 100% test coverage; exclude any methods that // have overrides in TestCoverageOverrides. @@ -43,8 +43,8 @@ object Jacoco { value = "MISSEDCOUNT" maximum = 0.toBigDecimal() } - // excludes = TestCoverageOverrides.methodsWithMissedLineCount.map { it.key } + - // TestCoverageOverrides.methodsToSkip + excludes = TestCoverageOverrides.methodsWithMissedLineCount.map { it.key } + + TestCoverageOverrides.methodsToSkip } } } From 4b71318034825c9b1afdf94f95c738fcbc36ea9d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 18:45:38 -0700 Subject: [PATCH 04/57] misc build fixes --- build-android.gradle | 6 +++++- build.gradle.kts | 6 +++++- buildSrc/src/main/kotlin/Dependencies.kt | 7 +------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/build-android.gradle b/build-android.gradle index a6f0ca9..adc549a 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -71,6 +71,10 @@ dependencies { // see Dependencies.kt in buildSrc // for the Android build. Libs.implementation.each { implementation(it) } Libs.privateImplementation.each { implementation(it) } - Libs.javaTestImplementation.each { testImplementation(it) } + Libs.javaTestImplementation.forEach { + testImplementation(it) { + exclude(group = "org.hamcrest") // avoid conflicts between multiple Hamcrests + } + } Libs.androidTestImplementation.each { androidTestImplementation(it) } } diff --git a/build.gradle.kts b/build.gradle.kts index 76d9abf..8e029ea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -47,7 +47,11 @@ val privateImplementation by configurations.creating dependencies { // see Dependencies.kt in buildSrc Libs.implementation.forEach { implementation(it)} Libs.privateImplementation.forEach { privateImplementation(it)} - Libs.javaTestImplementation.forEach { testImplementation(it) } + Libs.javaTestImplementation.forEach { + testImplementation(it) { + exclude(group = "org.hamcrest") // avoid conflicts between multiple Hamcrests + } + } } checkstyle { diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index c305af7..4b9a659 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -41,12 +41,7 @@ object Libs { // the usual Gradle "implementation" configuration, because we don't want them to be // visible at all in the module's published dependencies - not even in "runtime" scope. // - // While java-sdk-internal does need Gson in order to work, the LaunchDarkly SDKs that - // use java-sdk-internal have different strategies for packaging Gson. The Android SDK - // exposes it as a regular dependency; the Java server-side SDK embeds and shades Gson - // and does not expose it as a dependency. So we are leaving it up to the SDK to - // provide Gson in some way. - "com.google.code.gson:gson:${Versions.gson}" + // TODO: Unclear whether we will need to use this category. ) val javaBuiltInGradlePlugins = listOf( From 95ec68491377fc58276bc629f40bc4fee498a073 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 18:47:51 -0700 Subject: [PATCH 05/57] misc build fixes --- build-android.gradle | 1 - build.gradle.kts | 1 - buildSrc/src/main/kotlin/Dependencies.kt | 8 -------- 3 files changed, 10 deletions(-) diff --git a/build-android.gradle b/build-android.gradle index adc549a..70c9e21 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -70,7 +70,6 @@ dependencies { // see Dependencies.kt in buildSrc // copy the dependencies from "privateImplementation" into the standard "implementation" // for the Android build. Libs.implementation.each { implementation(it) } - Libs.privateImplementation.each { implementation(it) } Libs.javaTestImplementation.forEach { testImplementation(it) { exclude(group = "org.hamcrest") // avoid conflicts between multiple Hamcrests diff --git a/build.gradle.kts b/build.gradle.kts index 8e029ea..2e132e5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,7 +46,6 @@ val privateImplementation by configurations.creating dependencies { // see Dependencies.kt in buildSrc Libs.implementation.forEach { implementation(it)} - Libs.privateImplementation.forEach { privateImplementation(it)} Libs.javaTestImplementation.forEach { testImplementation(it) { exclude(group = "org.hamcrest") // avoid conflicts between multiple Hamcrests diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 4b9a659..ecc134d 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -36,14 +36,6 @@ object Libs { "com.android.support.test:runner:1.0.2" ) - val privateImplementation = listOf( - // These will be used in the compile-time classpath, but they should *not* be put in - // the usual Gradle "implementation" configuration, because we don't want them to be - // visible at all in the module's published dependencies - not even in "runtime" scope. - // - // TODO: Unclear whether we will need to use this category. - ) - val javaBuiltInGradlePlugins = listOf( "java", "java-library", From 9c82053cd431e2428d58292153853f6ca1c62ebc Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 18:52:05 -0700 Subject: [PATCH 06/57] misc build fixes --- build-android.gradle | 1 + build.gradle.kts | 1 + buildSrc/src/main/kotlin/Dependencies.kt | 8 +++++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/build-android.gradle b/build-android.gradle index 70c9e21..badf681 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -76,4 +76,5 @@ dependencies { // see Dependencies.kt in buildSrc } } Libs.androidTestImplementation.each { androidTestImplementation(it) } + Libs.testHamcrest.forEach { testImplementation(it) } } diff --git a/build.gradle.kts b/build.gradle.kts index 2e132e5..d6ce835 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { // see Dependencies.kt in buildSrc exclude(group = "org.hamcrest") // avoid conflicts between multiple Hamcrests } } + Libs.testHamcrest.forEach { testImplementation(it) } } checkstyle { diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index ecc134d..6e9a30c 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -26,8 +26,14 @@ object Libs { "org.slf4j:slf4j-api:${Versions.slf4j}" ) + val testHamcrest = listOf( + // This is in a separate category because some of our other test dependencies have + // Hamcrest as a transitive dependency, but they use different artifacts, which + // would cause conflicts if we didn't exclude Hamcrest when importing them. + "org.hamcrest:hamcrest-library:1.3" + ) + val javaTestImplementation = listOf( - "org.hamcrest:hamcrest-library:1.3", "junit:junit:4.12", "com.launchdarkly:test-helpers:1.1.0" ) From 62c25a48dd57dcb9e343a8a2699caef56a94a2f9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 18:56:17 -0700 Subject: [PATCH 07/57] misc build fixes --- build-android.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-android.gradle b/build-android.gradle index badf681..a88e58f 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -72,7 +72,7 @@ dependencies { // see Dependencies.kt in buildSrc Libs.implementation.each { implementation(it) } Libs.javaTestImplementation.forEach { testImplementation(it) { - exclude(group = "org.hamcrest") // avoid conflicts between multiple Hamcrests + exclude group: "org.hamcrest" // avoid conflicts between multiple Hamcrests } } Libs.androidTestImplementation.each { androidTestImplementation(it) } From 34104bc0021dc4b416f79e413cdf117e868b6a29 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 19:00:24 -0700 Subject: [PATCH 08/57] temporarily disable Android build --- .circleci/config.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2cb624d..45d0704 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,8 +30,9 @@ workflows: - build-test-windows: name: Java 17 - Windows - OpenJDK openjdk-version: 17.0.1 - - build-test-android: - name: Android + # TODO: Android job does not currently pass due to dependency problems + # - build-test-android: + # name: Android jobs: build-linux: From b98e1eb9cf6cb7b312820e4555bacc0028eca705 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 19:03:56 -0700 Subject: [PATCH 09/57] fix the Android build for now by removing the problematic dependency --- .circleci/config.yml | 5 ++--- build-android.gradle | 7 +------ build.gradle.kts | 7 +------ buildSrc/src/main/kotlin/Dependencies.kt | 9 +-------- 4 files changed, 5 insertions(+), 23 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 45d0704..2cb624d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,9 +30,8 @@ workflows: - build-test-windows: name: Java 17 - Windows - OpenJDK openjdk-version: 17.0.1 - # TODO: Android job does not currently pass due to dependency problems - # - build-test-android: - # name: Android + - build-test-android: + name: Android jobs: build-linux: diff --git a/build-android.gradle b/build-android.gradle index a88e58f..79953a0 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -70,11 +70,6 @@ dependencies { // see Dependencies.kt in buildSrc // copy the dependencies from "privateImplementation" into the standard "implementation" // for the Android build. Libs.implementation.each { implementation(it) } - Libs.javaTestImplementation.forEach { - testImplementation(it) { - exclude group: "org.hamcrest" // avoid conflicts between multiple Hamcrests - } - } + Libs.javaTestImplementation.forEach { testImplementation(it) } Libs.androidTestImplementation.each { androidTestImplementation(it) } - Libs.testHamcrest.forEach { testImplementation(it) } } diff --git a/build.gradle.kts b/build.gradle.kts index d6ce835..1e4b5cb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,12 +46,7 @@ val privateImplementation by configurations.creating dependencies { // see Dependencies.kt in buildSrc Libs.implementation.forEach { implementation(it)} - Libs.javaTestImplementation.forEach { - testImplementation(it) { - exclude(group = "org.hamcrest") // avoid conflicts between multiple Hamcrests - } - } - Libs.testHamcrest.forEach { testImplementation(it) } + Libs.javaTestImplementation.forEach { testImplementation(it) } } checkstyle { diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 6e9a30c..652c797 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -26,16 +26,9 @@ object Libs { "org.slf4j:slf4j-api:${Versions.slf4j}" ) - val testHamcrest = listOf( - // This is in a separate category because some of our other test dependencies have - // Hamcrest as a transitive dependency, but they use different artifacts, which - // would cause conflicts if we didn't exclude Hamcrest when importing them. - "org.hamcrest:hamcrest-library:1.3" - ) - val javaTestImplementation = listOf( "junit:junit:4.12", - "com.launchdarkly:test-helpers:1.1.0" + "org.hamcrest:hamcrest-library:1.3" ) val androidTestImplementation = javaTestImplementation + listOf( From 53a4cda39539d5d733a6a260733b66bd1bcc9669 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 19:17:32 -0700 Subject: [PATCH 10/57] fix Android test job --- .../java/com/launchdarkly/sdk/BaseTest.java | 12 ------------ .../java/com/launchdarkly/sdk/internal/BaseTest.java | 9 +++++++++ .../launchdarkly/sdk/internal/PlaceholderTest.java | 2 +- 3 files changed, 10 insertions(+), 13 deletions(-) delete mode 100644 src/androidTest/java/com/launchdarkly/sdk/BaseTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/BaseTest.java diff --git a/src/androidTest/java/com/launchdarkly/sdk/BaseTest.java b/src/androidTest/java/com/launchdarkly/sdk/BaseTest.java deleted file mode 100644 index 7334fae..0000000 --- a/src/androidTest/java/com/launchdarkly/sdk/BaseTest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.launchdarkly.sdk; - -import android.support.test.runner.AndroidJUnit4; -import org.junit.runner.RunWith; - -/** - * When running our unit tests in Android, we substitute this version of BaseTest which provides - * the correct test runner. - */ -@RunWith(AndroidJUnit4.class) -public abstract class BaseTest { -} diff --git a/src/test/java/com/launchdarkly/sdk/internal/BaseTest.java b/src/test/java/com/launchdarkly/sdk/internal/BaseTest.java new file mode 100644 index 0000000..7eda47a --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/BaseTest.java @@ -0,0 +1,9 @@ +package com.launchdarkly.sdk.internal; + +/** + * The only purpose of this class is to support the somewhat roundabout mechanism we use in CI to run + * all of our unit tests in an Android environment too. All unit tests in this project should have this + * as a base class. + */ +public abstract class BaseTest { +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/PlaceholderTest.java b/src/test/java/com/launchdarkly/sdk/internal/PlaceholderTest.java index 8d1a6e9..e7943f4 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/PlaceholderTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/PlaceholderTest.java @@ -2,7 +2,7 @@ import org.junit.Test; -public class PlaceholderTest { +public class PlaceholderTest extends BaseTest { @Test public void placeholder() {} } From d57e024b0cb19c2d64cbdea6044d28fa9b1b6fae Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 19:21:35 -0700 Subject: [PATCH 11/57] fix Android test job some more --- .gitignore | 4 ++-- .../java/com/launchdarkly/sdk/internal/BaseTest.java | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java diff --git a/.gitignore b/.gitignore index 9cbae19..d8bea40 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,5 @@ out/ classes/ # Test code that gets temporarily copied by our Android CI build -src/androidTest/java/com/launchdarkly/sdk/**/*.java -!src/androidTest/java/com/launchdarkly/sdk/BaseTest.java +src/androidTest/java/com/launchdarkly/sdk/internal/**/*.java +!src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java diff --git a/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java b/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java new file mode 100644 index 0000000..bfd5d63 --- /dev/null +++ b/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java @@ -0,0 +1,12 @@ +package com.launchdarkly.sdk.internal; + +import android.support.test.runner.AndroidJUnit4; +import org.junit.runner.RunWith; + +/** + * When running our unit tests in Android, we substitute this version of BaseTest which provides + * the correct test runner. + */ +@RunWith(AndroidJUnit4.class) +public abstract class BaseTest { +} From 0f4e0a45d1acf306dd3cd69a990b9c617d679b16 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 19:27:18 -0700 Subject: [PATCH 12/57] fix Android test job some more --- src/androidTest/AndroidManifest.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/androidTest/AndroidManifest.xml b/src/androidTest/AndroidManifest.xml index 1ef6096..3824bf1 100644 --- a/src/androidTest/AndroidManifest.xml +++ b/src/androidTest/AndroidManifest.xml @@ -1,6 +1,6 @@ + package="com.launchdarkly.sdk.internal"> + android:targetPackage="com.launchdarkly.sdk.internal" /> \ No newline at end of file From 321a7aa1a711410ccaac2f4763bc48c019da02d4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Aug 2022 17:18:59 -0700 Subject: [PATCH 13/57] initial move of events/http packages from java-server-sdk 6.0 codebase --- build-android.gradle | 6 - build.gradle.kts | 13 - buildSrc/src/main/kotlin/Dependencies.kt | 7 +- .../sdk/internal/GsonHelpers.java | 18 + .../sdk/internal/Placeholder.java | 4 - .../events/DefaultEventProcessor.java | 719 ++++++++++++++++++ .../internal/events/DefaultEventSender.java | 189 +++++ .../events/DiagnosticConfigProperty.java | 39 + .../sdk/internal/events/DiagnosticEvent.java | 81 ++ .../sdk/internal/events/DiagnosticId.java | 17 + .../sdk/internal/events/DiagnosticStore.java | 206 +++++ .../sdk/internal/events/Event.java | 255 +++++++ .../events/EventContextDeduplicator.java | 31 + .../events/EventContextFormatter.java | 219 ++++++ .../internal/events/EventOutputFormatter.java | 213 ++++++ .../sdk/internal/events/EventSender.java | 92 +++ .../sdk/internal/events/EventSummarizer.java | 320 ++++++++ .../internal/events/EventsConfiguration.java | 61 ++ .../sdk/internal/events/package-info.java | 9 + .../sdk/internal/http/HttpErrors.java | 95 +++ .../sdk/internal/http/HttpHelpers.java | 53 ++ .../sdk/internal/http/HttpProperties.java | 159 ++++ .../sdk/internal/http/package-info.java | 9 + .../sdk/internal/package-info.java | 10 +- .../sdk/internal/BaseInternalTest.java | 35 + .../sdk/internal/PlaceholderTest.java | 8 - .../sdk/internal/events/BaseEventTest.java | 518 +++++++++++++ .../DefaultEventProcessorDiagnosticsTest.java | 180 +++++ .../DefaultEventProcessorOutputTest.java | 423 +++++++++++ .../events/DefaultEventProcessorTest.java | 329 ++++++++ .../events/DefaultEventSenderTest.java | 381 ++++++++++ .../internal/events/DiagnosticEventTest.java | 45 ++ .../sdk/internal/events/DiagnosticIdTest.java | 52 ++ .../internal/events/DiagnosticStoreTest.java | 177 +++++ .../events/EventContextFormatterTest.java | 184 +++++ .../sdk/internal/events/EventOutputTest.java | 376 +++++++++ .../internal/events/EventSummarizerTest.java | 198 +++++ .../sdk/internal/http/HttpPropertiesTest.java | 37 + 38 files changed, 5730 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/internal/GsonHelpers.java delete mode 100644 src/main/java/com/launchdarkly/sdk/internal/Placeholder.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticConfigProperty.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticEvent.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticId.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticStore.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/Event.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/EventContextDeduplicator.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/EventSender.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizer.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/package-info.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/http/HttpErrors.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/http/HttpHelpers.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/http/HttpProperties.java create mode 100644 src/main/java/com/launchdarkly/sdk/internal/http/package-info.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/BaseInternalTest.java delete mode 100644 src/test/java/com/launchdarkly/sdk/internal/PlaceholderTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticEventTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticIdTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticStoreTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/events/EventSummarizerTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/http/HttpPropertiesTest.java diff --git a/build-android.gradle b/build-android.gradle index 79953a0..d5a7490 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -63,12 +63,6 @@ android { } dependencies { // see Dependencies.kt in buildSrc - // See note in Dependencies.kt in buildSrc on the purpose of "privateImplementation". - // The Android Gradle plugin doesn't seem to have a good way to customize the classpath - // of the compile and test tasks, but since we're not actually publishing any Android - // artifacts from this project (we use this build only for CI testing), we can simply - // copy the dependencies from "privateImplementation" into the standard "implementation" - // for the Android build. Libs.implementation.each { implementation(it) } Libs.javaTestImplementation.forEach { testImplementation(it) } Libs.androidTestImplementation.each { androidTestImplementation(it) } diff --git a/build.gradle.kts b/build.gradle.kts index 1e4b5cb..50c75d6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,9 +41,6 @@ java { targetCompatibility = JavaVersion.VERSION_1_8 } -// See Dependencies.kt in buildSrc for the purpose of "privateImplementation" -val privateImplementation by configurations.creating - dependencies { // see Dependencies.kt in buildSrc Libs.implementation.forEach { implementation(it)} Libs.javaTestImplementation.forEach { testImplementation(it) } @@ -53,16 +50,6 @@ checkstyle { configFile = file("${project.rootDir}/checkstyle.xml") } -tasks.compileJava { - // See note in Dependencies.kt in buildSrc on "privateImplementation" - classpath = configurations["privateImplementation"] -} - -helpers.Javadoc.configureTask(tasks.javadoc, configurations["privateImplementation"]) // see Javadoc.kt in buildSrc - -helpers.Test.configureTask(tasks.compileTestJava, tasks.test, - configurations["privateImplementation"]) // see Test.kt in buildSrc - helpers.Jacoco.configureTasks( // see Jacoco.kt in buildSrc tasks.jacocoTestReport, tasks.jacocoTestCoverageVerification diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 652c797..5b3ed02 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -5,7 +5,8 @@ object Versions { const val gson = "2.8.9" const val guava = "30.1-jre" - const val launchdarklyJavaSdkCommon = "1.3.0" + const val launchdarklyJavaSdkCommon = "2.0.0-SNAPSHOT" + const val launchdarklyLogging = "1.1.1" const val okhttp = "4.9.1" const val slf4j = "1.7.21" } @@ -22,13 +23,15 @@ object Libs { "com.google.code.gson:gson:${Versions.gson}", "com.google.guava:guava:${Versions.guava}", "com.launchdarkly:launchdarkly-java-sdk-common:${Versions.launchdarklyJavaSdkCommon}", + "com.launchdarkly:launchdarkly-logging:${Versions.launchdarklyLogging}", "com.squareup.okhttp3:okhttp:${Versions.okhttp}", "org.slf4j:slf4j-api:${Versions.slf4j}" ) val javaTestImplementation = listOf( "junit:junit:4.12", - "org.hamcrest:hamcrest-library:1.3" + "org.hamcrest:hamcrest-library:1.3", + "com.launchdarkly:test-helpers:1.2.0" ) val androidTestImplementation = javaTestImplementation + listOf( diff --git a/src/main/java/com/launchdarkly/sdk/internal/GsonHelpers.java b/src/main/java/com/launchdarkly/sdk/internal/GsonHelpers.java new file mode 100644 index 0000000..b39928b --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/GsonHelpers.java @@ -0,0 +1,18 @@ +package com.launchdarkly.sdk.internal; + +import com.google.gson.Gson; + +/** + * General-purpose Gson helpers. + */ +public abstract class GsonHelpers { + private static final Gson GSON_INSTANCE = new Gson(); + + /** + * A singleton instance of Gson with the default configuration. + * @return a Gson instance + */ + public static Gson gsonInstance() { + return GSON_INSTANCE; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/Placeholder.java b/src/main/java/com/launchdarkly/sdk/internal/Placeholder.java deleted file mode 100644 index 5b14df5..0000000 --- a/src/main/java/com/launchdarkly/sdk/internal/Placeholder.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.launchdarkly.sdk.internal; - -public class Placeholder { -} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java new file mode 100644 index 0000000..89c4212 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java @@ -0,0 +1,719 @@ +package com.launchdarkly.sdk.internal.events; + +import com.google.gson.Gson; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.internal.events.EventSummarizer.EventSummary; + +import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.Semaphore; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * The internal component that processes and delivers analytics events. + *

+ * This component is not visible to application code; the SDKs may choose to expose an + * interface for customizing event behavior, but if so, their default implementations of + * the interface will delegate to this component rather than this component implementing + * the interface itself. This allows us to make changes as needed to the internal interface + * and event parameters without disrupting application code, and also to provide internal + * features that may not be relevant to some SDKs. + * + * The current implementation is really three components. DefaultEventProcessor is a simple + * facade that accepts event parameters (from SDK activity that might be happening on many + * threads) and pushes the events onto a queue. The queue is consumed by a single-threaded + * task run by EventDispatcher, which performs any necessary processing such as + * incrementing summary counters. When events are ready to deliver, it uses an + * implementation of EventSender (normally DefaultEventSender) to deliver the JSON data. + */ +public final class DefaultEventProcessor implements Closeable { + private static final int INITIAL_OUTPUT_BUFFER_SIZE = 2000; + + private static final Gson gson = new Gson(); + + private final BlockingQueue inbox; + private final ScheduledExecutorService scheduler; + private final AtomicBoolean closed = new AtomicBoolean(false); + private final List> scheduledTasks = new ArrayList<>(); + private volatile boolean inputCapacityExceeded = false; + private final LDLogger logger; + + /** + * Creates an instance. + * + * @param eventsConfig the events configuration + * @param sharedExecutor used for scheduling repeating tasks + * @param threadPriority worker thread priority + * @param logger the logger + */ + public DefaultEventProcessor( + EventsConfiguration eventsConfig, + ScheduledExecutorService sharedExecutor, + int threadPriority, + LDLogger logger + ) { + inbox = new ArrayBlockingQueue<>(eventsConfig.capacity); + + scheduler = sharedExecutor; + this.logger = logger; + + new EventDispatcher( + eventsConfig, + sharedExecutor, + threadPriority, + inbox, + closed, + logger + ); + // we don't need to save a reference to this - we communicate with it entirely through the inbox queue. + + Runnable flusher = postMessageRunnable(MessageType.FLUSH, null); + scheduledTasks.add(this.scheduler.scheduleAtFixedRate(flusher, eventsConfig.flushIntervalMillis, + eventsConfig.flushIntervalMillis, TimeUnit.MILLISECONDS)); + if (eventsConfig.contextDeduplicator != null && eventsConfig.contextDeduplicator.getFlushInterval() != null) { + Runnable userKeysFlusher = postMessageRunnable(MessageType.FLUSH_USERS, null); + long intervalMillis = eventsConfig.contextDeduplicator.getFlushInterval().longValue(); + scheduledTasks.add(this.scheduler.scheduleAtFixedRate(userKeysFlusher, intervalMillis, + intervalMillis, TimeUnit.MILLISECONDS)); + } + if (eventsConfig.diagnosticStore != null) { + Runnable diagnosticsTrigger = postMessageRunnable(MessageType.DIAGNOSTIC, null); + scheduledTasks.add(this.scheduler.scheduleAtFixedRate(diagnosticsTrigger, eventsConfig.diagnosticRecordingIntervalMillis, + eventsConfig.diagnosticRecordingIntervalMillis, TimeUnit.MILLISECONDS)); + } + } + + /** + * Enqueues an event. + * + * @param e the input data + */ + public void sendEvent(Event e) { + if (!closed.get()) { + postMessageAsync(MessageType.EVENT, e); + } + } + + /** + * Schedules an asynchronous flush. + */ + public void flush() { + if (!closed.get()) { + postMessageAsync(MessageType.FLUSH, null); + } + } + + public void close() throws IOException { + if (closed.compareAndSet(false, true)) { + for (ScheduledFuture task: scheduledTasks) { + task.cancel(false); + } + postMessageAsync(MessageType.FLUSH, null); + postMessageAndWait(MessageType.SHUTDOWN, null); + } + } + + void waitUntilInactive() throws IOException { // visible for testing + postMessageAndWait(MessageType.SYNC, null); + } + + void postDiagnostic() { // visible for testing + postMessageAsync(MessageType.DIAGNOSTIC, null); + } + + private void postMessageAsync(MessageType type, Event event) { + postToChannel(new EventProcessorMessage(type, event, false)); + } + + private void postMessageAndWait(MessageType type, Event event) { + EventProcessorMessage message = new EventProcessorMessage(type, event, true); + if (postToChannel(message)) { + // COVERAGE: There is no way to reliably cause this to fail in tests + message.waitForCompletion(); + } + } + + private Runnable postMessageRunnable(final MessageType messageType, final Event event) { + return new Runnable() { + public void run() { + postMessageAsync(messageType, event); + } + }; + } + + private boolean postToChannel(EventProcessorMessage message) { + if (inbox.offer(message)) { + return true; + } + // If the inbox is full, it means the EventDispatcher thread is seriously backed up with not-yet-processed + // events. This is unlikely, but if it happens, it means the application is probably doing a ton of flag + // evaluations across many threads-- so if we wait for a space in the inbox, we risk a very serious slowdown + // of the app. To avoid that, we'll just drop the event. The log warning about this will only be shown once. + boolean alreadyLogged = inputCapacityExceeded; // possible race between this and the next line, but it's of no real consequence - we'd just get an extra log line + inputCapacityExceeded = true; + // COVERAGE: There is no way to reliably cause this condition in tests + if (!alreadyLogged) { + logger.warn("Events are being produced faster than they can be processed; some events will be dropped"); + } + return false; + } + + private static enum MessageType { + EVENT, + FLUSH, + FLUSH_USERS, + DIAGNOSTIC, + SYNC, + SHUTDOWN + } + + private static final class EventProcessorMessage { + private final MessageType type; + private final Event event; + private final Semaphore reply; + + private EventProcessorMessage(MessageType type, Event event, boolean sync) { + this.type = type; + this.event = event; + reply = sync ? new Semaphore(0) : null; + } + + void completed() { + if (reply != null) { + reply.release(); + } + } + + void waitForCompletion() { + if (reply == null) { // COVERAGE: there is no way to make this happen from test code + return; + } + while (true) { + try { + reply.acquire(); + return; + } + catch (InterruptedException ex) { // COVERAGE: there is no way to make this happen from test code. + } + } + } + +// intentionally commented out so this doesn't affect coverage reports when we're not debugging +// @Override +// public String toString() { // for debugging only +// return ((event == null) ? type.toString() : (type + ": " + event.getClass().getSimpleName())) + +// (reply == null ? "" : " (sync)"); +// } + } + + /** + * Takes messages from the input queue, updating the event buffer and summary counters + * on its own thread. + */ + static final class EventDispatcher { + private static final int MAX_FLUSH_THREADS = 5; + private static final int MESSAGE_BATCH_SIZE = 50; + + final EventsConfiguration eventsConfig; // visible for testing + private final BlockingQueue inbox; + private final AtomicBoolean closed; + private final List flushWorkers; + private final AtomicInteger busyFlushWorkersCount; + private final AtomicLong lastKnownPastTime = new AtomicLong(0); + private final AtomicBoolean disabled = new AtomicBoolean(false); + final DiagnosticStore diagnosticStore; // visible for testing + private final EventContextDeduplicator contextDeduplicator; + private final ExecutorService sharedExecutor; + private final SendDiagnosticTaskFactory sendDiagnosticTaskFactory; + private final LDLogger logger; + + private long deduplicatedUsers = 0; + + private EventDispatcher( + EventsConfiguration eventsConfig, + ExecutorService sharedExecutor, + int threadPriority, + BlockingQueue inbox, + AtomicBoolean closed, + LDLogger logger + ) { + this.eventsConfig = eventsConfig; + this.inbox = inbox; + this.closed = closed; + this.sharedExecutor = sharedExecutor; + this.diagnosticStore = eventsConfig.diagnosticStore; + this.busyFlushWorkersCount = new AtomicInteger(0); + this.logger = logger; + + ThreadFactory threadFactory = new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r); + t.setDaemon(true);; + t.setName(String.format("LaunchDarkly-event-delivery-%d", t.getId())); + t.setPriority(threadPriority); + return t; + } + }; + + // This queue only holds one element; it represents a flush task that has not yet been + // picked up by any worker, so if we try to push another one and are refused, it means + // all the workers are busy. + final BlockingQueue payloadQueue = new ArrayBlockingQueue<>(1); + + final EventBuffer outbox = new EventBuffer(eventsConfig.capacity, logger); + this.contextDeduplicator = eventsConfig.contextDeduplicator; + + Thread mainThread = threadFactory.newThread(new Thread() { + public void run() { + runMainLoop(inbox, outbox, payloadQueue); + } + }); + mainThread.setDaemon(true); + + mainThread.setUncaughtExceptionHandler(this::onUncaughtException); + + mainThread.start(); + + flushWorkers = new ArrayList<>(); + EventResponseListener listener = this::handleResponse; + for (int i = 0; i < MAX_FLUSH_THREADS; i++) { + SendEventsTask task = new SendEventsTask( + eventsConfig, + listener, + payloadQueue, + busyFlushWorkersCount, + threadFactory, + logger + ); + flushWorkers.add(task); + } + + if (diagnosticStore != null) { + // Set up diagnostics + this.sendDiagnosticTaskFactory = new SendDiagnosticTaskFactory(eventsConfig, this::handleResponse, logger); + sharedExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticStore.getInitEvent())); + } else { + sendDiagnosticTaskFactory = null; + } + } + + private void onUncaughtException(Thread thread, Throwable e) { + // The thread's main loop catches all exceptions, so we'll only get here if an Error was thrown. + // In that case, the application is probably already in a bad state, but we can try to degrade + // relatively gracefully by performing an orderly shutdown of the event processor, so the + // application won't end up blocking on a queue that's no longer being consumed. + // COVERAGE: there is no way to make this happen from test code. + + logger.error("Event processor thread was terminated by an unrecoverable error. No more analytics events will be sent. {} {}", + LogValues.exceptionSummary(e), LogValues.exceptionTrace(e)); + // Note that this is a rare case where we always log the exception stacktrace, instead of only + // logging it at debug level. That's because an exception of this kind should never happen and, + // if it happens, may be difficult to debug. + + // Flip the switch to prevent DefaultEventProcessor from putting any more messages on the queue + closed.set(true); + // Now discard everything that was on the queue, but also make sure no one was blocking on a message + List messages = new ArrayList(); + inbox.drainTo(messages); + for (EventProcessorMessage m: messages) { + m.completed(); + } + } + + /** + * This task drains the input queue as quickly as possible. Everything here is done on a single + * thread so we don't have to synchronize on our internal structures; when it's time to flush, + * triggerFlush will hand the events off to another task. + */ + private void runMainLoop( + BlockingQueue inbox, + EventBuffer outbox, + BlockingQueue payloadQueue + ) { + List batch = new ArrayList(MESSAGE_BATCH_SIZE); + while (true) { + try { + batch.clear(); + batch.add(inbox.take()); // take() blocks until a message is available + inbox.drainTo(batch, MESSAGE_BATCH_SIZE - 1); // this nonblocking call allows us to pick up more messages if available + for (EventProcessorMessage message: batch) { + switch (message.type) { // COVERAGE: adding a default branch does not prevent coverage warnings here due to compiler issues + case EVENT: + processEvent(message.event, outbox); + break; + case FLUSH: + triggerFlush(outbox, payloadQueue); + break; + case FLUSH_USERS: + if (contextDeduplicator != null) { + contextDeduplicator.flush(); + } + break; + case DIAGNOSTIC: + sendAndResetDiagnostics(outbox); + break; + case SYNC: // this is used only by unit tests + waitUntilAllFlushWorkersInactive(); + break; + case SHUTDOWN: + doShutdown(); + message.completed(); + return; // deliberately exit the thread loop + } + message.completed(); + } + } catch (InterruptedException e) { + } catch (Exception e) { // COVERAGE: there is no way to cause this condition in tests + logger.error("Unexpected error in event processor: {}", e.toString()); + logger.debug(e.toString(), e); + } + } + } + + private void sendAndResetDiagnostics(EventBuffer outbox) { + if (disabled.get()) { + return; + } + long droppedEvents = outbox.getAndClearDroppedCount(); + // We pass droppedEvents and deduplicatedUsers as parameters here because they are updated frequently in the main loop so we want to avoid synchronization on them. + DiagnosticEvent diagnosticEvent = diagnosticStore.createEventAndReset(droppedEvents, deduplicatedUsers); + deduplicatedUsers = 0; + sharedExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticEvent)); + } + + private void doShutdown() { + waitUntilAllFlushWorkersInactive(); + disabled.set(true); // In case there are any more messages, we want to ignore them + for (SendEventsTask task: flushWorkers) { + task.stop(); + } + try { + eventsConfig.eventSender.close(); + } catch (IOException e) { + logger.error("Unexpected error when closing event sender: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); + } + } + + private void waitUntilAllFlushWorkersInactive() { + while (true) { + try { + synchronized(busyFlushWorkersCount) { + if (busyFlushWorkersCount.get() == 0) { + return; + } else { + busyFlushWorkersCount.wait(); + } + } + } catch (InterruptedException e) {} // COVERAGE: there is no way to cause this condition in tests + } + } + + private void processEvent(Event e, EventBuffer outbox) { + if (disabled.get()) { + return; + } + + LDContext context = e.getContext(); + if (context == null) { + return; // LDClient should never give us an event with no context + } + + // Decide whether to add the event to the payload. Feature events may be added twice, once for + // the event (if tracked) and once for debugging. + boolean addIndexEvent = false, + addFullEvent = false; + Event debugEvent = null; + + if (e instanceof Event.FeatureRequest) { + Event.FeatureRequest fe = (Event.FeatureRequest)e; + outbox.addToSummary(fe); + addFullEvent = fe.isTrackEvents(); + if (shouldDebugEvent(fe)) { + debugEvent = fe.toDebugEvent(); + } + } else { + addFullEvent = true; + } + + // For each context we haven't seen before, we add an index event - unless this is already + // an identify event for that context. + if (context != null && context.getFullyQualifiedKey() != null) { + if (e instanceof Event.FeatureRequest || e instanceof Event.Custom) { + if (contextDeduplicator != null) { + // Add to the set of contexts we've noticed + addIndexEvent = contextDeduplicator.processContext(context); + if (!addIndexEvent) { + deduplicatedUsers++; + } + } + } else if (e instanceof Event.Identify) { + if (contextDeduplicator != null) { + contextDeduplicator.processContext(context); // just mark that we've seen it + } + } + } + + if (addIndexEvent) { + Event.Index ie = new Event.Index(e.getCreationDate(), e.getContext()); + outbox.add(ie); + } + if (addFullEvent) { + outbox.add(e); + } + if (debugEvent != null) { + outbox.add(debugEvent); + } + } + + private boolean shouldDebugEvent(Event.FeatureRequest fe) { + Long maybeDate = fe.getDebugEventsUntilDate(); + if (maybeDate == null) { + return false; + } + long debugEventsUntilDate = maybeDate.longValue(); + if (debugEventsUntilDate > 0) { + // The "last known past time" comes from the last HTTP response we got from the server. + // In case the client's time is set wrong, at least we know that any expiration date + // earlier than that point is definitely in the past. If there's any discrepancy, we + // want to err on the side of cutting off event debugging sooner. + long lastPast = lastKnownPastTime.get(); + if (debugEventsUntilDate > lastPast && + debugEventsUntilDate > System.currentTimeMillis()) { + return true; + } + } + return false; + } + + private void triggerFlush(EventBuffer outbox, BlockingQueue payloadQueue) { + if (disabled.get() || outbox.isEmpty()) { + return; + } + FlushPayload payload = outbox.getPayload(); + if (diagnosticStore != null) { + int eventCount = payload.events.length + (payload.summary.isEmpty() ? 0 : 1); + diagnosticStore.recordEventsInBatch(eventCount); + } + busyFlushWorkersCount.incrementAndGet(); + if (payloadQueue.offer(payload)) { + // These events now belong to the next available flush worker, so drop them from our state + outbox.clear(); + } else { + logger.debug("Skipped flushing because all workers are busy"); + // All the workers are busy so we can't flush now; keep the events in our state + outbox.summarizer.restoreTo(payload.summary); + synchronized(busyFlushWorkersCount) { + busyFlushWorkersCount.decrementAndGet(); + busyFlushWorkersCount.notify(); + } + } + } + + private void handleResponse(EventSender.Result result) { + if (result.getTimeFromServer() != null) { + lastKnownPastTime.set(result.getTimeFromServer().getTime()); + } + if (result.isMustShutDown()) { + disabled.set(true); + } + } + } + + private static final class EventBuffer { + final List events = new ArrayList<>(); + final EventSummarizer summarizer = new EventSummarizer(); + private final int capacity; + private final LDLogger logger; + private boolean capacityExceeded = false; + private long droppedEventCount = 0; + + EventBuffer(int capacity, LDLogger logger) { + this.capacity = capacity; + this.logger = logger; + } + + void add(Event e) { + if (events.size() >= capacity) { + if (!capacityExceeded) { // don't need AtomicBoolean, this is only checked on one thread + capacityExceeded = true; + logger.warn("Exceeded event queue capacity. Increase capacity to avoid dropping events."); + } + droppedEventCount++; + } else { + capacityExceeded = false; + events.add(e); + } + } + + void addToSummary(Event.FeatureRequest e) { + summarizer.summarizeEvent( + e.getCreationDate(), + e.getKey(), + e.getVersion(), + e.getVariation(), + e.getValue(), + e.getDefaultVal(), + e.getContext() + ); + } + + boolean isEmpty() { + return events.isEmpty() && summarizer.isEmpty(); + } + + long getAndClearDroppedCount() { + long res = droppedEventCount; + droppedEventCount = 0; + return res; + } + + FlushPayload getPayload() { + Event[] eventsOut = events.toArray(new Event[events.size()]); + EventSummarizer.EventSummary summary = summarizer.getSummaryAndReset(); + return new FlushPayload(eventsOut, summary); + } + + void clear() { + events.clear(); + summarizer.clear(); + } + } + + private static final class FlushPayload { + final Event[] events; + final EventSummary summary; + + FlushPayload(Event[] events, EventSummary summary) { + this.events = events; + this.summary = summary; + } + } + + private static interface EventResponseListener { + void handleResponse(EventSender.Result result); + } + + private static final class SendEventsTask implements Runnable { + private final EventsConfiguration eventsConfig; + private final EventResponseListener responseListener; + private final BlockingQueue payloadQueue; + private final AtomicInteger activeFlushWorkersCount; + private final AtomicBoolean stopping; + private final EventOutputFormatter formatter; + private final Thread thread; + private final LDLogger logger; + + SendEventsTask( + EventsConfiguration eventsConfig, + EventResponseListener responseListener, + BlockingQueue payloadQueue, + AtomicInteger activeFlushWorkersCount, + ThreadFactory threadFactory, + LDLogger logger + ) { + this.eventsConfig = eventsConfig; + this.formatter = new EventOutputFormatter(eventsConfig); + this.responseListener = responseListener; + this.payloadQueue = payloadQueue; + this.activeFlushWorkersCount = activeFlushWorkersCount; + this.stopping = new AtomicBoolean(false); + this.logger = logger; + thread = threadFactory.newThread(this); + thread.setDaemon(true); + thread.start(); + } + + public void run() { + while (!stopping.get()) { + FlushPayload payload = null; + try { + payload = payloadQueue.take(); + } catch (InterruptedException e) { + continue; + } + try { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(INITIAL_OUTPUT_BUFFER_SIZE); + Writer writer = new BufferedWriter(new OutputStreamWriter(buffer, Charset.forName("UTF-8")), INITIAL_OUTPUT_BUFFER_SIZE); + int outputEventCount = formatter.writeOutputEvents(payload.events, payload.summary, writer); + writer.flush(); + EventSender.Result result = eventsConfig.eventSender.sendAnalyticsEvents( + buffer.toByteArray(), + outputEventCount, + eventsConfig.eventsUri + ); + responseListener.handleResponse(result); + } catch (Exception e) { + logger.error("Unexpected error in event processor: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); + } + synchronized (activeFlushWorkersCount) { + activeFlushWorkersCount.decrementAndGet(); + activeFlushWorkersCount.notifyAll(); + } + } + } + + void stop() { + stopping.set(true); + thread.interrupt(); + } + } + + private static final class SendDiagnosticTaskFactory { + private final EventsConfiguration eventsConfig; + private final EventResponseListener eventResponseListener; + private final LDLogger logger; + + SendDiagnosticTaskFactory( + EventsConfiguration eventsConfig, + EventResponseListener eventResponseListener, + LDLogger logger + ) { + this.eventsConfig = eventsConfig; + this.eventResponseListener = eventResponseListener; + this.logger = logger; + } + + Runnable createSendDiagnosticTask(final DiagnosticEvent diagnosticEvent) { + return new Runnable() { + @Override + public void run() { + try { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(INITIAL_OUTPUT_BUFFER_SIZE); + Writer writer = new BufferedWriter(new OutputStreamWriter(buffer, Charset.forName("UTF-8")), INITIAL_OUTPUT_BUFFER_SIZE); + gson.toJson(diagnosticEvent, writer); + writer.flush(); + EventSender.Result result = eventsConfig.eventSender.sendDiagnosticEvent( + buffer.toByteArray(), eventsConfig.eventsUri); + if (eventResponseListener != null) { + eventResponseListener.handleResponse(result); + } + } catch (Exception e) { + logger.error("Unexpected error in event processor: {}", e.toString()); + logger.debug(e.toString(), e); + } + } + }; + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java new file mode 100644 index 0000000..1e960bc --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java @@ -0,0 +1,189 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; +import com.launchdarkly.sdk.internal.http.HttpHelpers; +import com.launchdarkly.sdk.internal.http.HttpProperties; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.Charset; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.UUID; + +import static com.launchdarkly.sdk.internal.http.HttpErrors.checkIfErrorIsRecoverableAndLog; +import static com.launchdarkly.sdk.internal.http.HttpErrors.httpErrorDescription; + +import okhttp3.Headers; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +/** + * The default implementation of delivering JSON data to an LaunchDarkly event endpoint. + * This is the only implementation that is used by the SDKs. It is abstracted out with an + * interface for the sake of testability. + */ +public final class DefaultEventSender implements EventSender { + static final long DEFAULT_RETRY_DELAY_MILLIS = 1000; + private static final String ANALYTICS_EVENTS_POST_REQUEST_PATH = "/bulk"; + private static final String DIAGNOSTIC_EVENTS_POST_REQUEST_PATH = "/diagnostic"; + private static final String EVENT_SCHEMA_HEADER = "X-LaunchDarkly-Event-Schema"; + private static final String EVENT_SCHEMA_VERSION = "4"; + private static final String EVENT_PAYLOAD_ID_HEADER = "X-LaunchDarkly-Payload-ID"; + private static final MediaType JSON_CONTENT_TYPE = MediaType.parse("application/json; charset=utf-8"); + private static final SimpleDateFormat HTTP_DATE_FORMAT = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", + Locale.US); // server dates as defined by RFC-822/RFC-1123 use English day/month names + private static final Object HTTP_DATE_FORMAT_LOCK = new Object(); // synchronize on this because DateFormat isn't thread-safe + + private final OkHttpClient httpClient; + private final Headers baseHeaders; + final long retryDelayMillis; // visible for testing + private final LDLogger logger; + + /** + * Creates an instance. + * + * @param httpProperties the HTTP configuration + * @param retryDelayMillis retry delay, or zero to use the default + * @param logger the logger + */ + public DefaultEventSender( + HttpProperties httpProperties, + long retryDelayMillis, + LDLogger logger + ) { + this.httpClient = httpProperties.toHttpClientBuilder().build(); + this.logger = logger; + + this.baseHeaders = httpProperties.toHeadersBuilder() + .add("Content-Type", "application/json") + .build(); + + this.retryDelayMillis = retryDelayMillis <= 0 ? DEFAULT_RETRY_DELAY_MILLIS : retryDelayMillis; + } + + @Override + public void close() throws IOException { + HttpProperties.shutdownHttpClient(httpClient); + } + + @Override + public Result sendAnalyticsEvents(byte[] data, int eventCount, URI eventsBaseUri) { + return sendEventData(false, data, eventCount, eventsBaseUri); + } + + @Override + public Result sendDiagnosticEvent(byte[] data, URI eventsBaseUri) { + return sendEventData(true, data, 1, eventsBaseUri); + } + + private Result sendEventData(boolean isDiagnostic, byte[] data, int eventCount, URI eventsBaseUri) { + if (data == null || data.length == 0) { + // DefaultEventProcessor won't normally pass us an empty payload, but if it does, don't bother sending + return new Result(true, false, null); + } + + Headers.Builder headersBuilder = baseHeaders.newBuilder(); + String path; + String description; + + if (isDiagnostic) { + path = DIAGNOSTIC_EVENTS_POST_REQUEST_PATH; + description = "diagnostic event"; + } else { + path = ANALYTICS_EVENTS_POST_REQUEST_PATH; + String eventPayloadId = UUID.randomUUID().toString(); + headersBuilder.add(EVENT_PAYLOAD_ID_HEADER, eventPayloadId); + headersBuilder.add(EVENT_SCHEMA_HEADER, EVENT_SCHEMA_VERSION); + description = String.format("%d event(s)", eventCount); + } + + URI uri = HttpHelpers.concatenateUriPath(eventsBaseUri, path); + Headers headers = headersBuilder.build(); + RequestBody body = RequestBody.create(data, JSON_CONTENT_TYPE); + boolean mustShutDown = false; + + logger.debug("Posting {} to {} with payload: {}", description, uri, + LogValues.defer(new LazilyPrintedUtf8Data(data))); + + for (int attempt = 0; attempt < 2; attempt++) { + if (attempt > 0) { + logger.warn("Will retry posting {} after {}ms", description, retryDelayMillis); + try { + Thread.sleep(retryDelayMillis); + } catch (InterruptedException e) { // COVERAGE: there's no way to cause this in tests + } + } + + Request request = new Request.Builder() + .url(uri.toASCIIString()) + .post(body) + .headers(headers) + .build(); + + long startTime = System.currentTimeMillis(); + String nextActionMessage = attempt == 0 ? "will retry" : "some events were dropped"; + String errorContext = "posting " + description; + + try (Response response = httpClient.newCall(request).execute()) { + long endTime = System.currentTimeMillis(); + logger.debug("{} delivery took {} ms, response status {}", description, endTime - startTime, response.code()); + + if (response.isSuccessful()) { + return new Result(true, false, parseResponseDate(response)); + } + + String errorDesc = httpErrorDescription(response.code()); + boolean recoverable = checkIfErrorIsRecoverableAndLog( + logger, + errorDesc, + errorContext, + response.code(), + nextActionMessage + ); + if (!recoverable) { + mustShutDown = true; + break; + } + } catch (IOException e) { + checkIfErrorIsRecoverableAndLog(logger, e.toString(), errorContext, 0, nextActionMessage); + } + } + + return new Result(false, mustShutDown, null); + } + + private final Date parseResponseDate(Response response) { + String dateStr = response.header("Date"); + if (dateStr != null) { + try { + // DateFormat is not thread-safe, so must synchronize + synchronized (HTTP_DATE_FORMAT_LOCK) { + return HTTP_DATE_FORMAT.parse(dateStr); + } + } catch (ParseException e) { + logger.warn("Received invalid Date header from events service"); + } + } + return null; + } + + private final class LazilyPrintedUtf8Data implements LogValues.StringProvider { + private final byte[] data; + + LazilyPrintedUtf8Data(byte[] data) { + this.data = data; + } + + @Override + public String get() { + return data == null ? "" : new String(data, Charset.forName("UTF-8")); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticConfigProperty.java b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticConfigProperty.java new file mode 100644 index 0000000..88106b8 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticConfigProperty.java @@ -0,0 +1,39 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.LDValueType; + +/** + * Defines the standard properties that are allowed in the configuration section of a diagnostic + * initialization event. + */ +@SuppressWarnings("javadoc") +public enum DiagnosticConfigProperty { + ALL_ATTRIBUTES_PRIVATE("allAttributesPrivate", LDValueType.BOOLEAN), + CONNECT_TIMEOUT_MILLIS("connectTimeoutMillis", LDValueType.NUMBER), + CUSTOM_BASE_URI("customBaseURI", LDValueType.BOOLEAN), + CUSTOM_EVENTS_URI("customEventsURI", LDValueType.BOOLEAN), + CUSTOM_STREAM_URI("customStreamURI", LDValueType.BOOLEAN), + DATA_STORE_TYPE("dataStoreType", LDValueType.STRING), + DIAGNOSTIC_RECORDING_INTERVAL_MILLIS("diagnosticRecordingIntervalMillis", LDValueType.NUMBER), + EVENTS_CAPACITY("eventsCapacity", LDValueType.NUMBER), + EVENTS_FLUSH_INTERVAL_MILLIS("eventsFlushIntervalMillis", LDValueType.NUMBER), + POLLING_INTERVAL_MILLIS("pollingIntervalMillis", LDValueType.NUMBER), + RECONNECT_TIME_MILLIS("reconnectTimeMillis", LDValueType.NUMBER), + SAMPLING_INTERVAL("samplingInterval", LDValueType.NUMBER), + SOCKET_TIMEOUT_MILLIS("socketTimeoutMillis", LDValueType.NUMBER), + START_WAIT_MILLIS("startWaitMillis", LDValueType.NUMBER), + STREAMING_DISABLED("streamingDisabled", LDValueType.BOOLEAN), + USER_KEYS_CAPACITY("userKeysCapacity", LDValueType.NUMBER), + USER_KEYS_FLUSH_INTERVAL_MILLIS("userKeysFlushIntervalMillis", LDValueType.NUMBER), + USING_PROXY("usingProxy", LDValueType.BOOLEAN), + USING_PROXY_AUTHENTICATOR("usingProxyAuthenticator", LDValueType.BOOLEAN), + USING_RELAY_DAEMON("usingRelayDaemon", LDValueType.BOOLEAN); + + public final String name; + public final LDValueType type; + + private DiagnosticConfigProperty(String name, LDValueType type) { + this.name = name; + this.type = type; + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticEvent.java b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticEvent.java new file mode 100644 index 0000000..55e69c0 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticEvent.java @@ -0,0 +1,81 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.LDValue; + +import java.util.List; + +/** + * Base class for diagnostic events. This class and its subclasses are used only for JSON serialization. + */ +public class DiagnosticEvent { + final String kind; + final long creationDate; + final DiagnosticId id; + + DiagnosticEvent(String kind, long creationDate, DiagnosticId id) { + this.kind = kind; + this.creationDate = creationDate; + this.id = id; + } + + /** + * Stream initialization data in a diagnostic event. + */ + @SuppressWarnings("javadoc") + public static class StreamInit { + public final long timestamp; + public final long durationMillis; + public final boolean failed; + + StreamInit(long timestamp, long durationMillis, boolean failed) { + this.timestamp = timestamp; + this.durationMillis = durationMillis; + this.failed = failed; + } + } + + /** + * A diagnostic statistics event. + */ + @SuppressWarnings("javadoc") + public static class Statistics extends DiagnosticEvent { + public final long dataSinceDate; + public final long droppedEvents; + public final long deduplicatedUsers; + public final long eventsInLastBatch; + public final List streamInits; + + Statistics(long creationDate, DiagnosticId id, long dataSinceDate, long droppedEvents, long deduplicatedUsers, + long eventsInLastBatch, List streamInits) { + super("diagnostic", creationDate, id); + this.dataSinceDate = dataSinceDate; + this.droppedEvents = droppedEvents; + this.deduplicatedUsers = deduplicatedUsers; + this.eventsInLastBatch = eventsInLastBatch; + this.streamInits = streamInits; + } + } + + /** + * A diagnostic initialization event. + */ + @SuppressWarnings("javadoc") + public static class Init extends DiagnosticEvent { + public final LDValue sdk; + public final LDValue configuration; + public final LDValue platform; + + Init( + long creationDate, + DiagnosticId diagnosticId, + LDValue sdk, + LDValue configuration, + LDValue platform + ) { + super("diagnostic-init", creationDate, diagnosticId); + this.sdk = sdk; + this.configuration = configuration; + this.platform = platform; + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticId.java b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticId.java new file mode 100644 index 0000000..755656a --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticId.java @@ -0,0 +1,17 @@ +package com.launchdarkly.sdk.internal.events; + +import java.util.UUID; + +class DiagnosticId { + + final String diagnosticId = UUID.randomUUID().toString(); + final String sdkKeySuffix; + + DiagnosticId(String sdkKey) { + if (sdkKey == null) { + sdkKeySuffix = null; + } else { + this.sdkKeySuffix = sdkKey.substring(Math.max(0, sdkKey.length() - 6)); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticStore.java b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticStore.java new file mode 100644 index 0000000..dfcc102 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticStore.java @@ -0,0 +1,206 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.ObjectBuilder; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; + +/** + * Implementation of basic diagnostic event creation. Platform-specific details are provided in + * SdkDiagnosticParams. + */ +public final class DiagnosticStore { + private final DiagnosticId diagnosticId; + private final long creationDate; + private final SdkDiagnosticParams diagnosticParams; + + private volatile long dataSinceDate; + private final AtomicInteger eventsInLastBatch = new AtomicInteger(0); + private final Object streamInitsLock = new Object(); + private ArrayList streamInits = new ArrayList<>(); + + /** + * Parameters for creating a DiagnosticStore. + */ + public static class SdkDiagnosticParams { + final String sdkKeyOrMobileKey; + final String sdkName; + final String sdkVersion; + final String platformName; + final LDValue extraPlatformData; + final Map defaultHttpHeaders; + final List configProperties; + + /** + * Creates an instance. + * + * @param sdkKeyOrMobileKey the SDK key or mobile key + * @param sdkName the SDK name as represented in diagnostic events + * @param sdkVersion the version string + * @param platformName the platform name as represented in diagnostic events + * @param extraPlatformData optional JSON object for platform properties + * @param defaultHttpHeaders from the HTTP configuration (we get the wrapper name from this) + * @param configProperties optional JSON object for any additional config properties + */ + public SdkDiagnosticParams( + String sdkKeyOrMobileKey, + String sdkName, + String sdkVersion, + String platformName, + LDValue extraPlatformData, + Map defaultHttpHeaders, + List configProperties + ) { + this.sdkKeyOrMobileKey = sdkKeyOrMobileKey; + this.sdkName = sdkName; + this.sdkVersion = sdkVersion; + this.platformName = platformName; + this.extraPlatformData = extraPlatformData; + this.defaultHttpHeaders = defaultHttpHeaders == null ? emptyMap() : new HashMap<>(defaultHttpHeaders); + this.configProperties = configProperties == null ? emptyList() : new ArrayList(configProperties); + } + } + + /** + * Constructs an instance. + * + * @param params the diagnostic properties + */ + public DiagnosticStore(SdkDiagnosticParams params) { + this.creationDate = this.dataSinceDate = System.currentTimeMillis(); + this.diagnosticId = new DiagnosticId(params.sdkKeyOrMobileKey); + this.diagnosticParams = params; + } + + /** + * Returns the unique diagnostic identifier. + * + * @return the identifier + */ + public DiagnosticId getDiagnosticId() { + return diagnosticId; + } + + /** + * Returns the millisecond timestamp when the current diagnostic stats began. + * + * @return the timestamp + */ + public long getDataSinceDate() { + return dataSinceDate; + } + + /** + * Returns the initial diagnostic event. + * + * @return the initial event + */ + public DiagnosticEvent.Init getInitEvent() { + return new DiagnosticEvent.Init(creationDate, diagnosticId, + makeInitEventSdkData(), makeInitEventConfigData(), makeInitEventPlatformData()); + } + + private LDValue makeInitEventSdkData() { + ObjectBuilder b = LDValue.buildObject() + .put("name", diagnosticParams.sdkName) + .put("version", diagnosticParams.sdkVersion); + for (Map.Entry kv: diagnosticParams.defaultHttpHeaders.entrySet()) { + if (kv.getKey().equalsIgnoreCase("x-launchdarkly-wrapper")) { + if (kv.getValue().contains("/")) { + b.put("wrapperName", kv.getValue().substring(0, kv.getValue().indexOf("/"))); + b.put("wrapperVersion", kv.getValue().substring(kv.getValue().indexOf("/") + 1)); + } else { + b.put("wrapperName", kv.getValue()); + } + } + } + return b.build(); + } + + private LDValue makeInitEventConfigData() { + ObjectBuilder b = LDValue.buildObject(); + for (LDValue configProps: diagnosticParams.configProperties) { + if (configProps == null || configProps.getType() != LDValueType.OBJECT) { + continue; + } + for (String prop: configProps.keys()) { + // filter this to make sure a badly-behaved custom component doesn't inject weird + // properties that will confuse the event recorder + for (DiagnosticConfigProperty p: DiagnosticConfigProperty.values()) { + if (p.name.equals(prop)) { + LDValue value = configProps.get(prop); + if (value.getType() == p.type) { + b.put(prop, value); + } + break; + } + } + } + } + return b.build(); + } + + private LDValue makeInitEventPlatformData() { + ObjectBuilder b = LDValue.buildObject() + .put("name", diagnosticParams.platformName) + .put("osArch", System.getProperty("os.arch")) + .put("osVersion", System.getProperty("os.version")); + if (diagnosticParams.extraPlatformData != null) { + for (String key: diagnosticParams.extraPlatformData.keys()) { + b.put(key, diagnosticParams.extraPlatformData.get(key)); + } + } + return b.build(); + } + + /** + * Records a successful or failed stream initialization. + * + * @param timestamp the millisecond timestamp + * @param durationMillis how long the initialization took + * @param failed true if failed + */ + public void recordStreamInit(long timestamp, long durationMillis, boolean failed) { + synchronized (streamInitsLock) { + streamInits.add(new DiagnosticEvent.StreamInit(timestamp, durationMillis, failed)); + } + } + + /** + * Records the number of events in the last flush payload. + * + * @param eventsInBatch the event count + */ + public void recordEventsInBatch(int eventsInBatch) { + eventsInLastBatch.set(eventsInBatch); + } + + /** + * Creates a statistics event and then resets the counters. + * + * @param droppedEvents number of dropped events + * @param deduplicatedContexts number of deduplicated contexts + * @return the event + */ + public DiagnosticEvent.Statistics createEventAndReset(long droppedEvents, long deduplicatedContexts) { + long currentTime = System.currentTimeMillis(); + List eventInits; + synchronized (streamInitsLock) { + eventInits = streamInits; + streamInits = new ArrayList<>(); + } + long eventsInBatch = eventsInLastBatch.getAndSet(0); + DiagnosticEvent.Statistics res = new DiagnosticEvent.Statistics(currentTime, diagnosticId, dataSinceDate, droppedEvents, + deduplicatedContexts, eventsInBatch, eventInits); + dataSinceDate = currentTime; + return res; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/Event.java b/src/main/java/com/launchdarkly/sdk/internal/events/Event.java new file mode 100644 index 0000000..0767509 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/Event.java @@ -0,0 +1,255 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; + +/** + * Base class for all analytics events that are generated by the client. Also defines all of its own subclasses. + * + * These types are not visible to applications; they are an implementation detail of the default event + * processor. + */ +public class Event { + private final long creationDate; + private final LDContext context; + + /** + * Base event constructor. + * @param creationDate the timestamp in milliseconds + * @param context the context associated with the event + */ + public Event(long creationDate, LDContext context) { + this.creationDate = creationDate; + this.context = context; + } + + /** + * The event timestamp. + * @return the timestamp in milliseconds + */ + public long getCreationDate() { + return creationDate; + } + + /** + * The context associated with the event. + * @return the context object + */ + public LDContext getContext() { + return context; + } + + /** + * A custom event created with one of the SDK's {@code track} methods. + */ + public static final class Custom extends Event { + private final String key; + private final LDValue data; + private final Double metricValue; + + /** + * Constructs a custom event. + * @param timestamp the timestamp in milliseconds + * @param key the event key + * @param context the context associated with the event + * @param data custom data if any (null is the same as {@link LDValue#ofNull()}) + * @param metricValue custom metric value if any + */ + public Custom(long timestamp, String key, LDContext context, LDValue data, Double metricValue) { + super(timestamp, context); + this.key = key; + this.data = LDValue.normalize(data); + this.metricValue = metricValue; + } + + /** + * The custom event key. + * @return the event key + */ + public String getKey() { + return key; + } + + /** + * The custom data associated with the event, if any. + * @return the event data (null is equivalent to {@link LDValue#ofNull()}) + */ + public LDValue getData() { + return data; + } + + /** + * The numeric metric value associated with the event, if any. + * @return the metric value or null + */ + public Double getMetricValue() { + return metricValue; + } + } + + /** + * An event created with the SDK's {@code identify} method (or generated automatically at startup + * if it is a client-side SDK). + */ + public static final class Identify extends Event { + /** + * Constructs an identify event. + * @param timestamp the timestamp in milliseconds + * @param context the context associated with the event + */ + public Identify(long timestamp, LDContext context) { + super(timestamp, context); + } + } + + /** + * An event created internally by the SDK to hold user data that may be referenced by multiple events. + */ + public static final class Index extends Event { + /** + * Constructs an index event. + * @param timestamp the timestamp in milliseconds + * @param context the context associated with the event + */ + public Index(long timestamp, LDContext context) { + super(timestamp, context); + } + } + + /** + * An event generated by a feature flag evaluation. + */ + public static final class FeatureRequest extends Event { + private final String key; + private final int variation; + private final LDValue value; + private final LDValue defaultVal; + private final int version; + private final String prereqOf; + private final boolean trackEvents; + private final Long debugEventsUntilDate; + private final EvaluationReason reason; + private final boolean debug; + + /** + * Constructs a feature request event. + * @param timestamp the timestamp in milliseconds + * @param key the flag key + * @param context the context associated with the event + * @param version the flag version, or -1 if the flag was not found + * @param variation the result variation, or -1 if there was an error + * @param value the result value + * @param defaultVal the default value passed by the application + * @param reason the evaluation reason, if it is to be included in the event + * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it + * @param trackEvents true if full event tracking is turned on for this flag + * @param debugEventsUntilDate if non-null, the time until which event debugging should be enabled + * @param debug true if this is a debugging event + */ + public FeatureRequest(long timestamp, String key, LDContext context, int version, int variation, LDValue value, + LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, Long debugEventsUntilDate, boolean debug) { + super(timestamp, context); + this.key = key; + this.version = version; + this.variation = variation; + this.value = value; + this.defaultVal = defaultVal; + this.prereqOf = prereqOf; + this.trackEvents = trackEvents; + this.debugEventsUntilDate = debugEventsUntilDate; + this.reason = reason; + this.debug = debug; + } + + /** + * The key of the feature flag that was evaluated. + * @return the flag key + */ + public String getKey() { + return key; + } + + /** + * The index of the selected flag variation, or -1 if the application default value was used. + * @return zero-based index of the variation, or -1 + */ + public int getVariation() { + return variation; + } + + /** + * The value of the selected flag variation. + * @return the value + */ + public LDValue getValue() { + return value; + } + + /** + * The application default value used in the evaluation. + * @return the application default + */ + public LDValue getDefaultVal() { + return defaultVal; + } + + /** + * The version of the feature flag that was evaluated, or -1 if the flag was not found. + * @return the flag version or null + */ + public int getVersion() { + return version; + } + + /** + * If this flag was evaluated as a prerequisite for another flag, the key of the other flag. + * @return a flag key or null + */ + public String getPrereqOf() { + return prereqOf; + } + + /** + * True if full event tracking is enabled for this flag. + * @return true if full event tracking is on + */ + public boolean isTrackEvents() { + return trackEvents; + } + + /** + * If debugging is enabled for this flag, the Unix millisecond time at which to stop debugging. + * @return a timestamp or null + */ + public Long getDebugEventsUntilDate() { + return debugEventsUntilDate; + } + + /** + * The {@link EvaluationReason} for this evaluation, or null if the reason was not requested for this evaluation. + * @return a reason object or null + */ + public EvaluationReason getReason() { + return reason; + } + + /** + * True if this event was generated due to debugging being enabled. + * @return true if this is a debug event + */ + public boolean isDebug() { + return debug; + } + + /** + * Creates a debug event with the same properties as this event. + * @return a debug event + */ + public FeatureRequest toDebugEvent() { + return new FeatureRequest(getCreationDate(), getKey(), getContext(), getVersion(), + getVariation(), getValue(), getDefaultVal(), getReason(), getPrereqOf(), + false, null, true); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventContextDeduplicator.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventContextDeduplicator.java new file mode 100644 index 0000000..ec8c846 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventContextDeduplicator.java @@ -0,0 +1,31 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.LDContext; + +/** + * Interface for a strategy for removing duplicate contexts from the event stream. This has + * been factored out of DefaultEventProcessor because the client-side and server-side SDKs + * behave differently (client-side does not send index events). + */ +public interface EventContextDeduplicator { + /** + * Returns the millisecond interval, if any, at which the event processor should call flush(). + * + * @return a number of milliseconds, or null if not applicable + */ + Long getFlushInterval(); + + /** + * Updates the internal state if necessary to reflect that we have seen the given context. + * Returns true if it is time to insert an index event for this context into the event output. + * + * @param context a context object + * @return true if an index event should be emitted + */ + boolean processContext(LDContext context); + + /** + * Forgets any cached context information, so all subsequent contexs will be treated as new. + */ + void flush(); +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java new file mode 100644 index 0000000..ebedfb0 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java @@ -0,0 +1,219 @@ +package com.launchdarkly.sdk.internal.events; + +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.AttributeRef; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; + +/** + * Implements serialization of contexts within JSON event data. This uses a similar schema to the + * regular context JSON schema (i.e. what you get if you call JsonSerialization.serialize() on an + * LDContext), but not quite the same, because it transforms the context to redact any attributes + * (or subproperties of attributes that are objects) that were designated as private, accumulating + * a list of the names of these in _meta.redactedAttributes. + *

+ * This implementation is optimized to avoid unnecessary work in the typical use case where there + * aren't any private attributes. + */ +class EventContextFormatter { + private final boolean allAttributesPrivate; + private final AttributeRef[] globalPrivateAttributes; + + EventContextFormatter(boolean allAttributesPrivate, AttributeRef[] globalPrivateAttributes) { + this.allAttributesPrivate = allAttributesPrivate; + this.globalPrivateAttributes = globalPrivateAttributes == null ? new AttributeRef[0] : globalPrivateAttributes; + } + + public void write(LDContext c, JsonWriter w) throws IOException { + if (c.isMultiple()) { + w.beginObject(); + w.name("kind").value("multi"); + for (int i = 0; i < c.getIndividualContextCount(); i++) { + LDContext c1 = c.getIndividualContext(i); + w.name(c1.getKind().toString()); + writeSingleKind(c1, w, false); + } + w.endObject(); + } else { + writeSingleKind(c, w, true); + } + } + + private void writeSingleKind(LDContext c, JsonWriter w, boolean includeKind) throws IOException { + w.beginObject(); + + // kind, key, and anonymous are never redacted + if (includeKind) { + w.name("kind").value(c.getKind().toString()); + } + w.name("key").value(c.getKey()); + if (c.isAnonymous()) { + w.name("anonymous").value(true); + } + + List redacted = null; + + if (c.getName() != null) { + if (isAttributeEntirelyPrivate(c, "name")) { + redacted = addOrCreate(redacted, "name"); + } else { + w.name("name").value(c.getName()); + } + } + + for (String attrName: c.getCustomAttributeNames()) { + redacted = writeOrRedactAttribute(w, c, attrName, c.getValue(attrName), redacted); + } + + boolean haveRedacted = redacted != null && !redacted.isEmpty(), + haveSecondary = c.getSecondary() != null; + if (haveRedacted || haveSecondary) { + w.name("_meta").beginObject(); + if (haveRedacted) { + w.name("redactedAttributes").beginArray(); + for (String a: redacted) { + w.value(a); + } + w.endArray(); + } + if (haveSecondary) { + w.name("secondary").value(c.getSecondary()); + } + w.endObject(); + } + + w.endObject(); + } + + private boolean isAttributeEntirelyPrivate(LDContext c, String attrName) { + if (allAttributesPrivate) { + return true; + } + AttributeRef privateRef = findPrivateRef(c, 1, attrName, null); + return privateRef != null && privateRef.getDepth() == 1; + } + + private List writeOrRedactAttribute( + JsonWriter w, + LDContext c, + String attrName, + LDValue value, + List redacted + ) throws IOException { + if (allAttributesPrivate) { + return addOrCreate(redacted, attrName); + } + return writeRedactedValue(w, c, 0, attrName, value, null, redacted); + } + + // This method implements the context-aware attribute redaction logic, in which an attribute + // can be 1. written as-is, 2. fully redacted, or 3. (for a JSON object) partially redacted. + // It returns the updated redacted attribute list. + private List writeRedactedValue( + JsonWriter w, + LDContext c, + int previousDepth, + String attrName, + LDValue value, + AttributeRef previousMatchRef, + List redacted + ) throws IOException { + // See findPrivateRef for the meaning of the previousMatchRef parameter. + int depth = previousDepth + 1; + AttributeRef privateRef = findPrivateRef(c, depth, attrName, previousMatchRef); + + // If privateRef is non-null, then it is either an exact match for the property we're looking at, + // or it refers to a subproperty of it (for instance, if we are redacting property "b" within + // attriute "a", it could be /a/b [depth 2] or /a/b/c [depth 3]). If the depth shows that it's an + // exact match, this whole value is redacted and we don't bother recursing. + if (privateRef != null && privateRef.getDepth() == depth) { + return addOrCreate(redacted, privateRef.toString()); + } + + // If privateRef is null (there was no matching private attribute)-- or, if privateRef isn't null + // but it refers to a subproperty, and this value isn't an object so it has no properties-- then + // we just write the value unredacted. + if (privateRef == null || value.getType() != LDValueType.OBJECT) { + writeNameAndValue(w, attrName, value); + return redacted; + } + + // At this point we know it is an object and we are redacting subproperties. + w.name(attrName).beginObject(); + for (String name: value.keys()) { + redacted = writeRedactedValue(w, c, depth, name, value.get(name), privateRef, redacted); + } + w.endObject(); + return redacted; + } + + // Searches both the globally private attributes and the per-context private attributes to find a + // match for the attribute or subproperty we're looking at. + // + // If we find one that exactly matches the current path (that is, the depth is the same), we + // return that one, because that would tell us that the entire attribute/subproperty should be + // redacted. If we don't find that, but we do find at least one match for a subproperty of this + // path (that is, it has the current path as a prefix, but the depth is greater), then we return + // it, to tell us that we'll need to recurse to redact subproperties. + // + // The previousMatchRef parameter is what we use instead of a stack to keep track of where we're + // at in recursive calls. It starts out as null at the top level. Then, every time we recurse to + // redact subproperties of an object, we set previousMatchRef to any AttributeRef that has the + // current subpath as a prefix; such an AttributeRef is guaranteed to exist, because we wouldn't + // have bothered to recurse if we hadn't found one. + private AttributeRef findPrivateRef(LDContext c, int depth, String attrName, AttributeRef previousMatchRef) { + AttributeRef nonExactMatch = null; + if (globalPrivateAttributes.length != 0) { // minor optimization to avoid creating an iterator if it's empty + for (AttributeRef globalPrivate: globalPrivateAttributes) { + if (matchPrivateRef(globalPrivate, depth, attrName, previousMatchRef)) { + if (globalPrivate.getDepth() == depth) { + return globalPrivate; + } + nonExactMatch = globalPrivate; + } + } + } + for (int i = 0; i < c.getPrivateAttributeCount(); i++) { + AttributeRef contextPrivate = c.getPrivateAttribute(i); + if (matchPrivateRef(contextPrivate, depth, attrName, previousMatchRef)) { + if (contextPrivate.getDepth() == depth) { + return contextPrivate; + } + nonExactMatch = contextPrivate; + } + } + return nonExactMatch; + } + + private static boolean matchPrivateRef(AttributeRef ref, int depth, String attrName, AttributeRef previousMatchRef) { + if (ref.getDepth() < depth) { + return false; + } + for (int i = 0; i < (depth - 1); i++) { + if (!ref.getComponent(i).equals(previousMatchRef.getComponent(i))) { + return false; + } + } + return ref.getComponent(depth - 1).equals(attrName); + } + + private static void writeNameAndValue(JsonWriter w, String name, LDValue value) throws IOException { + w.name(name); + gsonInstance().toJson(value, LDValue.class, w); + } + + private static List addOrCreate(List list, T value) { + if (list == null) { + list = new ArrayList<>(); + } + list.add(value); + return list; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java new file mode 100644 index 0000000..933648b --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java @@ -0,0 +1,213 @@ +package com.launchdarkly.sdk.internal.events; + +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.AttributeRef; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.internal.events.EventSummarizer.CounterValue; +import com.launchdarkly.sdk.internal.events.EventSummarizer.FlagInfo; +import com.launchdarkly.sdk.internal.events.EventSummarizer.SimpleIntKeyedMap; + +import java.io.IOException; +import java.io.Writer; +import java.util.Map; + +import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; + +/** + * Transforms analytics events and summary data into the JSON format that we send to LaunchDarkly. + * Rather than creating intermediate objects to represent this schema, we use the Gson streaming + * output API to construct JSON directly. + * + * Test coverage for this logic is in EventOutputTest and DefaultEventProcessorOutputTest. The + * handling of context data and private attribute redaction is implemented in EventContextFormatter + * and tested in more detail in EventContextFormatterTest. + */ +final class EventOutputFormatter { + private final EventContextFormatter contextFormatter; + + EventOutputFormatter(EventsConfiguration config) { + this.contextFormatter = new EventContextFormatter( + config.allAttributesPrivate, + config.privateAttributes.toArray(new AttributeRef[config.privateAttributes.size()])); + } + + final int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writer writer) throws IOException { + int count = 0; + JsonWriter jsonWriter = new JsonWriter(writer); + jsonWriter.beginArray(); + for (Event event: events) { + if (writeOutputEvent(event, jsonWriter)) { + count++; + } + } + if (!summary.isEmpty()) { + writeSummaryEvent(summary, jsonWriter); + count++; + } + jsonWriter.endArray(); + jsonWriter.flush(); + return count; + } + + private final boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException { + if (event.getContext() == null || !event.getContext().isValid()) { + // The SDK should never send us an event without a valid context, but if we somehow get one, + // just skip the event since there's no way to serialize it. + return false; + } + if (event instanceof Event.FeatureRequest) { + Event.FeatureRequest fe = (Event.FeatureRequest)event; + jw.beginObject(); + writeKindAndCreationDate(jw, fe.isDebug() ? "debug" : "feature", event.getCreationDate()); + jw.name("key").value(fe.getKey()); + if (fe.isDebug()) { + writeContext(fe.getContext(), jw); + } else { + writeContextKeys(fe.getContext(), jw); + } + if (fe.getVersion() >= 0) { + jw.name("version"); + jw.value(fe.getVersion()); + } + if (fe.getVariation() >= 0) { + jw.name("variation"); + jw.value(fe.getVariation()); + } + writeLDValue("value", fe.getValue(), jw); + writeLDValue("default", fe.getDefaultVal(), jw); + if (fe.getPrereqOf() != null) { + jw.name("prereqOf"); + jw.value(fe.getPrereqOf()); + } + writeEvaluationReason("reason", fe.getReason(), jw); + jw.endObject(); + } else if (event instanceof Event.Identify) { + jw.beginObject(); + writeKindAndCreationDate(jw, "identify", event.getCreationDate()); + writeContext(event.getContext(), jw); + jw.endObject(); + } else if (event instanceof Event.Custom) { + Event.Custom ce = (Event.Custom)event; + jw.beginObject(); + writeKindAndCreationDate(jw, "custom", event.getCreationDate()); + jw.name("key").value(ce.getKey()); + writeContextKeys(ce.getContext(), jw); + writeLDValue("data", ce.getData(), jw); + if (ce.getMetricValue() != null) { + jw.name("metricValue"); + jw.value(ce.getMetricValue()); + } + jw.endObject(); + } else if (event instanceof Event.Index) { + jw.beginObject(); + writeKindAndCreationDate(jw, "index", event.getCreationDate()); + writeContext(event.getContext(), jw); + jw.endObject(); + } else { + return false; + } + return true; + } + + private final void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw) throws IOException { + jw.beginObject(); + + jw.name("kind"); + jw.value("summary"); + + jw.name("startDate"); + jw.value(summary.startDate); + jw.name("endDate"); + jw.value(summary.endDate); + + jw.name("features"); + jw.beginObject(); + + for (Map.Entry flag: summary.counters.entrySet()) { + String flagKey = flag.getKey(); + FlagInfo flagInfo = flag.getValue(); + + jw.name(flagKey); + jw.beginObject(); + + writeLDValue("default", flagInfo.defaultVal, jw); + jw.name("contextKinds").beginArray(); + for (String kind: flagInfo.contextKinds) { + jw.value(kind); + } + jw.endArray(); + + jw.name("counters"); + jw.beginArray(); + + for (int i = 0; i < flagInfo.versionsAndVariations.size(); i++) { + int version = flagInfo.versionsAndVariations.keyAt(i); + SimpleIntKeyedMap variations = flagInfo.versionsAndVariations.valueAt(i); + for (int j = 0; j < variations.size(); j++) { + int variation = variations.keyAt(j); + CounterValue counter = variations.valueAt(j); + + jw.beginObject(); + + if (variation >= 0) { + jw.name("variation").value(variation); + } + if (version >= 0) { + jw.name("version").value(version); + } else { + jw.name("unknown").value(true); + } + writeLDValue("value", counter.flagValue, jw); + jw.name("count").value(counter.count); + + jw.endObject(); + } + } + + jw.endArray(); // end of "counters" array + jw.endObject(); // end of this flag + } + + jw.endObject(); // end of "features" + jw.endObject(); // end of summary event object + } + + private final void writeKindAndCreationDate(JsonWriter jw, String kind, long creationDate) throws IOException { + jw.name("kind").value(kind); + jw.name("creationDate").value(creationDate); + } + + private final void writeContext(LDContext context, JsonWriter jw) throws IOException { + jw.name("context"); + contextFormatter.write(context, jw); + } + + private final void writeContextKeys(LDContext context, JsonWriter jw) throws IOException { + jw.name("contextKeys").beginObject(); + for (int i = 0; i < context.getIndividualContextCount(); i++) { + LDContext c = context.getIndividualContext(i); + if (c != null) { + jw.name(c.getKind().toString()).value(c.getKey()); + } + } + jw.endObject(); + } + + private final void writeLDValue(String key, LDValue value, JsonWriter jw) throws IOException { + if (value == null || value.isNull()) { + return; + } + jw.name(key); + gsonInstance().toJson(value, LDValue.class, jw); // LDValue defines its own custom serializer + } + + private final void writeEvaluationReason(String key, EvaluationReason er, JsonWriter jw) throws IOException { + if (er == null) { + return; + } + jw.name(key); + gsonInstance().toJson(er, EvaluationReason.class, jw); // EvaluationReason defines its own custom serializer + } +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventSender.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventSender.java new file mode 100644 index 0000000..2f2a31e --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventSender.java @@ -0,0 +1,92 @@ +package com.launchdarkly.sdk.internal.events; + +import java.io.Closeable; +import java.net.URI; +import java.util.Date; + +/** + * Internal interface for a component that can deliver preformatted event data. + *

+ * This is separate from the public EventSender interface in the SDK that applications can use to + * provide a custom implementation. The latter is used as a wrapper for this one, so we do not have + * to expose any types from the internal events code. The public interface is simpler because it + * only needs to return success/failure/shutdown status; the use of the Date header is an + * implementation detail that is specific to the default HTTP implementation of event delivery. + */ +public interface EventSender extends Closeable { + /** + * Attempt to deliver an analytics event data payload. + *

+ * This method will be called synchronously from an event delivery worker thread. + * + * @param data the preformatted JSON data, in UTF-8 encoding + * @param eventCount the number of individual events in the data + * @param eventsBaseUri the configured events endpoint base URI + * @return a {@link Result} + */ + Result sendAnalyticsEvents(byte[] data, int eventCount, URI eventsBaseUri); + + /** + * Attempt to deliver a diagnostic event data payload. + *

+ * This method will be called synchronously from an event delivery worker thread. + * + * @param data the preformatted JSON data, as a string + * @param eventsBaseUri the configured events endpoint base URI + * @return a {@link Result} + */ + Result sendDiagnosticEvent(byte[] data, URI eventsBaseUri); + + /** + * Encapsulates the results of a call to an EventSender method. + */ + public static final class Result { + private boolean success; + private boolean mustShutDown; + private Date timeFromServer; + + /** + * Constructs an instance. + * + * @param success true if the events were delivered + * @param mustShutDown true if an unrecoverable error (such as an HTTP 401 error, implying that the + * SDK key is invalid) means the SDK should permanently stop trying to send events + * @param timeFromServer the parsed value of an HTTP Date header received from the remote server, + * if any; this is used to compensate for differences between the application's time and server time + */ + public Result(boolean success, boolean mustShutDown, Date timeFromServer) { + this.success = success; + this.mustShutDown = mustShutDown; + this.timeFromServer = timeFromServer; + } + + /** + * Returns true if the events were delivered. + * + * @return true if the events were delivered + */ + public boolean isSuccess() { + return success; + } + + /** + * Returns true if an unrecoverable error (such as an HTTP 401 error, implying that the + * SDK key is invalid) means the SDK should permanently stop trying to send events + * + * @return true if event delivery should shut down + */ + public boolean isMustShutDown() { + return mustShutDown; + } + + /** + * Returns the parsed value of an HTTP Date header received from the remote server, if any. This + * is used to compensate for differences between the application's time and server time. + * + * @return a date value or null + */ + public Date getTimeFromServer() { + return timeFromServer; + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizer.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizer.java new file mode 100644 index 0000000..9eaef83 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizer.java @@ -0,0 +1,320 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Manages the state of summarizable information for the EventProcessor. Note that the + * methods of this class are deliberately not thread-safe, because they should always + * be called from EventProcessor's single message-processing thread. + */ +final class EventSummarizer { + private EventSummary eventsState; + + EventSummarizer() { + this.eventsState = new EventSummary(); + } + + /** + * Adds information about an evaluation to our counters. + * + * @param timestamp the millisecond timestamp + * @param flagKey the flag key + * @param flagVersion the flag version, or -1 if the flag is unknown + * @param variation the result variation, or -1 if none + * @param value the result value + * @param defaultValue the application default value + * @param context the evaluation context + */ + void summarizeEvent( + long timestamp, + String flagKey, + int flagVersion, + int variation, + LDValue value, + LDValue defaultValue, + LDContext context + ) { + eventsState.incrementCounter(flagKey, flagVersion, variation, value, defaultValue, context); + eventsState.noteTimestamp(timestamp); + } + + /** + * Gets the current summarized event data, and resets the EventSummarizer's state to contain + * a new empty EventSummary. + * + * @return the summary state + */ + EventSummary getSummaryAndReset() { + EventSummary ret = eventsState; + clear(); + return ret; + } + + /** + * Indicates that we decided not to send the summary values returned by {@link #getSummaryAndReset()}, + * and instead we should return to using the previous state object and keep accumulating data + * in it. + */ + void restoreTo(EventSummary previousState) { + eventsState = previousState; + } + + /** + * Returns true if there is no summary data in the current state. + * + * @return true if the state is empty + */ + boolean isEmpty() { + return eventsState.isEmpty(); + } + + void clear() { + eventsState = new EventSummary(); + } + + static final class EventSummary { + final Map counters; + long startDate; + long endDate; + + EventSummary() { + counters = new HashMap<>(); + } + + EventSummary(EventSummary from) { + counters = new HashMap<>(from.counters); + startDate = from.startDate; + endDate = from.endDate; + } + + boolean isEmpty() { + return counters.isEmpty(); + } + + void incrementCounter( + String flagKey, + int flagVersion, + int variation, + LDValue flagValue, + LDValue defaultVal, + LDContext context + ) { + FlagInfo flagInfo = counters.get(flagKey); + if (flagInfo == null) { + flagInfo = new FlagInfo(defaultVal, new SimpleIntKeyedMap<>(), new HashSet<>()); + counters.put(flagKey, flagInfo); + } + for (int i = 0; i < context.getIndividualContextCount(); i++) { + flagInfo.contextKinds.add(context.getIndividualContext(i).getKind().toString()); + } + + SimpleIntKeyedMap variations = flagInfo.versionsAndVariations.get(flagVersion); + if (variations == null) { + variations = new SimpleIntKeyedMap<>(); + flagInfo.versionsAndVariations.put(flagVersion, variations); + } + + CounterValue value = variations.get(variation); + if (value == null) { + variations.put(variation, new CounterValue(1, flagValue)); + } else { + value.increment(); + } + } + + void noteTimestamp(long time) { + if (startDate == 0 || time < startDate) { + startDate = time; + } + if (time > endDate) { + endDate = time; + } + } + + @Override + public boolean equals(Object other) { + if (other instanceof EventSummary) { + EventSummary o = (EventSummary)other; + return o.counters.equals(counters) && startDate == o.startDate && endDate == o.endDate; + } + return false; + } + + @Override + public int hashCode() { + // We can't make meaningful hash codes for EventSummary, because the same counters could be + // represented differently in our Map. It doesn't matter because there's no reason to use an + // EventSummary instance as a hash key. + return 0; + } + } + + static final class FlagInfo { + final LDValue defaultVal; + final SimpleIntKeyedMap> versionsAndVariations; + final Set contextKinds; + + FlagInfo(LDValue defaultVal, SimpleIntKeyedMap> versionsAndVariations, + Set contextKinds) { + this.defaultVal = defaultVal; + this.versionsAndVariations = versionsAndVariations; + this.contextKinds = contextKinds; + } + + @Override + public boolean equals(Object other) { // used only in tests + if (other instanceof FlagInfo) { + FlagInfo o = (FlagInfo)other; + return o.defaultVal.equals(this.defaultVal) && o.versionsAndVariations.equals(this.versionsAndVariations) && + o.contextKinds.equals(this.contextKinds); + } + return false; + } + + @Override + public int hashCode() { // used only in tests + return this.defaultVal.hashCode() + 31 * versionsAndVariations.hashCode(); + } + + @Override + public String toString() { // used only in tests + return "(default=" + defaultVal + ", counters=" + versionsAndVariations + ", contextKinds=" + + String.join(",", contextKinds) + ")"; + } + } + + static final class CounterValue { + long count; + final LDValue flagValue; + + CounterValue(long count, LDValue flagValue) { + this.count = count; + this.flagValue = flagValue; + } + + void increment() { + count = count + 1; + } + + @Override + public boolean equals(Object other) { // used only in tests + if (other instanceof CounterValue) { + CounterValue o = (CounterValue)other; + return count == o.count && Objects.equals(flagValue, o.flagValue); + } + return false; + } + + @Override + public String toString() { // used only in tests + return "(" + count + "," + flagValue + ")"; + } + } + + // A very simple array-backed structure with map-like semantics for primitive int keys. This + // is highly specialized for the EventSummarizer use case (which is why it is an inner class + // of EventSummarizer, to emphasize that it should not be used elsewhere). It makes the + // following assumptions: + // - The number of keys will almost always be small: most flags have only a few variations, + // and most flags will have only one version or a few versions during the lifetime of an + // event payload. Therefore, we use simple iteration and int comparisons for the keys; the + // overhead of this is likely less than the overhead of maintaining a hashtable and creating + // objects for its keys and iterators. + // - Data will never be deleted from the map after being added (the summarizer simply makes + // a new map when it's time to start over). + static final class SimpleIntKeyedMap { + private static final int INITIAL_CAPACITY = 4; + + private int[] keys; + private Object[] values; + private int n; + + SimpleIntKeyedMap() { + keys = new int[INITIAL_CAPACITY]; + values = new Object[INITIAL_CAPACITY]; + } + + int size() { + return n; + } + + int capacity() { + return keys.length; + } + + int keyAt(int index) { + return keys[index]; + } + + @SuppressWarnings("unchecked") + T valueAt(int index) { + return (T)values[index]; + } + + @SuppressWarnings("unchecked") + T get(int key) { + for (int i = 0; i < n; i++) { + if (keys[i] == key) { + return (T)values[i]; + } + } + return null; + } + + SimpleIntKeyedMap put(int key, T value) { + for (int i = 0; i < n; i++) { + if (keys[i] == key) { + values[i] = value; + return this; + } + } + if (n == keys.length) { + int[] newKeys = new int[keys.length * 2]; + System.arraycopy(keys, 0, newKeys, 0, n); + Object[] newValues = new Object[keys.length * 2]; + System.arraycopy(values, 0, newValues, 0, n); + keys = newKeys; + values = newValues; + } + keys[n] = key; + values[n] = value; + n++; + return this; + } + + @SuppressWarnings("unchecked") + @Override + public boolean equals(Object o) { // used only in tests + if (o instanceof SimpleIntKeyedMap) { + SimpleIntKeyedMap other = (SimpleIntKeyedMap)o; + if (this.n == other.n) { + for (int i = 0; i < n; i++) { + T value1 = (T)values[i], value2 = other.get(keys[i]); + if (!Objects.equals(value1, value2)) { + return false; + } + } + return true; + } + } + return false; + } + + @Override + public String toString() { // used only in tests + StringBuilder s = new StringBuilder("{"); + for (int i = 0; i < n; i++) { + s.append(keys[i]).append("=").append(values[i] == null ? "null" : values[i].toString()); + } + s.append("}"); + return s.toString(); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java new file mode 100644 index 0000000..d1dbe23 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java @@ -0,0 +1,61 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.AttributeRef; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * Internal representation of the configuration properties for {@link DefaultEventProcessor}. + * This class is not exposed in the public SDK API. + */ +public final class EventsConfiguration { + final boolean allAttributesPrivate; + final int capacity; + final EventContextDeduplicator contextDeduplicator; + final long diagnosticRecordingIntervalMillis; + final DiagnosticStore diagnosticStore; + final EventSender eventSender; + final URI eventsUri; + final long flushIntervalMillis; + final List privateAttributes; + + /** + * Creates an instance. + * + * @param allAttributesPrivate true if all attributes are private + * @param capacity event buffer capacity (if zero or negative, a value of 1 is used to prevent errors) + * @param contextDeduplicator optional EventContextDeduplicator; null for client-side SDK + * @param diagnosticRecordingIntervalMillis diagnostic recording interval + * @param diagnosticStore optional DiagnosticStore; null if diagnostics are disabled + * @param eventSender event delivery component; must not be null + * @param eventsUri events base URI + * @param flushIntervalMillis event flush interval + * @param privateAttributes list of private attribute references; may be null + */ + public EventsConfiguration( + boolean allAttributesPrivate, + int capacity, + EventContextDeduplicator contextDeduplicator, + long diagnosticRecordingIntervalMillis, + DiagnosticStore diagnosticStore, + EventSender eventSender, + URI eventsUri, + long flushIntervalMillis, + Collection privateAttributes + ) { + super(); + this.allAttributesPrivate = allAttributesPrivate; + this.capacity = capacity >= 0 ? capacity : 1; + this.contextDeduplicator = contextDeduplicator; + this.diagnosticRecordingIntervalMillis = diagnosticRecordingIntervalMillis; + this.diagnosticStore = diagnosticStore; + this.eventSender = eventSender; + this.eventsUri = eventsUri; + this.flushIntervalMillis = flushIntervalMillis; + this.privateAttributes = privateAttributes == null ? Collections.emptyList() : new ArrayList<>(privateAttributes); + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/package-info.java b/src/main/java/com/launchdarkly/sdk/internal/events/package-info.java new file mode 100644 index 0000000..3a5d24b --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/package-info.java @@ -0,0 +1,9 @@ +/** + * This package contains the core implementation of analytics events and diagnostic events + * used by the Java SDK and Android SDK. + *

+ * All types in this package are for internal LaunchDarkly use only, and are subject to change. + * They are not part of the public supported API of the SDKs, and they should not be referenced + * by application code. + */ +package com.launchdarkly.sdk.internal.events; diff --git a/src/main/java/com/launchdarkly/sdk/internal/http/HttpErrors.java b/src/main/java/com/launchdarkly/sdk/internal/http/HttpErrors.java new file mode 100644 index 0000000..e99126e --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/http/HttpErrors.java @@ -0,0 +1,95 @@ +package com.launchdarkly.sdk.internal.http; + +import com.launchdarkly.logging.LDLogger; + +/** + * Contains shared helpers related to HTTP response validation. + *

+ * This class is for internal use only and should not be documented in the SDK API. It is not + * supported for any use outside of the LaunchDarkly SDKs, and is subject to change without notice. + */ +public abstract class HttpErrors { + private HttpErrors() {} + + /** + * Represents an HTTP response error as an exception. + */ + @SuppressWarnings("serial") + public static final class HttpErrorException extends Exception { + private final int status; + + /** + * Constructs an instance. + * @param status the status code + */ + public HttpErrorException(int status) { + super("HTTP error " + status); + this.status = status; + } + + /** + * Returns the status code. + * @return the status code + */ + public int getStatus() { + return status; + } + } + + /** + * Tests whether an HTTP error status represents a condition that might resolve on its own if we retry. + * @param statusCode the HTTP status + * @return true if retrying makes sense; false if it should be considered a permanent failure + */ + public static boolean isHttpErrorRecoverable(int statusCode) { + if (statusCode >= 400 && statusCode < 500) { + switch (statusCode) { + case 400: // bad request + case 408: // request timeout + case 429: // too many requests + return true; + default: + return false; // all other 4xx errors are unrecoverable + } + } + return true; + } + + /** + * Logs an HTTP error or network error at the appropriate level and determines whether it is recoverable + * (as defined by {@link #isHttpErrorRecoverable(int)}). + * + * @param logger the logger to log to + * @param errorDesc description of the error + * @param errorContext a phrase like "when doing such-and-such" + * @param statusCode HTTP status code, or 0 for a network error + * @param recoverableMessage a phrase like "will retry" to use if the error is recoverable + * @return true if the error is recoverable + */ + public static boolean checkIfErrorIsRecoverableAndLog( + LDLogger logger, + String errorDesc, + String errorContext, + int statusCode, + String recoverableMessage + ) { + if (statusCode > 0 && !isHttpErrorRecoverable(statusCode)) { + logger.error("Error {} (giving up permanently): {}", errorContext, errorDesc); + return false; + } else { + logger.warn("Error {} ({}): {}", errorContext, recoverableMessage, errorDesc); + return true; + } + } + + /** + * Returns a text description of an HTTP error. + * + * @param statusCode the status code + * @return the error description + */ + public static String httpErrorDescription(int statusCode) { + return "HTTP error " + statusCode + + (statusCode == 401 || statusCode == 403 ? " (invalid SDK key)" : ""); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/http/HttpHelpers.java b/src/main/java/com/launchdarkly/sdk/internal/http/HttpHelpers.java new file mode 100644 index 0000000..87a3029 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/http/HttpHelpers.java @@ -0,0 +1,53 @@ +package com.launchdarkly.sdk.internal.http; + +import java.net.URI; + +/** + * Helper methods related to HTTP. + *

+ * This class is for internal use only and should not be documented in the SDK API. It is not + * supported for any use outside of the LaunchDarkly SDKs, and is subject to change without notice. + */ +public abstract class HttpHelpers { + private HttpHelpers() {} + + /** + * Safely concatenates a path, ensuring that there is exactly one slash between components. + * + * @param baseUri the base URI + * @param path the path to add + * @return a new URI + */ + public static URI concatenateUriPath(URI baseUri, String path) { + String uriStr = baseUri.toString(); + String addPath = path.startsWith("/") ? path.substring(1) : path; + return URI.create(uriStr + (uriStr.endsWith("/") ? "" : "/") + addPath); + } + + /** + * Tests whether a string contains only characters that are safe to use in an HTTP header value. + *

+ * This is specifically testing whether the string would be considered a valid HTTP header value + * by the OkHttp client. The actual HTTP spec does not prohibit characters 127 and higher; OkHttp's + * check is overly strict, as was pointed out in https://github.com/square/okhttp/issues/2016. + * But all OkHttp 3.x and 4.x versions so far have continued to enforce that check. Control + * characters other than a tab are always illegal. + * + * The value we're mainly concerned with is the SDK key (Authorization header). If an SDK key + * accidentally has (for instance) a newline added to it, we don't want to end up having OkHttp + * throw an exception mentioning the value, which might get logged (https://github.com/square/okhttp/issues/6738). + * + * @param value a string + * @return true if valid + */ + public static boolean isAsciiHeaderValue(String value) { + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + if ((ch < 0x20 || ch > 0x7e) && ch != '\t') { + return false; + } + } + return true; + } + +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/http/HttpProperties.java b/src/main/java/com/launchdarkly/sdk/internal/http/HttpProperties.java new file mode 100644 index 0000000..5ad4ba5 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/http/HttpProperties.java @@ -0,0 +1,159 @@ +package com.launchdarkly.sdk.internal.http; + +import java.net.Proxy; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import javax.net.SocketFactory; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + +import okhttp3.Authenticator; +import okhttp3.ConnectionPool; +import okhttp3.Headers; +import okhttp3.OkHttpClient; + +/** + * Internal container for HTTP parameters used by SDK components. Includes logic for creating an + * OkHttp client. + *

+ * This is separate from any public HTTP configuration/builder classes that are part of the SDK API. + * Those are transformed into this when the SDK is constructing components. The public API does not + * reference any OkHttp classes, but this internal class does. + */ +public final class HttpProperties { + private static final int DEFAULT_TIMEOUT = 10000; // not used by the SDKs, just prevents invalid test conditions + + private final long connectTimeoutMillis; + private final Map defaultHeaders; + private final Proxy proxy; + private final Authenticator proxyAuth; + private final SocketFactory socketFactory; + private final long socketTimeoutMillis; + private final SSLSocketFactory sslSocketFactory; + private final X509TrustManager trustManager; + + /** + * Constructs an instance. + * + * @param connectTimeoutMillis connection timeout milliseconds + * @param defaultHeaders headers to add to all requests + * @param proxy optional proxy + * @param proxyAuth optional proxy authenticator + * @param socketFactory optional socket factory + * @param socketTimeoutMillis socket timeout milliseconds + * @param sslSocketFactory optional SSL socket factory + * @param trustManager optional SSL trust manager + */ + public HttpProperties(long connectTimeoutMillis, Map defaultHeaders, Proxy proxy, + Authenticator proxyAuth, SocketFactory socketFactory, long socketTimeoutMillis, SSLSocketFactory sslSocketFactory, + X509TrustManager trustManager) { + super(); + this.connectTimeoutMillis = connectTimeoutMillis <= 0 ? DEFAULT_TIMEOUT : connectTimeoutMillis; + this.defaultHeaders = defaultHeaders == null ? Collections.emptyMap() : new HashMap<>(defaultHeaders); + this.proxy = proxy; + this.proxyAuth = proxyAuth; + this.socketFactory = socketFactory; + this.socketTimeoutMillis = socketTimeoutMillis <= 0 ? DEFAULT_TIMEOUT : socketTimeoutMillis; + this.sslSocketFactory = sslSocketFactory; + this.trustManager = trustManager; + } + + /** + * Returns a minimal set of properties. + * + * @return a default instance + */ + public static HttpProperties defaults() { + return new HttpProperties(0, null, null, null, null, 0, null, null); + } + + /** + * Returns an immutable view of the default headers. + * + * @return the default headers + */ + public Iterable> getDefaultHeaders() { + return defaultHeaders.entrySet(); + } + + /** + * Applies the configured properties to an OkHttp client builder. + * + * @param builder the client builder + */ + public void applyToHttpClientBuilder(OkHttpClient.Builder builder) { + builder.connectionPool(new ConnectionPool(5, 5, TimeUnit.SECONDS)); + if (connectTimeoutMillis > 0) { + builder.connectTimeout(connectTimeoutMillis, TimeUnit.MILLISECONDS); + } + if (socketTimeoutMillis > 0) { + builder.readTimeout(socketTimeoutMillis, TimeUnit.MILLISECONDS) + .writeTimeout(socketTimeoutMillis, TimeUnit.MILLISECONDS); + } + builder.retryOnConnectionFailure(false); // we will implement our own retry logic + + if (socketFactory != null) { + builder.socketFactory(socketFactory); + } + + if (sslSocketFactory != null) { + builder.sslSocketFactory(sslSocketFactory, trustManager); + } + + if (proxy != null) { + builder.proxy(proxy); + if (proxyAuth != null) { + builder.proxyAuthenticator(proxyAuth); + } + } + } + + /** + * Returns an OkHttp client builder initialized with the configured properties. + * + * @return a client builder + */ + public OkHttpClient.Builder toHttpClientBuilder() { + OkHttpClient.Builder builder = new OkHttpClient.Builder(); + applyToHttpClientBuilder(builder); + return builder; + } + + /** + * Returns an OkHttp Headers builder initialized with the default headers. + * + * @return a Headers builder + */ + public Headers.Builder toHeadersBuilder() { + Headers.Builder builder = new Headers.Builder(); + for (Map.Entry kv: getDefaultHeaders()) { + builder.add(kv.getKey(), kv.getValue()); + } + return builder; + } + + /** + * Attempts to completely shut down an OkHttp client. + * + * @param client the client to stop + */ + public static void shutdownHttpClient(OkHttpClient client) { + if (client.dispatcher() != null) { + client.dispatcher().cancelAll(); + if (client.dispatcher().executorService() != null) { + client.dispatcher().executorService().shutdown(); + } + } + if (client.connectionPool() != null) { + client.connectionPool().evictAll(); + } + if (client.cache() != null) { + try { + client.cache().close(); + } catch (Exception e) {} + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/http/package-info.java b/src/main/java/com/launchdarkly/sdk/internal/http/package-info.java new file mode 100644 index 0000000..9fe15e9 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/http/package-info.java @@ -0,0 +1,9 @@ +/** + * This package contains HTTP-related helpers that can be used by both the Java SDK and the + * Android SDK, based on v4.x of the OkHttp client. + *

+ * All types in this package are for internal LaunchDarkly use only, and are subject to change. + * They are not part of the public supported API of the SDKs, and they should not be referenced + * by application code. + */ +package com.launchdarkly.sdk.internal.http; diff --git a/src/main/java/com/launchdarkly/sdk/internal/package-info.java b/src/main/java/com/launchdarkly/sdk/internal/package-info.java index 11767f4..6e77750 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/internal/package-info.java @@ -1,9 +1,9 @@ /** - * General-purpose helper types and methods for use in LaunchDarkly SDK code. + * This package contains general-purpose helpers that can be used by both the Java SDK and the + * Android SDK. *

- * All symbols in this package are for internal SDK use only. They are subject to change - * without notice, and any direct reference to them in application code is unsupported. - * They have public scope only because they need to be available to LaunchDarkly SDK - * code in other packages. + * All types in this package are for internal LaunchDarkly use only, and are subject to change. + * They are not part of the public supported API of the SDKs, and they should not be referenced + * by application code. */ package com.launchdarkly.sdk.internal; diff --git a/src/test/java/com/launchdarkly/sdk/internal/BaseInternalTest.java b/src/test/java/com/launchdarkly/sdk/internal/BaseInternalTest.java new file mode 100644 index 0000000..932256d --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/BaseInternalTest.java @@ -0,0 +1,35 @@ +package com.launchdarkly.sdk.internal; + +import com.launchdarkly.logging.LDLogAdapter; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogCapture; +import com.launchdarkly.logging.Logs; + +import org.junit.Rule; +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; + +@SuppressWarnings("javadoc") +public class BaseInternalTest { + @Rule public DumpLogIfTestFails dumpLogIfTestFails; + + protected final LDLogAdapter testLogging; + protected final LDLogger testLogger; + protected final LogCapture logCapture; + + protected BaseInternalTest() { + logCapture = Logs.capture(); + testLogging = logCapture; + testLogger = LDLogger.withAdapter(testLogging, ""); + dumpLogIfTestFails = new DumpLogIfTestFails(); + } + + class DumpLogIfTestFails extends TestWatcher { + @Override + protected void failed(Throwable e, Description description) { + for (LogCapture.Message message: logCapture.getMessages()) { + System.out.println("LOG {" + description.getDisplayName() + "} >>> " + message.toStringWithTimestamp()); + } + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/PlaceholderTest.java b/src/test/java/com/launchdarkly/sdk/internal/PlaceholderTest.java deleted file mode 100644 index e7943f4..0000000 --- a/src/test/java/com/launchdarkly/sdk/internal/PlaceholderTest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.launchdarkly.sdk.internal; - -import org.junit.Test; - -public class PlaceholderTest extends BaseTest { - @Test - public void placeholder() {} -} diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java new file mode 100644 index 0000000..0725e70 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java @@ -0,0 +1,518 @@ +package com.launchdarkly.sdk.internal.events; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.google.gson.Gson; +import com.launchdarkly.sdk.AttributeRef; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.internal.BaseInternalTest; +import com.launchdarkly.sdk.internal.http.HttpProperties; +import com.launchdarkly.testhelpers.JsonAssertions; +import com.launchdarkly.testhelpers.JsonTestValue; + +import org.hamcrest.Matcher; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; +import static com.launchdarkly.testhelpers.JsonAssertions.isJsonArray; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonEqualsValue; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonProperty; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonUndefined; +import static com.launchdarkly.testhelpers.JsonTestValue.jsonFromValue; +import static java.util.concurrent.Executors.newSingleThreadScheduledExecutor; +import static org.hamcrest.Matchers.allOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@SuppressWarnings("javadoc") +public abstract class BaseEventTest extends BaseInternalTest { + public static final String SDK_KEY = "SDK_KEY"; + public static final long FAKE_TIME = 100000; + public static final String FLAG_KEY = "flagkey"; + public static final int FLAG_VERSION = 11; + public static final URI FAKE_URI = URI.create("http://fake"); + public static final LDContext user = LDContext.builder("userkey").name("Red").build(); + public static final Gson gson = new Gson(); + public static final LDValue userJson = LDValue.buildObject().put("kind", "user") + .put("key", "userkey").put("name", "Red").build(); + public static final LDValue filteredUserJson = LDValue.buildObject().put("kind", "user") + .put("key", "userkey").put("_meta", LDValue.parse("{\"redactedAttributes\":[\"name\"]}")).build(); + + public static ScheduledExecutorService sharedExecutor = newSingleThreadScheduledExecutor( + new ThreadFactoryBuilder().setNameFormat("tests-sharedExecutor-%d").build()); + + public static void assertJsonEquals(LDValue expected, LDValue actual) { + JsonAssertions.assertJsonEquals(expected.toJsonString(), actual.toJsonString()); + } + + public static EventsConfigurationBuilder baseConfig(EventSender es) { + return new EventsConfigurationBuilder().eventSender(es); + } + + public static HttpProperties defaultHttpProperties() { + return new HttpProperties(0, null, null, null, null, 0, null, null); + } + + public DefaultEventProcessor makeEventProcessor(EventsConfigurationBuilder ec) { + return makeEventProcessor(ec, null); + } + + public DefaultEventProcessor makeEventProcessor( + EventsConfigurationBuilder ec, + DiagnosticStore diagnosticStore + ) { + return new DefaultEventProcessor( + ec.build(), + sharedExecutor, + Thread.MAX_PRIORITY, + testLogger + ); + } + + public static EventsConfiguration defaultEventsConfig() { + return makeEventsConfig(false, null); + } + + public static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, + Collection privateAttributes) { + return new EventsConfiguration( + allAttributesPrivate, + 0, + null, + 100000, // arbitrary long flush interval + null, + null, + null, + 100000, // arbitrary long flush interval + privateAttributes + ); + } + + public static EvaluationDetail simpleEvaluation(int variation, LDValue value) { + return EvaluationDetail.fromValue(value, variation, EvaluationReason.off()); + } + + public static final class MockEventSender implements EventSender { + volatile boolean closed; + volatile Result result = new Result(true, false, null); + volatile RuntimeException fakeError = null; + volatile IOException fakeErrorOnClose = null; + volatile CountDownLatch receivedCounter = null; + volatile Object waitSignal = null; + + final BlockingQueue receivedParams = new LinkedBlockingQueue<>(); + + static final class Params { + final boolean diagnostic; + final String data; + final int eventCount; + final URI eventsBaseUri; + + Params(boolean diagnostic, String data, int eventCount, URI eventsBaseUri) { + this.diagnostic = diagnostic; + this.data = data; + this.eventCount = eventCount; + assertNotNull(eventsBaseUri); + this.eventsBaseUri = eventsBaseUri; + } + } + + @Override + public Result sendAnalyticsEvents(byte[] data, int eventCount, URI eventsBaseUri) { + return receive(false, data, eventCount, eventsBaseUri); + } + + @Override + public Result sendDiagnosticEvent(byte[] data, URI eventsBaseUri) { + return receive(true, data, 1, eventsBaseUri); + } + + @Override + public void close() throws IOException { + closed = true; + if (fakeErrorOnClose != null) { + throw fakeErrorOnClose; + } + } + + private Result receive(boolean diagnostic, byte[] data, int eventCount, URI eventsBaseUri) { + receivedParams.add(new Params(diagnostic, new String(data, Charset.forName("UTF-8")), eventCount, eventsBaseUri)); + if (waitSignal != null) { + // this is used in DefaultEventProcessorTest.eventsAreKeptInBufferIfAllFlushWorkersAreBusy + synchronized (waitSignal) { + if (receivedCounter != null) { + receivedCounter.countDown(); + } + try { + waitSignal.wait(); + } catch (InterruptedException e) {} + } + } + if (fakeError != null) { + throw fakeError; + } + return result; + } + + Params awaitRequest() { + return awaitValue(receivedParams, 5, TimeUnit.SECONDS); + } + + void expectNoRequests(long timeoutMillis) { + assertNoMoreValues(receivedParams, timeoutMillis, TimeUnit.MILLISECONDS); + } + + List getEventsFromLastRequest() { + Params p = awaitRequest(); + LDValue a = LDValue.parse(p.data); + assertEquals(p.eventCount, a.size()); + List ret = new ArrayList<>(); + for (LDValue v: a.values()) { + ret.add(jsonFromValue(v)); + } + return ret; + } + } + + public static Matcher isIdentifyEvent(Event sourceEvent, LDValue context) { + return allOf( + jsonProperty("kind", "identify"), + jsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + jsonProperty("context", jsonFromValue(context)) + ); + } + + public static Matcher isIndexEvent() { + return jsonProperty("kind", "index"); + } + + public static Matcher isIndexEvent(Event sourceEvent, LDValue context) { + return allOf( + jsonProperty("kind", "index"), + jsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + jsonProperty("context", jsonFromValue(context)) + ); + } + + public static Matcher isFeatureEvent(Event.FeatureRequest sourceEvent) { + return isFeatureOrDebugEvent(sourceEvent, null, false); + } + + public static Matcher isDebugEvent(Event.FeatureRequest sourceEvent, LDValue inlineContext) { + return isFeatureOrDebugEvent(sourceEvent, inlineContext, true); + } + + @SuppressWarnings("unchecked") + private static Matcher isFeatureOrDebugEvent(Event.FeatureRequest sourceEvent, + LDValue inlineContext, boolean debug) { + return allOf( + jsonProperty("kind", debug ? "debug" : "feature"), + jsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + jsonProperty("key", sourceEvent.getKey()), + jsonProperty("version", sourceEvent.getVersion()), + jsonProperty("variation", sourceEvent.getVariation()), + jsonProperty("value", jsonFromValue(sourceEvent.getValue())), + inlineContext == null ? hasContextKeys(sourceEvent) : hasInlineContext(inlineContext), + jsonProperty("reason", sourceEvent.getReason() == null ? jsonUndefined() : jsonEqualsValue(sourceEvent.getReason())), + jsonProperty("prereqOf", sourceEvent.getPrereqOf() == null ? jsonUndefined() : jsonEqualsValue(sourceEvent.getPrereqOf())) + ); + } + + public static Matcher isCustomEvent(Event.Custom sourceEvent) { + boolean hasData = sourceEvent.getData() != null && !sourceEvent.getData().isNull(); + return allOf( + jsonProperty("kind", "custom"), + jsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + jsonProperty("key", sourceEvent.getKey()), + hasContextKeys(sourceEvent), + jsonProperty("data", hasData ? jsonEqualsValue(sourceEvent.getData()) : jsonUndefined()), + jsonProperty("metricValue", sourceEvent.getMetricValue() == null ? jsonUndefined() : jsonEqualsValue(sourceEvent.getMetricValue())) + ); + } + + public static Matcher hasContextKeys(Event sourceEvent) { + ObjectBuilder b = LDValue.buildObject(); + LDContext c = sourceEvent.getContext(); + for (int i = 0; i < c.getIndividualContextCount(); i++) { + LDContext c1 = c.getIndividualContext(i); + b.put(c1.getKind().toString(), c1.getKey()); + } + return jsonProperty("contextKeys", jsonEqualsValue(b.build())); + } + + public static Matcher hasInlineContext(LDValue inlineContext) { + return allOf( + jsonProperty("context", jsonEqualsValue(inlineContext)), + jsonProperty("contextKeys", jsonUndefined()) + ); + } + + public static Matcher isSummaryEvent() { + return jsonProperty("kind", "summary"); + } + + public static Matcher isSummaryEvent(long startDate, long endDate) { + return allOf( + jsonProperty("kind", "summary"), + jsonProperty("startDate", (double)startDate), + jsonProperty("endDate", (double)endDate) + ); + } + + public static Matcher hasSummaryFlag(String key, LDValue defaultVal, Matcher> counters) { + return jsonProperty("features", + jsonProperty(key, allOf( + jsonProperty("default", jsonFromValue(defaultVal)), + jsonProperty("counters", isJsonArray(counters)) + ))); + } + + public static Matcher isSummaryEventCounter(int flagVersion, Integer variation, LDValue value, int count) { + return allOf( + jsonProperty("variation", variation), + jsonProperty("version", (double)flagVersion), + jsonProperty("value", jsonFromValue(value)), + jsonProperty("count", (double)count) + ); + } + + public static FeatureRequestEventBuilder featureEvent(LDContext context, String flagKey) { + return new FeatureRequestEventBuilder(context, flagKey); + } + + public static CustomEventBuilder customEvent(LDContext context, String flagKey) { + return new CustomEventBuilder(context, flagKey); + } + + public static Event.Identify identifyEvent(LDContext context) { + return new Event.Identify(FAKE_TIME, context); + } + + /** + * This builder is similar to the public SDK configuration builder for events, except it is building + * the internal config object for the lower-level event processing code. This allows us to test that + * code independently of the rest of the SDK. Note that the default values here are deliberately not + * the same as the defaults in the SDK; they are chosen to make it unlikely for tests to be affected + * by any behavior we're not specifically trying to test-- for instance, a long flush interval means + * that flushes normally won't happen, and any test where we want flushes to happen will not rely on + * the defaults. + *

+ * This is defined only in test code, instead of as an inner class of EventsConfiguration, because + * in non-test code there's only one place where we ever construct EventsConfiguration. + */ + public static class EventsConfigurationBuilder { + private boolean allAttributesPrivate = false; + private int capacity = 1000; + private EventContextDeduplicator contextDeduplicator = null; + private long diagnosticRecordingIntervalMillis = 1000000; + private DiagnosticStore diagnosticStore = null; + private URI eventsUri = URI.create("not-valid"); + private long flushIntervalMillis = 1000000; + private Set privateAttributes = new HashSet<>(); + private EventSender eventSender = null; + + public EventsConfiguration build() { + return new EventsConfiguration( + allAttributesPrivate, + capacity, + contextDeduplicator, + diagnosticRecordingIntervalMillis, + diagnosticStore, + eventSender, + eventsUri, + flushIntervalMillis, + privateAttributes + ); + } + + public EventsConfigurationBuilder allAttributesPrivate(boolean allAttributesPrivate) { + this.allAttributesPrivate = allAttributesPrivate; + return this; + } + + public EventsConfigurationBuilder capacity(int capacity) { + this.capacity = capacity; + return this; + } + + public EventsConfigurationBuilder contextDeduplicator(EventContextDeduplicator contextDeduplicator) { + this.contextDeduplicator = contextDeduplicator; + return this; + } + + public EventsConfigurationBuilder diagnosticRecordingIntervalMillis(long diagnosticRecordingIntervalMillis) { + this.diagnosticRecordingIntervalMillis = diagnosticRecordingIntervalMillis; + return this; + } + + public EventsConfigurationBuilder diagnosticStore(DiagnosticStore diagnosticStore) { + this.diagnosticStore = diagnosticStore; + return this; + } + + public EventsConfigurationBuilder eventsUri(URI eventsUri) { + this.eventsUri = eventsUri; + return this; + } + + public EventsConfigurationBuilder flushIntervalMillis(long flushIntervalMillis) { + this.flushIntervalMillis = flushIntervalMillis; + return this; + } + + public EventsConfigurationBuilder privateAttributes(Set privateAttributes) { + this.privateAttributes = privateAttributes; + return this; + } + + public EventsConfigurationBuilder eventSender(EventSender eventSender) { + this.eventSender = eventSender; + return this; + } + } + + public static EventContextDeduplicator contextDeduplicatorThatAlwaysSaysKeysAreNew() { + return new EventContextDeduplicator() { + @Override + public Long getFlushInterval() { + return null; + } + + @Override + public boolean processContext(LDContext context) { + return true; + } + + @Override + public void flush() {} + }; + } + + public static EventContextDeduplicator contextDeduplicatorThatSaysKeyIsNewOnFirstCallOnly() { + return new EventContextDeduplicator() { + private int calls = 0; + + @Override + public Long getFlushInterval() { + return null; + } + + @Override + public boolean processContext(LDContext context) { + ++calls; + return calls == 1; + } + + @Override + public void flush() {} + }; + } + + public static final class FeatureRequestEventBuilder { + private long timestamp = FAKE_TIME; + private LDContext context; + private String flagKey; + private int flagVersion = 100; + private int variation = 1; + private LDValue value = LDValue.of("value"); + private EvaluationReason reason = null; + private LDValue defaultValue = LDValue.of("default"); + private String prereqOf = null; + private boolean trackEvents = false; + private Long debugEventsUntilDate = null; + + public FeatureRequestEventBuilder(LDContext context, String flagKey) { + this.context = context; + this.flagKey = flagKey; + } + + public Event.FeatureRequest build() { + return new Event.FeatureRequest(timestamp, flagKey, context, flagVersion, variation, value, + defaultValue, reason, prereqOf, trackEvents, debugEventsUntilDate, false); + } + + public FeatureRequestEventBuilder flagVersion(int flagVersion) { + this.flagVersion = flagVersion; + return this; + } + + public FeatureRequestEventBuilder variation(int variation) { + this.variation = variation; + return this; + } + + public FeatureRequestEventBuilder value(LDValue value) { + this.value = value; + return this; + } + + public FeatureRequestEventBuilder defaultValue(LDValue defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public FeatureRequestEventBuilder reason(EvaluationReason reason) { + this.reason = reason; + return this; + } + + public FeatureRequestEventBuilder prereqOf(String prereqOf) { + this.prereqOf = prereqOf; + return this; + } + + public FeatureRequestEventBuilder trackEvents(boolean trackEvents) { + this.trackEvents = trackEvents; + return this; + } + + public FeatureRequestEventBuilder debugEventsUntilDate(Long debugEventsUntilDate) { + this.debugEventsUntilDate = debugEventsUntilDate; + return this; + } + } + + public static final class CustomEventBuilder { + private long timestamp = FAKE_TIME; + private LDContext context; + private String eventKey; + private LDValue data = LDValue.ofNull(); + private Double metricValue = null; + + public CustomEventBuilder(LDContext context, String eventKey) { + this.context = context; + this.eventKey = eventKey; + } + + public Event.Custom build() { + return new Event.Custom(timestamp, eventKey, context, data, metricValue); + } + + public CustomEventBuilder data(LDValue data) { + this.data = data; + return this; + } + + public CustomEventBuilder metricValue(Double metricValue) { + this.metricValue = metricValue; + return this; + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java new file mode 100644 index 0000000..753d822 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java @@ -0,0 +1,180 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.LDValue; + +import org.junit.Test; + +import java.net.URI; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.samePropertyValuesAs; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * These DefaultEventProcessor tests cover diagnostic event behavior. + */ +@SuppressWarnings("javadoc") +public class DefaultEventProcessorDiagnosticsTest extends BaseEventTest { + private static LDValue fakePlatformData = LDValue.buildObject().put("cats", 2).build(); + + private DiagnosticId diagnosticId; + private DiagnosticStore diagnosticStore; + + public DefaultEventProcessorDiagnosticsTest() { + diagnosticId = new DiagnosticId(SDK_KEY); + diagnosticStore = new DiagnosticStore( + new DiagnosticStore.SdkDiagnosticParams( + SDK_KEY, + "fake-sdk", + "1.2.3", + "fake-platform", + fakePlatformData, + null, + null + )); + } + + @Test + public void diagnosticEventsSentToDiagnosticEndpoint() throws Exception { + MockEventSender es = new MockEventSender(); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).diagnosticStore(diagnosticStore))) { + MockEventSender.Params initReq = es.awaitRequest(); + ep.postDiagnostic(); + MockEventSender.Params periodicReq = es.awaitRequest(); + + assertThat(initReq.diagnostic, is(true)); + assertThat(periodicReq.diagnostic, is(true)); + } + } + + @Test + public void initialDiagnosticEventHasInitBody() throws Exception { + MockEventSender es = new MockEventSender(); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).diagnosticStore(diagnosticStore))) { + MockEventSender.Params req = es.awaitRequest(); + + DiagnosticEvent.Init initEvent = gson.fromJson(req.data, DiagnosticEvent.Init.class); + + assertNotNull(initEvent); + assertThat(initEvent.kind, equalTo("diagnostic-init")); + assertThat(initEvent.id, samePropertyValuesAs(diagnosticId)); + assertNotNull(initEvent.configuration); + assertNotNull(initEvent.sdk); + assertNotNull(initEvent.platform); + } + } + + @Test + public void periodicDiagnosticEventHasStatisticsBody() throws Exception { + MockEventSender es = new MockEventSender(); + long dataSinceDate = diagnosticStore.getDataSinceDate(); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).diagnosticStore(diagnosticStore))) { + // Ignore the initial diagnostic event + es.awaitRequest(); + ep.postDiagnostic(); + MockEventSender.Params periodicReq = es.awaitRequest(); + + assertNotNull(periodicReq); + DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); + + assertNotNull(statsEvent); + assertThat(statsEvent.kind, equalTo("diagnostic")); + assertThat(statsEvent.id, samePropertyValuesAs(diagnosticId)); + assertThat(statsEvent.dataSinceDate, equalTo(dataSinceDate)); + assertThat(statsEvent.creationDate, equalTo(diagnosticStore.getDataSinceDate())); + assertThat(statsEvent.deduplicatedUsers, equalTo(0L)); + assertThat(statsEvent.eventsInLastBatch, equalTo(0L)); + assertThat(statsEvent.droppedEvents, equalTo(0L)); + } + } + + @Test + public void periodicDiagnosticEventGetsEventsInLastBatchAndDeduplicatedUsers() throws Exception { + MockEventSender es = new MockEventSender(); + Event.FeatureRequest fe1 = featureEvent(user, "flagkey1").build(); + Event.FeatureRequest fe2 = featureEvent(user, "flagkey2").build(); + + // Create a fake deduplicator that just says "not seen" for the first call and "seen" thereafter + EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatSaysKeyIsNewOnFirstCallOnly(); + + try (DefaultEventProcessor ep = makeEventProcessor( + baseConfig(es).contextDeduplicator(contextDeduplicator).diagnosticStore(diagnosticStore))) { + // Ignore the initial diagnostic event + es.awaitRequest(); + + ep.sendEvent(fe1); + ep.sendEvent(fe2); + ep.flush(); + // Ignore normal events + es.awaitRequest(); + + ep.postDiagnostic(); + MockEventSender.Params periodicReq = es.awaitRequest(); + + assertNotNull(periodicReq); + DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); + + assertNotNull(statsEvent); + assertThat(statsEvent.deduplicatedUsers, equalTo(1L)); + assertThat(statsEvent.eventsInLastBatch, equalTo(2L)); // 1 index event + 1 summary event + assertThat(statsEvent.droppedEvents, equalTo(0L)); + } + } + + @Test + public void periodicDiagnosticEventsAreSentAutomatically() throws Exception { + MockEventSender es = new MockEventSender(); + + EventsConfigurationBuilder eventsConfig = makeEventsConfigurationWithBriefDiagnosticInterval(es); + + try (DefaultEventProcessor ep = makeEventProcessor(eventsConfig.diagnosticStore(diagnosticStore))) { + // Ignore the initial diagnostic event + es.awaitRequest(); + + MockEventSender.Params periodicReq = es.awaitRequest(); + + assertNotNull(periodicReq); + DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); + assertEquals("diagnostic", statsEvent.kind); + } + } + + private EventsConfigurationBuilder makeEventsConfigurationWithBriefDiagnosticInterval(EventSender es) { + return baseConfig(es).diagnosticRecordingIntervalMillis(50); + } + + @Test + public void diagnosticEventsStopAfter401Error() throws Exception { + // This is easier to test with a mock component than it would be in LDClientEndToEndTest, because + // we don't have to worry about the latency of a real HTTP request which could allow the periodic + // task to fire again before we received a response. In real life, that wouldn't matter because + // the minimum diagnostic interval is so long, but in a test we need to be able to use a short + // interval. + MockEventSender es = new MockEventSender(); + es.result = new EventSender.Result(false, true, null); // mustShutdown=true; this is what would be returned for a 401 error + + EventsConfigurationBuilder eventsConfig = makeEventsConfigurationWithBriefDiagnosticInterval(es); + + try (DefaultEventProcessor ep = makeEventProcessor(eventsConfig.diagnosticStore(diagnosticStore))) { + // Ignore the initial diagnostic event + es.awaitRequest(); + + es.expectNoRequests(100); + } + } + + @Test + public void customBaseUriIsPassedToEventSenderForDiagnosticEvents() throws Exception { + MockEventSender es = new MockEventSender(); + URI uri = URI.create("fake-uri"); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).eventsUri(uri).diagnosticStore(diagnosticStore))) { + } + + MockEventSender.Params p = es.awaitRequest(); + assertThat(p.eventsBaseUri, equalTo(uri)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java new file mode 100644 index 0000000..f0fe1dd --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java @@ -0,0 +1,423 @@ +package com.launchdarkly.sdk.internal.events; + +//import com.launchdarkly.sdk.EvaluationDetail; +//import com.launchdarkly.sdk.EvaluationReason; +//import com.launchdarkly.sdk.LDContext; +//import com.launchdarkly.sdk.LDValue; +//import com.launchdarkly.sdk.internal.events.DefaultEventProcessor; +//import com.launchdarkly.sdk.internal.events.Event; +//import com.launchdarkly.sdk.internal.events.EventContextDeduplicator; +//import com.launchdarkly.sdk.internal.events.EventSender; +//import com.launchdarkly.sdk.server.DataModel; +// +//import org.hamcrest.Matchers; +//import org.junit.Test; +// +//import java.util.Date; +// +//import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +//import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; +//import static org.hamcrest.MatcherAssert.assertThat; +//import static org.hamcrest.Matchers.allOf; +//import static org.hamcrest.Matchers.contains; +// +///** +// * These DefaultEventProcessor tests cover the specific content that should appear in event payloads. +// */ +//@SuppressWarnings("javadoc") +//public class DefaultEventProcessorOutputTest extends EventTestUtil { +// private static final LDContext invalidContext = LDContext.create(null); +// private static final String FLAG_KEY = "flagkey"; +// private static final int FLAG_VERSION = 11; +// private static final EvaluationDetail SOME_RESULT = EvaluationDetail.fromValue( +// LDValue.of("value"), 1, EvaluationReason.off()); +// +// // Note: context deduplication behavior has been abstracted out of DefaultEventProcessor, so that +// // by default it does not generate any index events. Test cases in this file that are not +// // specifically related to index events use this default behavior, and do not expect to see any. +// // When we are specifically testing this behavior, we substitute a mock EventContextDeduplicator +// // so we can verify how its outputs affect DefaultEventProcessor. +// +// @Test +// public void identifyEventIsQueued() throws Exception { +// MockEventSender es = new MockEventSender(); +// Event e = makeIdentifyEvent(user); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { +// ep.sendEvent(e); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// isIdentifyEvent(e, userJson) +// )); +// } +// +// @Test +// public void userIsFilteredInIdentifyEvent() throws Exception { +// MockEventSender es = new MockEventSender(); +// Event e = makeIdentifyEvent(user); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).allAttributesPrivate(true))) { +// ep.sendEvent(e); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// isIdentifyEvent(e, filteredUserJson) +// )); +// } +// +// @Test +// public void identifyEventWithNullContextOrInvalidContextDoesNotCauseError() throws Exception { +// // This should never happen because LDClient.identify() rejects such a user, but just in case, +// // we want to make sure it doesn't blow up the event processor. +// MockEventSender es = new MockEventSender(); +// Event event1 = makeIdentifyEvent(invalidContext); +// Event event2 = makeIdentifyEvent(null); +// Event event3 = makeIdentifyEvent(user); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { +// ep.sendEvent(event1); +// ep.sendEvent(event2); +// ep.sendEvent(event3); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// isIdentifyEvent(event3, userJson) +// )); +// } +// +// @SuppressWarnings("unchecked") +// @Test +// public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { +// MockEventSender es = new MockEventSender(); +// Event.FeatureRequest fe = makeFeatureRequestEvent(FLAG_KEY, user, FLAG_VERSION, +// SOME_RESULT, LDValue.ofNull()); +// +// EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatAlwaysSaysKeysAreNew(); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).contextDeduplicator(contextDeduplicator))) { +// ep.sendEvent(fe); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// isIndexEvent(fe, userJson), +// isFeatureEvent(fe, FLAG_KEY, FLAG_VERSION, false, null), +// isSummaryEvent() +// )); +// } +// +// @SuppressWarnings("unchecked") +// @Test +// public void userIsFilteredInIndexEvent() throws Exception { +// MockEventSender es = new MockEventSender(); +// Event.FeatureRequest fe = makeFeatureRequestEvent(FLAG_KEY, user, FLAG_VERSION, +// SOME_RESULT, LDValue.ofNull()); +// +// EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatAlwaysSaysKeysAreNew(); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).allAttributesPrivate(true).contextDeduplicator(contextDeduplicator))) { +// ep.sendEvent(fe); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// isIndexEvent(fe, filteredUserJson), +// isFeatureEvent(fe, flag, false, null), +// isSummaryEvent() +// )); +// } +// +// @SuppressWarnings("unchecked") +// @Test +// public void featureEventCanBeForPrerequisite() throws Exception { +// MockEventSender es = new MockEventSender(); +// DataModel.FeatureFlag mainFlag = flagBuilder("flagkey").version(11).build(); +// DataModel.FeatureFlag prereqFlag = flagBuilder("prereqkey").version(12).trackEvents(true).build(); +// Event.FeatureRequest fe = makePrerequisiteEvent(prereqFlag, user, +// simpleEvaluation(1, LDValue.of("value")), +// mainFlag); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { +// ep.sendEvent(fe); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// allOf(isFeatureEvent(fe, prereqFlag, false, null), isPrerequisiteOf(mainFlag.getKey())), +// isSummaryEvent() +// )); +// } +// +// @Test +// public void featureEventWithNullContextOrInvalidContextIsIgnored() throws Exception { +// // This should never happen because LDClient rejects such a user, but just in case, +// // we want to make sure it doesn't blow up the event processor. +// MockEventSender es = new MockEventSender(); +// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).build(); +// Event.FeatureRequest event1 = makeFeatureRequestEvent(flag, invalidContext, +// simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); +// Event.FeatureRequest event2 = makeFeatureRequestEvent(flag, null, +// simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es) +// .allAttributesPrivate(true))) { +// ep.sendEvent(event1); +// ep.sendEvent(event2); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// isSummaryEvent() +// )); +// } +// +// @SuppressWarnings("unchecked") +// @Test +// public void featureEventCanContainReason() throws Exception { +// MockEventSender es = new MockEventSender(); +// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); +// EvaluationReason reason = EvaluationReason.ruleMatch(1, null); +// Event.FeatureRequest fe = makeFeatureRequestEvent(flag, user, +// EvalResult.of(LDValue.of("value"), 1, reason), LDValue.ofNull(), true); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { +// ep.sendEvent(fe); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// isFeatureEvent(fe, flag, false, null, reason), +// isSummaryEvent() +// )); +// } +// +// @SuppressWarnings("unchecked") +// @Test +// public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { +// MockEventSender es = new MockEventSender(); +// long futureTime = System.currentTimeMillis() + 1000000; +// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); +// Event.FeatureRequest fe = makeFeatureRequestEvent(flag, user, +// simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { +// ep.sendEvent(fe); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// isFeatureEvent(fe, flag, true, userJson), +// isSummaryEvent() +// )); +// } +// +// @SuppressWarnings("unchecked") +// @Test +// public void eventCanBeBothTrackedAndDebugged() throws Exception { +// MockEventSender es = new MockEventSender(); +// long futureTime = System.currentTimeMillis() + 1000000; +// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true) +// .debugEventsUntilDate(futureTime).build(); +// Event.FeatureRequest fe = makeFeatureRequestEvent(flag, user, +// simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { +// ep.sendEvent(fe); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// isFeatureEvent(fe, flag, false, null), +// isFeatureEvent(fe, flag, true, userJson), +// isSummaryEvent() +// )); +// } +// +// @Test +// public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() throws Exception { +// MockEventSender es = new MockEventSender(); +// +// // Pick a server time that is somewhat behind the client time +// long serverTime = System.currentTimeMillis() - 20000; +// es.result = new EventSender.Result(true, false, new Date(serverTime)); +// +// long debugUntil = serverTime + 1000; +// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); +// Event.FeatureRequest fe = makeFeatureRequestEvent(flag, user, +// simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { +// // Send and flush an event we don't care about, just so we'll receive "resp1" which sets the last server time +// ep.sendEvent(new Event.Identify(FAKE_TIME, LDContext.create("otherUser"))); +// ep.flush(); +// ep.waitUntilInactive(); // this ensures that it has received the first response, with the date +// +// es.receivedParams.clear(); +// es.result = new EventSender.Result(true, false, null); +// +// // Now send an event with debug mode on, with a "debug until" time that is further in +// // the future than the server time, but in the past compared to the client. +// ep.sendEvent(fe); +// } +// +// // Should get a summary event only, not a full feature event +// assertThat(es.getEventsFromLastRequest(), contains( +// isSummaryEvent(fe.getCreationDate(), fe.getCreationDate()) +// )); +// } +// +// @Test +// public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() throws Exception { +// MockEventSender es = new MockEventSender(); +// +// // Pick a server time that is somewhat ahead of the client time +// long serverTime = System.currentTimeMillis() + 20000; +// es.result = new EventSender.Result(true, false, new Date(serverTime)); +// +// long debugUntil = serverTime - 1000; +// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); +// Event.FeatureRequest fe = makeFeatureRequestEvent(flag, user, +// simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { +// // Send and flush an event we don't care about, just to set the last server time +// ep.sendEvent(makeIdentifyEvent(LDContext.create("otherUser"))); +// ep.flush(); +// ep.waitUntilInactive(); // this ensures that it has received the first response, with the date +// +// es.receivedParams.clear(); +// es.result = new EventSender.Result(true, false, null); +// +// // Now send an event with debug mode on, with a "debug until" time that is further in +// // the future than the client time, but in the past compared to the server. +// ep.sendEvent(fe); +// } +// +// // Should get a summary event only, not a full feature event +// assertThat(es.getEventsFromLastRequest(), contains( +// isSummaryEvent(fe.getCreationDate(), fe.getCreationDate()) +// )); +// } +// +// @SuppressWarnings("unchecked") +// @Test +// public void twoFeatureEventsForSameContextGenerateOnlyOneIndexEvent() throws Exception { +// // More accurately, this is testing that DefaultEventProcessor respects whatever the +// // EventContextDeduplicator says about whether a context key is new or not. We will set up +// // an EventContextDeduplicator that reports "new" on the first call and "not new" on the 2nd. +// EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatSaysKeyIsNewOnFirstCallOnly(); +// +// MockEventSender es = new MockEventSender(); +// DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); +// DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).trackEvents(true).build(); +// LDValue value = LDValue.of("value"); +// Event.FeatureRequest fe1 = makeFeatureRequestEvent(flag1, user, +// simpleEvaluation(1, value), LDValue.ofNull()); +// Event.FeatureRequest fe2 = makeFeatureRequestEvent(flag2, user, +// simpleEvaluation(1, value), LDValue.ofNull()); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).contextDeduplicator(contextDeduplicator))) { +// ep.sendEvent(fe1); +// ep.sendEvent(fe2); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// isIndexEvent(fe1, userJson), +// isFeatureEvent(fe1, flag1, false, null), +// isFeatureEvent(fe2, flag2, false, null), +// isSummaryEvent(fe1.getCreationDate(), fe2.getCreationDate()) +// )); +// } +// +// @SuppressWarnings("unchecked") +// @Test +// public void identifyEventMakesIndexEventUnnecessary() throws Exception { +// MockEventSender es = new MockEventSender(); +// Event ie = new Event.Identify(FAKE_TIME, user); +// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); +// Event.FeatureRequest fe = makeFeatureRequestEvent(flag, user, +// simpleEvaluation(1, LDValue.of("value")), null); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { +// ep.sendEvent(ie); +// ep.sendEvent(fe); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// isIdentifyEvent(ie, userJson), +// isFeatureEvent(fe, flag, false, null), +// isSummaryEvent() +// )); +// } +// +// +// @SuppressWarnings("unchecked") +// @Test +// public void nonTrackedEventsAreSummarized() throws Exception { +// MockEventSender es = new MockEventSender(); +// DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).build(); +// DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).build(); +// LDValue value1 = LDValue.of("value1"); +// LDValue value2 = LDValue.of("value2"); +// LDValue default1 = LDValue.of("default1"); +// LDValue default2 = LDValue.of("default2"); +// Event fe1a = makeFeatureRequestEvent(flag1, user, +// simpleEvaluation(1, value1), default1); +// Event fe1b = makeFeatureRequestEvent(flag1, user, +// simpleEvaluation(1, value1), default1); +// Event fe1c = makeFeatureRequestEvent(flag1, user, +// simpleEvaluation(2, value2), default1); +// Event fe2 = makeFeatureRequestEvent(flag2, user, +// simpleEvaluation(2, value2), default2); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { +// ep.sendEvent(fe1a); +// ep.sendEvent(fe1b); +// ep.sendEvent(fe1c); +// ep.sendEvent(fe2); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// allOf( +// isSummaryEvent(fe1a.getCreationDate(), fe2.getCreationDate()), +// hasSummaryFlag(flag1.getKey(), default1, +// Matchers.containsInAnyOrder( +// isSummaryEventCounter(flag1, 1, value1, 2), +// isSummaryEventCounter(flag1, 2, value2, 1) +// )), +// hasSummaryFlag(flag2.getKey(), default2, +// contains(isSummaryEventCounter(flag2, 2, value2, 1))) +// ) +// )); +// } +// +// @Test +// public void customEventIsQueuedWithUser() throws Exception { +// MockEventSender es = new MockEventSender(); +// LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); +// double metric = 1.5; +// Event.Custom ce = new Event.Custom(FAKE_TIME, "eventkey", user, data, metric); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { +// ep.sendEvent(ce); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// isCustomEvent(ce) +// )); +// } +// +// @Test +// public void customEventWithNullContextOrInvalidContextDoesNotCauseError() throws Exception { +// // This should never happen because LDClient rejects such a user, but just in case, +// // we want to make sure it doesn't blow up the event processor. +// MockEventSender es = new MockEventSender(); +// Event.Custom event1 = new Event.Custom(FAKE_TIME, "eventkey", invalidContext, null, null); +// Event.Custom event2 = new Event.Custom(FAKE_TIME, "eventkey", null, null, null); +// Event.Custom event3 = new Event.Custom(FAKE_TIME, "eventkey", user, null, null); +// +// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { +// ep.sendEvent(event1); +// ep.sendEvent(event2); +// ep.sendEvent(event3); +// } +// +// assertThat(es.getEventsFromLastRequest(), contains( +// isCustomEvent(event3) +// )); +// } +//} diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java new file mode 100644 index 0000000..2a9ee6e --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java @@ -0,0 +1,329 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.testhelpers.JsonTestValue; + +import org.hamcrest.Matchers; +import org.junit.Test; + +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * These tests cover all of the basic DefaultEventProcessor behavior that is not covered by + * DefaultEventProcessorOutputTest or DefaultEventProcessorDiagnosticTest. + */ +@SuppressWarnings("javadoc") +public class DefaultEventProcessorTest extends BaseEventTest { + @SuppressWarnings("unchecked") + @Test + public void eventsAreFlushedAutomatically() throws Exception { + MockEventSender es = new MockEventSender(); + long briefFlushInterval = 50; + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).flushIntervalMillis(briefFlushInterval))) { + Event.Custom event1 = customEvent(user, "event1").build(); + Event.Custom event2 = customEvent(user, "event2").build(); + ep.sendEvent(event1); + ep.sendEvent(event2); + + // getEventsFromLastRequest will block until the MockEventSender receives a payload - we expect + // both events to be in one payload, but if some unusual delay happened in between the two + // sendEvent calls, they might be in two + List payload1 = es.getEventsFromLastRequest(); + if (payload1.size() == 1) { + assertThat(payload1, contains(isCustomEvent(event1))); + assertThat(es.getEventsFromLastRequest(), contains(isCustomEvent(event2))); + } else { + assertThat(payload1, contains(isCustomEvent(event1), isCustomEvent(event2))); + } + + Event.Custom event3 = customEvent(user, "event3").build(); + ep.sendEvent(event3); + assertThat(es.getEventsFromLastRequest(), contains(isCustomEvent(event3))); + } + + Event.Custom ce = customEvent(user, "eventkey").build(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(ce); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isCustomEvent(ce) + )); + } + + @Test + public void closingEventProcessorForcesSynchronousFlush() throws Exception { + MockEventSender es = new MockEventSender(); + Event e = identifyEvent(user); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(e); + } + + assertThat(es.getEventsFromLastRequest(), contains(isIdentifyEvent(e, userJson))); + } + + @Test + public void nothingIsSentIfThereAreNoEvents() throws Exception { + MockEventSender es = new MockEventSender(); + DefaultEventProcessor ep = makeEventProcessor(baseConfig(es)); + ep.close(); + + assertEquals(0, es.receivedParams.size()); + } + + @Test + public void contextKeysAreFlushedAutomatically() throws Exception { + // This test sets the context key flush interval to a small value and verifies that the + // context deduplicator receives a flush call. + MockEventSender es = new MockEventSender(); + long briefContextFlushIntervalMillis = 60; + Semaphore flushCalled = new Semaphore(0); + EventContextDeduplicator contextDeduplicator = new EventContextDeduplicator() { + @Override + public Long getFlushInterval() { + return briefContextFlushIntervalMillis; + } + + @Override + public boolean processContext(LDContext context) { + return false; + } + + @Override + public void flush() { + flushCalled.release(); + } + }; + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).contextDeduplicator(contextDeduplicator))) { + boolean called = flushCalled.tryAcquire(briefContextFlushIntervalMillis * 2, TimeUnit.MILLISECONDS); + assertTrue("expected context deduplicator flush method to be called, but it was not", called); + } + } + + @Test + public void eventSenderIsClosedWithEventProcessor() throws Exception { + MockEventSender es = new MockEventSender(); + assertThat(es.closed, is(false)); + DefaultEventProcessor ep = makeEventProcessor(baseConfig(es)); + ep.close(); + assertThat(es.closed, is(true)); + } + + @Test + public void eventProcessorCatchesExceptionWhenClosingEventSender() throws Exception { + MockEventSender es = new MockEventSender(); + es.fakeErrorOnClose = new IOException("sorry"); + assertThat(es.closed, is(false)); + DefaultEventProcessor ep = makeEventProcessor(baseConfig(es)); + ep.close(); + assertThat(es.closed, is(true)); + } + + @Test + public void customBaseUriIsPassedToEventSenderForAnalyticsEvents() throws Exception { + MockEventSender es = new MockEventSender(); + Event e = identifyEvent(user); + URI uri = URI.create("fake-uri"); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).eventsUri(uri))) { + ep.sendEvent(e); + } + + MockEventSender.Params p = es.awaitRequest(); + assertThat(p.eventsBaseUri, equalTo(uri)); + } + + @Test + public void eventCapacityIsEnforced() throws Exception { + int capacity = 10; + MockEventSender es = new MockEventSender(); + EventsConfigurationBuilder config = baseConfig(es).capacity(capacity) + .flushIntervalMillis(1000); + // The flush interval setting is a failsafe in case we do get a queue overflow due to the tiny buffer size - + // that might cause the special message that's generated by ep.flush() to be missed, so we just want to make + // sure a flush will happen within a few seconds so getEventsFromLastRequest() won't time out. + + try (DefaultEventProcessor ep = makeEventProcessor(config)) { + for (int i = 0; i < capacity + 2; i++) { + ep.sendEvent(identifyEvent(user)); + + // Using such a tiny buffer means there's also a tiny inbox queue, so we'll add a slight + // delay to keep EventDispatcher from being overwhelmed + Thread.sleep(10); + } + ep.flush(); + assertThat(es.getEventsFromLastRequest(), Matchers.iterableWithSize(capacity)); + } + } + + @Test + public void eventCapacityDoesNotPreventSummaryEventFromBeingSent() throws Exception { + int capacity = 10; + MockEventSender es = new MockEventSender(); + EventsConfigurationBuilder config = baseConfig(es).capacity(capacity) + .flushIntervalMillis(1000); + // The flush interval setting is a failsafe in case we do get a queue overflow due to the tiny buffer size - + // that might cause the special message that's generated by ep.flush() to be missed, so we just want to make + // sure a flush will happen within a few seconds so getEventsFromLastRequest() won't time out. + + try (DefaultEventProcessor ep = makeEventProcessor(config)) { + Event.FeatureRequest fe = featureEvent(user, "flagkey").build(); + ep.sendEvent(fe); + + for (int i = 0; i < capacity; i++) { + Event.Custom ce = customEvent(user, "event-key").build(); + ep.sendEvent(ce); + + // Using such a tiny buffer means there's also a tiny inbox queue, so we'll add a slight + // delay to keep EventDispatcher from being overwhelmed + Thread.sleep(10); + } + + ep.flush(); + List eventsReceived = es.getEventsFromLastRequest(); + + assertThat(eventsReceived, Matchers.iterableWithSize(capacity + 1)); + assertThat(eventsReceived.get(capacity), isSummaryEvent()); + } + } + + @Test + public void noMoreEventsAreProcessedAfterUnrecoverableError() throws Exception { + MockEventSender es = new MockEventSender(); + es.result = new EventSender.Result(false, true, null); // mustShutdown == true + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(identifyEvent(user)); + ep.flush(); + es.awaitRequest(); + + // allow a little time for the event processor to pass the "must shut down" signal back from the sender + Thread.sleep(50); + + ep.sendEvent(identifyEvent(user)); + ep.flush(); + es.expectNoRequests(100); + } + } + + @Test + public void noMoreEventsAreProcessedAfterClosingEventProcessor() throws Exception { + MockEventSender es = new MockEventSender(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.close(); + + ep.sendEvent(identifyEvent(user)); + ep.flush(); + + es.expectNoRequests(100); + } + } + + @Test + public void uncheckedExceptionFromEventSenderDoesNotStopWorkerThread() throws Exception { + MockEventSender es = new MockEventSender(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + es.fakeError = new RuntimeException("sorry"); + + ep.sendEvent(identifyEvent(user)); + ep.flush(); + es.awaitRequest(); + // MockEventSender now throws an unchecked exception up to EventProcessor's flush worker - + // verify that a subsequent flush still works + + es.fakeError = null; + ep.sendEvent(identifyEvent(user)); + ep.flush(); + es.awaitRequest(); + } + } + + @SuppressWarnings("unchecked") + @Test + public void eventsAreKeptInBufferIfAllFlushWorkersAreBusy() throws Exception { + // Note that in the current implementation, although the intention was that we would cancel a flush + // if there's not an available flush worker, instead what happens is that we will queue *one* flush + // in that case, and then cancel the *next* flush if the workers are still busy. This is because we + // used a BlockingQueue with a size of 1, rather than a SynchronousQueue. The test below verifies + // the current behavior. + + int numWorkers = 5; // must equal EventDispatcher.MAX_FLUSH_THREADS + LDContext testUser1 = LDContext.create("me"); + LDValue testUserJson1 = LDValue.buildObject().put("kind", "user").put("key", "me").build(); + LDContext testUser2 = LDContext.create("you"); + LDValue testUserJson2 = LDValue.buildObject().put("kind", "user").put("key", "you").build(); + LDContext testUser3 = LDContext.create("everyone we know"); + LDValue testUserJson3 = LDValue.buildObject().put("kind", "user").put("key", "everyone we know").build(); + + Object sendersWaitOnThis = new Object(); + CountDownLatch sendersSignalThisWhenWaiting = new CountDownLatch(numWorkers); + MockEventSender es = new MockEventSender(); + es.waitSignal = sendersWaitOnThis; + es.receivedCounter = sendersSignalThisWhenWaiting; + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + for (int i = 0; i < 5; i++) { + ep.sendEvent(identifyEvent(user)); + ep.flush(); + es.awaitRequest(); // we don't need to see this payload, just throw it away + } + + // When our CountDownLatch reaches zero, it means all of the worker threads are blocked in MockEventSender + sendersSignalThisWhenWaiting.await(); + es.waitSignal = null; + es.receivedCounter = null; + + // Now, put an event in the buffer and try to flush again. In the current implementation (see + // above) this payload gets queued in a holding area, and will be flushed after a worker + // becomes free. + Event.Identify event1 = identifyEvent(testUser1); + ep.sendEvent(event1); + ep.flush(); + + // Do an additional flush with another event. This time, the event processor should see that there's + // no space available and simply ignore the flush request. There's no way to verify programmatically + // that this has happened, so just give it a short delay. + Event.Identify event2 = identifyEvent(testUser2); + ep.sendEvent(event2); + ep.flush(); + Thread.sleep(100); + + // Enqueue a third event. The current payload should now be event2 + event3. + Event.Identify event3 = identifyEvent(testUser3); + ep.sendEvent(event3); + + // Now allow the workers to unblock + synchronized (sendersWaitOnThis) { + sendersWaitOnThis.notifyAll(); + } + + // The first unblocked worker should pick up the queued payload with event1. + assertThat(es.getEventsFromLastRequest(), contains(isIdentifyEvent(event1, testUserJson1))); + + // Now a flush should succeed and send the current payload. + ep.flush(); + assertThat(es.getEventsFromLastRequest(), contains( + isIdentifyEvent(event2, testUserJson2), + isIdentifyEvent(event3, testUserJson3))); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java new file mode 100644 index 0000000..42cbc97 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java @@ -0,0 +1,381 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.internal.http.HttpProperties; +import com.launchdarkly.testhelpers.httptest.Handler; +import com.launchdarkly.testhelpers.httptest.Handlers; +import com.launchdarkly.testhelpers.httptest.HttpServer; +import com.launchdarkly.testhelpers.httptest.RequestInfo; + +import org.junit.Test; + +import java.net.URI; +import java.nio.charset.Charset; +import java.text.SimpleDateFormat; +import java.time.Duration; +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.equalToIgnoringCase; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class DefaultEventSenderTest extends BaseEventTest { + private static final String FAKE_DATA = "some data"; + private static final byte[] FAKE_DATA_BYTES = FAKE_DATA.getBytes(Charset.forName("UTF-8")); + private static final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", + Locale.US); + private static final long BRIEF_RETRY_DELAY_MILLIS = 50; + + private EventSender makeEventSender() { + return makeEventSender(defaultHttpProperties()); + } + + private EventSender makeEventSender(HttpProperties httpProperties) { + return new DefaultEventSender(httpProperties, BRIEF_RETRY_DELAY_MILLIS, testLogger); + } + + @Test + public void analyticsDataIsDelivered() throws Exception { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + EventSender.Result result = es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, server.getUri()); + + assertTrue(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals("/bulk", req.getPath()); + assertThat(req.getHeader("content-type"), equalToIgnoringCase("application/json; charset=utf-8")); + assertEquals(FAKE_DATA, req.getBody()); + } + } + + @Test + public void diagnosticDataIsDelivered() throws Exception { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + EventSender.Result result = es.sendDiagnosticEvent(FAKE_DATA_BYTES, server.getUri()); + + assertTrue(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals("/diagnostic", req.getPath()); + assertThat(req.getHeader("content-type"), equalToIgnoringCase("application/json; charset=utf-8")); + assertEquals(FAKE_DATA, req.getBody()); + } + } + + @Test + public void headersAreSentForAnalytics() throws Exception { + Map headers = new HashMap<>(); + headers.put("name1", "value1"); + headers.put("name2", "value2"); + HttpProperties httpProperties = new HttpProperties(0, headers, null, null, null, 0, null, null); + + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { + try (EventSender es = makeEventSender(httpProperties)) { + es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, server.getUri()); + } + + RequestInfo req = server.getRecorder().requireRequest(); + for (Map.Entry kv: headers.entrySet()) { + assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue())); + } + } + } + + @Test + public void headersAreSentForDiagnostics() throws Exception { + Map headers = new HashMap<>(); + headers.put("name1", "value1"); + headers.put("name2", "value2"); + HttpProperties httpProperties = new HttpProperties(0, headers, null, null, null, 0, null, null); + + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { + try (EventSender es = makeEventSender(httpProperties)) { + es.sendDiagnosticEvent(FAKE_DATA_BYTES, server.getUri()); + } + + RequestInfo req = server.getRecorder().requireRequest(); + for (Map.Entry kv: headers.entrySet()) { + assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue())); + } + } + } + + @Test + public void eventSchemaIsSentForAnalytics() throws Exception { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, server.getUri()); + } + + RequestInfo req = server.getRecorder().requireRequest(); + assertThat(req.getHeader("X-LaunchDarkly-Event-Schema"), equalTo("4")); + } + } + + @Test + public void eventPayloadIdIsSentForAnalytics() throws Exception { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, server.getUri()); + } + + RequestInfo req = server.getRecorder().requireRequest(); + String payloadHeaderValue = req.getHeader("X-LaunchDarkly-Payload-ID"); + assertThat(payloadHeaderValue, notNullValue(String.class)); + assertThat(UUID.fromString(payloadHeaderValue), notNullValue(UUID.class)); + } + } + + @Test + public void eventPayloadIdReusedOnRetry() throws Exception { + Handler errorResponse = Handlers.status(429); + Handler errorThenSuccess = Handlers.sequential(errorResponse, eventsSuccessResponse(), eventsSuccessResponse()); + + try (HttpServer server = HttpServer.start(errorThenSuccess)) { + try (EventSender es = makeEventSender()) { + es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, server.getUri()); + es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, server.getUri()); + } + + // Failed response request + RequestInfo req = server.getRecorder().requireRequest(); + String payloadId = req.getHeader("X-LaunchDarkly-Payload-ID"); + // Retry request has same payload ID as failed request + req = server.getRecorder().requireRequest(); + String retryId = req.getHeader("X-LaunchDarkly-Payload-ID"); + assertThat(retryId, equalTo(payloadId)); + // Second request has different payload ID from first request + req = server.getRecorder().requireRequest(); + payloadId = req.getHeader("X-LaunchDarkly-Payload-ID"); + assertThat(retryId, not(equalTo(payloadId))); + } + } + + @Test + public void eventSchemaNotSetOnDiagnosticEvents() throws Exception { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + es.sendDiagnosticEvent(FAKE_DATA_BYTES, server.getUri()); + } + + RequestInfo req = server.getRecorder().requireRequest(); + assertNull(req.getHeader("X-LaunchDarkly-Event-Schema")); + } + } + + @Test + public void http400ErrorIsRecoverable() throws Exception { + testRecoverableHttpError(400); + } + + @Test + public void http401ErrorIsUnrecoverable() throws Exception { + testUnrecoverableHttpError(401); + } + + @Test + public void http403ErrorIsUnrecoverable() throws Exception { + testUnrecoverableHttpError(403); + } + + // Cannot test our retry logic for 408, because OkHttp insists on doing its own retry on 408 so that + // we never actually see that response status. +// @Test +// public void http408ErrorIsRecoverable() throws Exception { +// testRecoverableHttpError(408); +// } + + @Test + public void http429ErrorIsRecoverable() throws Exception { + testRecoverableHttpError(429); + } + + @Test + public void http500ErrorIsRecoverable() throws Exception { + testRecoverableHttpError(500); + } + + @Test + public void serverDateIsParsed() throws Exception { + long fakeTime = ((new Date().getTime() - 100000) / 1000) * 1000; // don't expect millisecond precision + Handler resp = Handlers.all(eventsSuccessResponse(), addDateHeader(new Date(fakeTime))); + + try (HttpServer server = HttpServer.start(resp)) { + try (EventSender es = makeEventSender()) { + EventSender.Result result = es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, server.getUri()); + + assertNotNull(result.getTimeFromServer()); + assertEquals(fakeTime, result.getTimeFromServer().getTime()); + } + } + } + + @Test + public void invalidServerDateIsIgnored() throws Exception { + Handler resp = Handlers.all(eventsSuccessResponse(), Handlers.header("Date", "not a date")); + + try (HttpServer server = HttpServer.start(resp)) { + try (EventSender es = makeEventSender()) { + EventSender.Result result = es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, server.getUri()); + + assertTrue(result.isSuccess()); + assertNull(result.getTimeFromServer()); + } + } + } + +// @Test +// public void testSpecialHttpConfigurations() throws Exception { +// Handler handler = eventsSuccessResponse(); +// +// TestHttpUtil.testWithSpecialHttpConfigurations(handler, +// (targetUri, goodHttpConfig) -> { +// HttpConfiguration config = goodHttpConfig.createHttpConfiguration(clientContext("", LDConfig.DEFAULT)); +// +// try (EventSender es = makeEventSender(ComponentsImpl.toHttpProperties(config))) { +// EventSender.Result result = es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, targetUri); +// +// assertTrue(result.isSuccess()); +// assertFalse(result.isMustShutDown()); +// } +// }, +// +// (targetUri, badHttpConfig) -> { +// HttpConfiguration config = badHttpConfig.createHttpConfiguration(clientContext("", LDConfig.DEFAULT)); +// +// try (EventSender es = makeEventSender(ComponentsImpl.toHttpProperties(config))) { +// EventSender.Result result = es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, targetUri); +// +// assertFalse(result.isSuccess()); +// assertFalse(result.isMustShutDown()); +// } +// } +// ); +// } + + @Test + public void baseUriDoesNotNeedTrailingSlash() throws Exception { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + URI uriWithoutSlash = URI.create(server.getUri().toString().replaceAll("/$", "")); + EventSender.Result result = es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, uriWithoutSlash); + + assertTrue(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals("/bulk", req.getPath()); + assertThat(req.getHeader("content-type"), equalToIgnoringCase("application/json; charset=utf-8")); + assertEquals(FAKE_DATA, req.getBody()); + } + } + + @Test + public void baseUriCanHaveContextPath() throws Exception { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + URI baseUri = server.getUri().resolve("/context/path"); + EventSender.Result result = es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, baseUri); + + assertTrue(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals("/context/path/bulk", req.getPath()); + assertThat(req.getHeader("content-type"), equalToIgnoringCase("application/json; charset=utf-8")); + assertEquals(FAKE_DATA, req.getBody()); + } + } + + @Test + public void nothingIsSentForNullData() throws Exception { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + EventSender.Result result1 = es.sendAnalyticsEvents(null, 0, server.getUri()); + EventSender.Result result2 = es.sendDiagnosticEvent(null, server.getUri()); + + assertTrue(result1.isSuccess()); + assertTrue(result2.isSuccess()); + assertEquals(0, server.getRecorder().count()); + } + } + } + + @Test + public void nothingIsSentForEmptyData() throws Exception { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + EventSender.Result result1 = es.sendAnalyticsEvents(new byte[0], 0, server.getUri()); + EventSender.Result result2 = es.sendDiagnosticEvent(new byte[0], server.getUri()); + + assertTrue(result1.isSuccess()); + assertTrue(result2.isSuccess()); + assertEquals(0, server.getRecorder().count()); + } + } + } + + private void testUnrecoverableHttpError(int status) throws Exception { + Handler errorResponse = Handlers.status(status); + + try (HttpServer server = HttpServer.start(errorResponse)) { + try (EventSender es = makeEventSender()) { + EventSender.Result result = es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, server.getUri()); + + assertFalse(result.isSuccess()); + assertTrue(result.isMustShutDown()); + } + + server.getRecorder().requireRequest(); + + // it does not retry after this type of error, so there are no more requests + server.getRecorder().requireNoRequests(Duration.ofMillis(100)); + } + } + + private void testRecoverableHttpError(int status) throws Exception { + Handler errorResponse = Handlers.status(status); + Handler errorsThenSuccess = Handlers.sequential(errorResponse, errorResponse, eventsSuccessResponse()); + // send two errors in a row, because the flush will be retried one time + + try (HttpServer server = HttpServer.start(errorsThenSuccess)) { + try (EventSender es = makeEventSender()) { + EventSender.Result result = es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, server.getUri()); + + assertFalse(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + + server.getRecorder().requireRequest(); + server.getRecorder().requireRequest(); + server.getRecorder().requireNoRequests(Duration.ofMillis(100)); // only 2 requests total + } + } + + private Handler eventsSuccessResponse() { + return Handlers.status(202); + } + + private Handler addDateHeader(Date date) { + return Handlers.header("Date", httpDateFormat.format(date)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticEventTest.java new file mode 100644 index 0000000..dab6091 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticEventTest.java @@ -0,0 +1,45 @@ +package com.launchdarkly.sdk.internal.events; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import org.junit.Test; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class DiagnosticEventTest { + private static List testStreamInits = + Collections.singletonList(new DiagnosticEvent.StreamInit(1500, 100, true)); + + @Test + public void testSerialization() { + DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); + DiagnosticEvent.Statistics diagnosticStatisticsEvent = new DiagnosticEvent.Statistics(2000, diagnosticId, 1000, 1, 2, 3, testStreamInits); + JsonObject jsonObject = gsonInstance().toJsonTree(diagnosticStatisticsEvent).getAsJsonObject(); + assertEquals(8, jsonObject.size()); + assertEquals("diagnostic", diagnosticStatisticsEvent.kind); + assertEquals(2000, jsonObject.getAsJsonPrimitive("creationDate").getAsLong()); + JsonObject idObject = jsonObject.getAsJsonObject("id"); + assertEquals("DK_KEY", idObject.getAsJsonPrimitive("sdkKeySuffix").getAsString()); + // Throws InvalidArgumentException on invalid UUID + @SuppressWarnings("unused") + UUID uuid = UUID.fromString(idObject.getAsJsonPrimitive("diagnosticId").getAsString()); + assertEquals(1000, jsonObject.getAsJsonPrimitive("dataSinceDate").getAsLong()); + assertEquals(1, jsonObject.getAsJsonPrimitive("droppedEvents").getAsLong()); + assertEquals(2, jsonObject.getAsJsonPrimitive("deduplicatedUsers").getAsLong()); + assertEquals(3, jsonObject.getAsJsonPrimitive("eventsInLastBatch").getAsLong()); + JsonArray initsJson = jsonObject.getAsJsonArray("streamInits"); + assertEquals(1, initsJson.size()); + JsonObject initJson = initsJson.get(0).getAsJsonObject(); + assertEquals(1500, initJson.getAsJsonPrimitive("timestamp").getAsInt()); + assertEquals(100, initJson.getAsJsonPrimitive("durationMillis").getAsInt()); + assertTrue(initJson.getAsJsonPrimitive("failed").getAsBoolean()); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticIdTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticIdTest.java new file mode 100644 index 0000000..b49e799 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticIdTest.java @@ -0,0 +1,52 @@ +package com.launchdarkly.sdk.internal.events; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +import org.junit.Test; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@SuppressWarnings("javadoc") +public class DiagnosticIdTest { + private static final Gson gson = new Gson(); + + @Test + public void hasUUID() { + DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); + assertNotNull(diagnosticId.diagnosticId); + assertNotNull(UUID.fromString(diagnosticId.diagnosticId)); + } + + @Test + public void nullKeyIsSafe() { + // We can't send diagnostics without a key anyway, so we're just validating that the + // constructor won't crash with a null key + new DiagnosticId(null); + } + + @Test + public void shortKeyIsSafe() { + DiagnosticId diagnosticId = new DiagnosticId("foo"); + assertEquals("foo", diagnosticId.sdkKeySuffix); + } + + @Test + public void keyIsSuffix() { + DiagnosticId diagnosticId = new DiagnosticId("this_is_a_fake_key"); + assertEquals("ke_key", diagnosticId.sdkKeySuffix); + } + + @Test + public void gsonSerialization() { + DiagnosticId diagnosticId = new DiagnosticId("this_is_a_fake_key"); + JsonObject jsonObject = gson.toJsonTree(diagnosticId).getAsJsonObject(); + assertEquals(2, jsonObject.size()); + String id = jsonObject.getAsJsonPrimitive("diagnosticId").getAsString(); + assertNotNull(UUID.fromString(id)); + assertEquals("ke_key", jsonObject.getAsJsonPrimitive("sdkKeySuffix").getAsString()); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticStoreTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticStoreTest.java new file mode 100644 index 0000000..8297c3e --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticStoreTest.java @@ -0,0 +1,177 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.internal.events.DiagnosticStore.SdkDiagnosticParams; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import static com.launchdarkly.testhelpers.JsonAssertions.jsonEqualsValue; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonProperty; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonUndefined; +import static com.launchdarkly.testhelpers.JsonTestValue.jsonFromValue; +import static java.util.Collections.singletonMap; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; + +@SuppressWarnings("javadoc") +public class DiagnosticStoreTest { + private static final String SDK_KEY = "key-abcdefg"; + private static final String SDK_NAME = "fake-sdk"; + private static final String SDK_VERSION = "1.2.3"; + private static final String PLATFORM_NAME = "fake-platform"; + + @Test + public void initEventBasicProperties() { + long now = System.currentTimeMillis(); + DiagnosticStore store = makeSimpleStore(); + DiagnosticEvent.Init ie = store.getInitEvent(); + assertThat(ie.kind, equalTo("diagnostic-init")); + assertThat(ie.creationDate, greaterThanOrEqualTo(now)); + assertThat(ie.id, notNullValue()); + assertThat(ie.id.diagnosticId, notNullValue()); + assertThat(ie.id.sdkKeySuffix, equalTo("bcdefg")); + } + + @Test + public void initEventSdkData() { + DiagnosticStore store = makeSimpleStore(); + DiagnosticEvent.Init ie = store.getInitEvent(); + assertThat(jsonFromValue(ie.sdk), allOf( + jsonProperty("name", SDK_NAME), + jsonProperty("version", SDK_VERSION), + jsonProperty("wrapperName", jsonUndefined()), + jsonProperty("wrapperVersion", jsonUndefined()) + )); + } + + @Test + public void initEventSdkDataWithWrapperName() { + DiagnosticStore store = new DiagnosticStore(new SdkDiagnosticParams( + SDK_KEY, SDK_NAME, SDK_VERSION, PLATFORM_NAME, null, + singletonMap("X-LaunchDarkly-Wrapper", "Scala"), + null + )); + DiagnosticEvent.Init ie = store.getInitEvent(); + assertThat(jsonFromValue(ie.sdk), allOf( + jsonProperty("name", SDK_NAME), + jsonProperty("version", SDK_VERSION), + jsonProperty("wrapperName", "Scala"), + jsonProperty("wrapperVersion", jsonUndefined()) + )); + } + + @Test + public void initEventSdkDataWithWrapperNameAndVersion() { + DiagnosticStore store = new DiagnosticStore(new SdkDiagnosticParams( + SDK_KEY, SDK_NAME, SDK_VERSION, PLATFORM_NAME, null, + singletonMap("X-LaunchDarkly-Wrapper", "Scala/0.1"), + null + )); + DiagnosticEvent.Init ie = store.getInitEvent(); + assertThat(jsonFromValue(ie.sdk), allOf( + jsonProperty("name", SDK_NAME), + jsonProperty("version", SDK_VERSION), + jsonProperty("wrapperName", "Scala"), + jsonProperty("wrapperVersion", "0.1") + )); + } + + @Test + public void platformDataFromSdk() { + DiagnosticStore store = new DiagnosticStore(new SdkDiagnosticParams( + SDK_KEY, SDK_NAME, SDK_VERSION, PLATFORM_NAME, + LDValue.buildObject().put("prop1", 2).put("prop2", 3).build(), + null, null + )); + DiagnosticEvent.Init ie = store.getInitEvent(); + assertThat(jsonFromValue(ie.platform), allOf( + jsonProperty("name", PLATFORM_NAME), + jsonProperty("prop1", 2), + jsonProperty("prop2", 3) + )); + } + + @Test + public void configurationData() { + List configValues = Arrays.asList( + LDValue.buildObject() + .put(DiagnosticConfigProperty.EVENTS_CAPACITY.name, 1000) + .put(DiagnosticConfigProperty.USER_KEYS_CAPACITY.name, 2000) + .put(DiagnosticConfigProperty.ALL_ATTRIBUTES_PRIVATE.name, "yes") // ignored because of wrong type + .build(), + LDValue.of("abcdef"), // ignored because it's not an object + null, // no-op + LDValue.buildObject().put(DiagnosticConfigProperty.DATA_STORE_TYPE.name, "custom").build() + ); + DiagnosticStore store = new DiagnosticStore(new SdkDiagnosticParams( + SDK_KEY, SDK_NAME, SDK_VERSION, PLATFORM_NAME, null, null, + configValues + )); + DiagnosticEvent.Init ie = store.getInitEvent(); + assertThat(jsonFromValue(ie.configuration), jsonEqualsValue( + LDValue.buildObject() + .put(DiagnosticConfigProperty.EVENTS_CAPACITY.name, 1000) + .put(DiagnosticConfigProperty.USER_KEYS_CAPACITY.name, 2000) + .put(DiagnosticConfigProperty.DATA_STORE_TYPE.name, "custom") + .build() + )); + } + + @Test + public void createsDiagnosticStatisticsEvent() { + DiagnosticStore store = makeSimpleStore(); + long startDate = store.getDataSinceDate(); + DiagnosticEvent.Statistics diagnosticStatisticsEvent = store.createEventAndReset(10, 15); + assertSame(store.getDiagnosticId(), diagnosticStatisticsEvent.id); + assertEquals(10, diagnosticStatisticsEvent.droppedEvents); + assertEquals(15, diagnosticStatisticsEvent.deduplicatedUsers); + assertEquals(0, diagnosticStatisticsEvent.eventsInLastBatch); + assertEquals(startDate, diagnosticStatisticsEvent.dataSinceDate); + } + + @Test + public void canRecordStreamInit() { + DiagnosticStore store = makeSimpleStore(); + store.recordStreamInit(1000, 200, false); + DiagnosticEvent.Statistics statsEvent = store.createEventAndReset(0, 0); + assertEquals(1, statsEvent.streamInits.size()); + assertEquals(1000, statsEvent.streamInits.get(0).timestamp); + assertEquals(200, statsEvent.streamInits.get(0).durationMillis); + assertEquals(false, statsEvent.streamInits.get(0).failed); + } + + @Test + public void canRecordEventsInBatch() { + DiagnosticStore store = makeSimpleStore(); + store.recordEventsInBatch(100); + DiagnosticEvent.Statistics statsEvent = store.createEventAndReset(0, 0); + assertEquals(100, statsEvent.eventsInLastBatch); + } + + @Test + public void resetsStatsOnCreate() throws InterruptedException { + DiagnosticStore store = makeSimpleStore(); + store.recordStreamInit(1000, 200, false); + store.recordEventsInBatch(100); + long startDate = store.getDataSinceDate(); + Thread.sleep(2); // so that dataSinceDate will be different + store.createEventAndReset(0, 0); + assertNotEquals(startDate, store.getDataSinceDate()); + DiagnosticEvent.Statistics resetEvent = store.createEventAndReset(0,0); + assertEquals(0, resetEvent.streamInits.size()); + assertEquals(0, resetEvent.eventsInLastBatch); + } + + private static DiagnosticStore makeSimpleStore() { + return new DiagnosticStore(new SdkDiagnosticParams(SDK_KEY, SDK_NAME, SDK_VERSION, PLATFORM_NAME, null, null, null)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java new file mode 100644 index 0000000..dca4f90 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java @@ -0,0 +1,184 @@ +package com.launchdarkly.sdk.internal.events; + +import com.google.common.collect.ImmutableList; +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.AttributeRef; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonEquals; + +@SuppressWarnings("javadoc") +@RunWith(Parameterized.class) +public class EventContextFormatterTest { + private final LDContext context; + private final boolean allAttributesPrivate; + private final AttributeRef[] globalPrivateAttributes; + private final String expectedJson; + + public EventContextFormatterTest( + String name, + LDContext context, + boolean allAttributesPrivate, + AttributeRef[] globalPrivateAttributes, + String expectedJson + ) { + this.context = context; + this.allAttributesPrivate = allAttributesPrivate; + this.globalPrivateAttributes = globalPrivateAttributes; + this.expectedJson = expectedJson; + } + + @Parameterized.Parameters(name = "{0}") + public static Iterable data() { + return ImmutableList.of( + new Object[] { + "no attributes private, single kind", + LDContext.builder("my-key").kind("org") + .name("my-name") + .set("attr1", "value1") + .build(), + false, + null, + "{\"kind\": \"org\", \"key\": \"my-key\", \"name\": \"my-name\", \"attr1\": \"value1\"}" + }, + new Object[] { + "no attributes private, multi-kind", + LDContext.createMulti( + LDContext.builder("org-key").kind("org") + .name("org-name") + .build(), + LDContext.builder("user-key") + .name("user-name") + .set("attr1", "value1") + .build() + ), + false, + null, + "{" + + "\"kind\": \"multi\"," + + "\"org\": {\"key\": \"org-key\", \"name\": \"org-name\"}," + + "\"user\": {\"key\": \"user-key\", \"name\": \"user-name\", \"attr1\": \"value1\"}" + + "}" + }, + new Object[] { + "anonymous", + LDContext.builder("my-key").kind("org").anonymous(true).build(), + false, + null, + "{\"kind\": \"org\", \"key\": \"my-key\", \"anonymous\": true}" + }, + new Object[] { + "secondary", + LDContext.builder("my-key").kind("org").secondary("x").build(), + false, + null, + "{\"kind\": \"org\", \"key\": \"my-key\", \"_meta\": {\"secondary\": \"x\"}}" + }, + new Object[] { + "all attributes private globally", + LDContext.builder("my-key").kind("org") + .name("my-name") + .set("attr1", "value1") + .build(), + true, + null, + "{" + + "\"kind\": \"org\"," + + "\"key\": \"my-key\"," + + "\"_meta\": {" + + "\"redactedAttributes\": [\"attr1\", \"name\"]" + + "}" + + "}" + }, + new Object[] { + "some top-level attributes private", + LDContext.builder("my-key").kind("org") + .name("my-name") + .set("attr1", "value1") + .set("attr2", "value2") + .privateAttributes("attr2") + .build(), + false, + new AttributeRef[] { AttributeRef.fromLiteral("name") }, + "{" + + "\"kind\": \"org\"," + + "\"key\": \"my-key\"," + + "\"attr1\": \"value1\"," + + "\"_meta\": {" + + "\"redactedAttributes\": [\"attr2\", \"name\"]" + + "}" + + "}" + }, + new Object[] { + "partially redacting object attributes", + LDContext.builder("my-key") + .set("address", LDValue.parse("{\"street\": \"17 Highbrow St.\", \"city\": \"London\"}")) + .set("complex", LDValue.parse("{\"a\": {\"b\": {\"c\": 1, \"d\": 2}, \"e\": 3}, \"f\": 4, \"g\": 5}")) + .privateAttributes("/complex/a/b/d", "/complex/a/b/nonexistent-prop", "/complex/f", "/complex/g/g-is-not-an-object") + .build(), + false, + new AttributeRef[] { AttributeRef.fromPath("/address/street") }, + "{" + + "\"kind\": \"user\"," + + "\"key\": \"my-key\"," + + "\"address\": {\"city\": \"London\"}," + + "\"complex\": {\"a\": {\"b\": {\"c\": 1}, \"e\": 3}, \"g\": 5}," + + "\"_meta\": {" + + "\"redactedAttributes\": [\"/address/street\", \"/complex/a/b/d\", \"/complex/f\"]" + + "}" + + "}" + } + ); + } + + @Test + public void testOutput() throws Exception { + EventContextFormatter f = new EventContextFormatter(allAttributesPrivate, globalPrivateAttributes); + StringWriter sw = new StringWriter(); + JsonWriter jw = new JsonWriter(sw); + + f.write(context, jw); + jw.flush(); + + String canonicalizedOutput = canonicalizeOutputJson(sw.toString()); + assertJsonEquals(expectedJson, canonicalizedOutput); + } + + private static String canonicalizeOutputJson(String json) { + return valueWithRedactedAttributesSorted(LDValue.parse(json)).toJsonString(); + } + + private static LDValue valueWithRedactedAttributesSorted(LDValue value) { + switch (value.getType()) { + case OBJECT: + ObjectBuilder ob = LDValue.buildObject(); + for (String key: value.keys()) { + LDValue propValue = value.get(key); + if (key.equals("redactedAttributes")) { + List strings = new ArrayList<>(); + for (LDValue element: propValue.values()) { + strings.add(element.stringValue()); + } + Collections.sort(strings); + ob.put(key, LDValue.Convert.String.arrayFrom(strings)); + } else { + ob.put(key, valueWithRedactedAttributesSorted(propValue)); + } + } + return ob.build(); + default: + return value; + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java new file mode 100644 index 0000000..afcc6d4 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java @@ -0,0 +1,376 @@ +package com.launchdarkly.sdk.internal.events; + +import com.google.common.collect.ImmutableSet; +import com.google.gson.Gson; +import com.launchdarkly.sdk.AttributeRef; +import com.launchdarkly.sdk.ContextBuilder; +import com.launchdarkly.sdk.ContextKind; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.internal.events.Event.FeatureRequest; +import com.launchdarkly.sdk.internal.events.EventSummarizer.EventSummary; + +import org.junit.Test; + +import java.io.IOException; +import java.io.StringWriter; + +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +public class EventOutputTest extends BaseEventTest { + private static final Gson gson = new Gson(); + + private ContextBuilder contextBuilderWithAllAttributes = LDContext.builder("userkey") + .anonymous(true) + .name("me") + .secondary("s") + .set("custom1", "value1") + .set("custom2", "value2"); + private static final LDValue contextJsonWithAllAttributes = parseValue("{" + + "\"kind\":\"user\"," + + "\"key\":\"userkey\"," + + "\"anonymous\":true," + + "\"custom1\":\"value1\"," + + "\"custom2\":\"value2\"," + + "\"name\":\"me\"," + + "\"_meta\":{\"secondary\":\"s\"}" + + "}"); + + @Test + public void allAttributesAreSerialized() throws Exception { + testInlineContextSerialization(contextBuilderWithAllAttributes.build(), contextJsonWithAllAttributes, + defaultEventsConfig()); + } + + @Test + public void contextKeysAreSetInsteadOfContextWhenNotInlined() throws Exception { + testContextKeysSerialization( + LDContext.create("userkey"), + LDValue.buildObject().put("user", "userkey").build() + ); + + testContextKeysSerialization( + LDContext.create(ContextKind.of("kind1"), "key1"), + LDValue.buildObject().put("kind1", "key1").build() + ); + + testContextKeysSerialization( + LDContext.createMulti( + LDContext.create(ContextKind.of("kind1"), "key1"), + LDContext.create(ContextKind.of("kind2"), "key2")), + LDValue.buildObject().put("kind1", "key1").put("kind2", "key2").build() + ); + } + + @Test + public void allAttributesPrivateMakesAttributesPrivate() throws Exception { + // We test this behavior in more detail in EventContextFormatterTest, but here we're verifying that the + // EventOutputFormatter is actually using EventContextFormatter and configuring it correctly. + LDContext context = LDContext.builder("userkey") + .name("me") + .build(); + LDValue expectedJson = LDValue.buildObject() + .put("kind", "user") + .put("key", context.getKey()) + .put("_meta", LDValue.parse("{\"redactedAttributes\":[\"name\"]}")) + .build(); + EventsConfiguration config = makeEventsConfig(true, null); + testInlineContextSerialization(context, expectedJson, config); + } + + @Test + public void globalPrivateAttributeNamesMakeAttributesPrivate() throws Exception { + // See comment in allAttributesPrivateMakesAttributesPrivate + LDContext context = LDContext.builder("userkey") + .name("me") + .set("attr1", "value1") + .build(); + LDValue expectedJson = LDValue.buildObject() + .put("kind", "user") + .put("key", context.getKey()) + .put("name", "me") + .put("_meta", LDValue.parse("{\"redactedAttributes\":[\"attr1\"]}")) + .build(); + EventsConfiguration config = makeEventsConfig(false, ImmutableSet.of(AttributeRef.fromLiteral("attr1"))); + testInlineContextSerialization(context, expectedJson, config); + } + + @Test + public void perContextPrivateAttributesMakeAttributePrivate() throws Exception { + // See comment in allAttributesPrivateMakesAttributesPrivate + LDContext context = LDContext.builder("userkey") + .name("me") + .set("attr1", "value1") + .privateAttributes("attr1") + .build(); + LDValue expectedJson = LDValue.buildObject() + .put("kind", "user") + .put("key", context.getKey()) + .put("name", "me") + .put("_meta", LDValue.parse("{\"redactedAttributes\":[\"attr1\"]}")) + .build(); + EventsConfiguration config = makeEventsConfig(false, null); + testInlineContextSerialization(context, expectedJson, config); + } + + private ObjectBuilder buildFeatureEventProps(String key, String userKey) { + return LDValue.buildObject() + .put("kind", "feature") + .put("key", key) + .put("creationDate", 100000) + .put("contextKeys", LDValue.buildObject().put("user", userKey).build()); + } + + private ObjectBuilder buildFeatureEventProps(String key) { + return buildFeatureEventProps(key, "userkey"); + } + + @Test + public void featureEventIsSerialized() throws Exception { + LDContext context = LDContext.builder("userkey").name("me").build(); + LDValue value = LDValue.of("flagvalue"), defaultVal = LDValue.of("defaultvalue"); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + + FeatureRequest feWithVariation = featureEvent(context, FLAG_KEY).flagVersion(FLAG_VERSION).variation(1) + .value(value).defaultValue(defaultVal).build(); + LDValue feJson1 = buildFeatureEventProps(FLAG_KEY) + .put("version", FLAG_VERSION) + .put("variation", 1) + .put("value", value) + .put("default", defaultVal) + .build(); + assertJsonEquals(feJson1, getSingleOutputEvent(f, feWithVariation)); + + FeatureRequest feWithoutVariationOrDefault = featureEvent(context, FLAG_KEY).flagVersion(FLAG_VERSION) + .variation(NO_VARIATION).value(value).defaultValue(null).build(); + LDValue feJson2 = buildFeatureEventProps(FLAG_KEY) + .put("version", FLAG_VERSION) + .put("value", value) + .build(); + assertJsonEquals(feJson2, getSingleOutputEvent(f, feWithoutVariationOrDefault)); + + FeatureRequest feWithReason = featureEvent(context, FLAG_KEY).flagVersion(FLAG_VERSION).variation(1) + .value(value).defaultValue(defaultVal).reason(EvaluationReason.fallthrough()).build(); + LDValue feJson3 = buildFeatureEventProps(FLAG_KEY) + .put("version", FLAG_VERSION) + .put("variation", 1) + .put("value", value) + .put("default", defaultVal) + .put("reason", LDValue.buildObject().put("kind", "FALLTHROUGH").build()) + .build(); + assertJsonEquals(feJson3, getSingleOutputEvent(f, feWithReason)); + + Event.FeatureRequest debugEvent = feWithVariation.toDebugEvent(); + LDValue feJson5 = LDValue.buildObject() + .put("kind", "debug") + .put("key", FLAG_KEY) + .put("creationDate", 100000) + .put("version", FLAG_VERSION) + .put("variation", 1) + .put("context", LDValue.buildObject().put("kind", "user").put("key", "userkey").put("name", "me").build()) + .put("value", value) + .put("default", defaultVal) + .build(); + assertJsonEquals(feJson5, getSingleOutputEvent(f, debugEvent)); + + Event.FeatureRequest prereqEvent = featureEvent(context, FLAG_KEY).flagVersion(FLAG_VERSION) + .variation(1).value(value).defaultValue(null).prereqOf("parent").build(); + LDValue feJson6 = buildFeatureEventProps(FLAG_KEY) + .put("version", 11) + .put("variation", 1) + .put("value", "flagvalue") + .put("prereqOf", "parent") + .build(); + assertJsonEquals(feJson6, getSingleOutputEvent(f, prereqEvent)); + } + + @Test + public void identifyEventIsSerialized() throws IOException { + LDContext context = LDContext.builder("userkey").name("me").build(); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + + Event.Identify ie = identifyEvent(context); + LDValue ieJson = parseValue("{" + + "\"kind\":\"identify\"," + + "\"creationDate\":100000," + + "\"context\":{\"kind\":\"user\",\"key\":\"userkey\",\"name\":\"me\"}" + + "}"); + assertJsonEquals(ieJson, getSingleOutputEvent(f, ie)); + } + + @Test + public void customEventIsSerialized() throws IOException { + LDContext context = LDContext.builder("userkey").name("me").build(); + LDValue contextKeysJson = LDValue.buildObject().put("user", context.getKey()).build(); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + + Event.Custom ceWithoutData = customEvent(context, "customkey").build(); + LDValue ceJson1 = parseValue("{" + + "\"kind\":\"custom\"," + + "\"creationDate\":100000," + + "\"key\":\"customkey\"," + + "\"contextKeys\":" + contextKeysJson + + "}"); + assertJsonEquals(ceJson1, getSingleOutputEvent(f, ceWithoutData)); + + Event.Custom ceWithData = customEvent(context, "customkey").data(LDValue.of("thing")).build(); + LDValue ceJson2 = parseValue("{" + + "\"kind\":\"custom\"," + + "\"creationDate\":100000," + + "\"key\":\"customkey\"," + + "\"contextKeys\":" + contextKeysJson + "," + + "\"data\":\"thing\"" + + "}"); + assertJsonEquals(ceJson2, getSingleOutputEvent(f, ceWithData)); + + Event.Custom ceWithMetric = customEvent(context, "customkey").metricValue(2.5).build(); + LDValue ceJson3 = parseValue("{" + + "\"kind\":\"custom\"," + + "\"creationDate\":100000," + + "\"key\":\"customkey\"," + + "\"contextKeys\":" + contextKeysJson + "," + + "\"metricValue\":2.5" + + "}"); + assertJsonEquals(ceJson3, getSingleOutputEvent(f, ceWithMetric)); + + Event.Custom ceWithDataAndMetric = customEvent(context, "customkey").data(LDValue.of("thing")) + .metricValue(2.5).build(); + LDValue ceJson4 = parseValue("{" + + "\"kind\":\"custom\"," + + "\"creationDate\":100000," + + "\"key\":\"customkey\"," + + "\"contextKeys\":" + contextKeysJson + "," + + "\"data\":\"thing\"," + + "\"metricValue\":2.5" + + "}"); + assertJsonEquals(ceJson4, getSingleOutputEvent(f, ceWithDataAndMetric)); + } + + @Test + public void summaryEventIsSerialized() throws Exception { + LDValue value1a = LDValue.of("value1a"), value2a = LDValue.of("value2a"), value2b = LDValue.of("value2b"), + default1 = LDValue.of("default1"), default2 = LDValue.of("default2"), default3 = LDValue.of("default3"); + LDContext context1 = LDContext.create("key1"); + LDContext context2 = LDContext.createMulti(context1, LDContext.create(ContextKind.of("kind2"), "key2")); + + EventSummarizer es = new EventSummarizer(); + + es.summarizeEvent(1000, "first", 11, 1, value1a, default1, context1); // context1 has kind "user" + + es.summarizeEvent(1000, "second", 21, 1, value2a, default2, context1); + + es.summarizeEvent(1001, "first", 11, 1, value1a, default1, context1); + es.summarizeEvent(1001, "first", 12, 1, value1a, default1, context2); // context2 has kind "user" and kind "kind2" + + es.summarizeEvent(1001, "second", 21, 2, value2b, default2, context1); + es.summarizeEvent(1002, "second", 21, -1, default2, default2, context1); + + es.summarizeEvent(1002, "third", -1, -1, default3, default3, context1); + + EventSummary summary = es.getSummaryAndReset(); + + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + StringWriter w = new StringWriter(); + int count = f.writeOutputEvents(new Event[0], summary, w); + assertEquals(1, count); + LDValue outputEvent = parseValue(w.toString()).get(0); + + assertEquals("summary", outputEvent.get("kind").stringValue()); + assertEquals(1000, outputEvent.get("startDate").intValue()); + assertEquals(1002, outputEvent.get("endDate").intValue()); + + LDValue featuresJson = outputEvent.get("features"); + assertEquals(3, featuresJson.size()); + + LDValue firstJson = featuresJson.get("first"); + assertEquals("default1", firstJson.get("default").stringValue()); + assertThat(firstJson.get("contextKinds").values(), containsInAnyOrder( + LDValue.of("user"), LDValue.of("kind2"))); + assertThat(firstJson.get("counters").values(), containsInAnyOrder( + parseValue("{\"value\":\"value1a\",\"variation\":1,\"version\":11,\"count\":2}"), + parseValue("{\"value\":\"value1a\",\"variation\":1,\"version\":12,\"count\":1}") + )); + + LDValue secondJson = featuresJson.get("second"); + assertEquals("default2", secondJson.get("default").stringValue()); + assertThat(secondJson.get("contextKinds").values(), contains(LDValue.of("user"))); + assertThat(secondJson.get("counters").values(), containsInAnyOrder( + parseValue("{\"value\":\"value2a\",\"variation\":1,\"version\":21,\"count\":1}"), + parseValue("{\"value\":\"value2b\",\"variation\":2,\"version\":21,\"count\":1}"), + parseValue("{\"value\":\"default2\",\"version\":21,\"count\":1}") + )); + + LDValue thirdJson = featuresJson.get("third"); + assertEquals("default3", thirdJson.get("default").stringValue()); + assertThat(thirdJson.get("contextKinds").values(), contains(LDValue.of("user"))); + assertThat(thirdJson.get("counters").values(), contains( + parseValue("{\"unknown\":true,\"value\":\"default3\",\"count\":1}") + )); + } + + @Test + public void unknownEventClassIsNotSerialized() throws Exception { + // This shouldn't be able to happen in reality. + Event event = new FakeEventClass(1000, LDContext.create("user")); + + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + StringWriter w = new StringWriter(); + f.writeOutputEvents(new Event[] { event }, new EventSummary(), w); + + assertEquals("[]", w.toString()); + } + + private static class FakeEventClass extends Event { + public FakeEventClass(long creationDate, LDContext context) { + super(creationDate, context); + } + } + + private static LDValue parseValue(String json) { + return gson.fromJson(json, LDValue.class); + } + + private LDValue getSingleOutputEvent(EventOutputFormatter f, Event event) throws IOException { + StringWriter w = new StringWriter(); + int count = f.writeOutputEvents(new Event[] { event }, new EventSummary(), w); + assertEquals(1, count); + return parseValue(w.toString()).get(0); + } + + private void testContextKeysSerialization(LDContext context, LDValue expectedJsonValue) throws IOException { + EventsConfiguration config = makeEventsConfig(false, null); + EventOutputFormatter f = new EventOutputFormatter(config); + + Event.FeatureRequest featureEvent = featureEvent(context, FLAG_KEY).build(); + LDValue outputEvent = getSingleOutputEvent(f, featureEvent); + assertJsonEquals(expectedJsonValue, outputEvent.get("contextKeys")); + assertJsonEquals(LDValue.ofNull(), outputEvent.get("context")); + + Event.Custom customEvent = customEvent(context, "eventkey").build(); + outputEvent = getSingleOutputEvent(f, customEvent); + assertJsonEquals(expectedJsonValue, outputEvent.get("contextKeys")); + assertJsonEquals(LDValue.ofNull(), outputEvent.get("context")); + } + + private void testInlineContextSerialization(LDContext context, LDValue expectedJsonValue, EventsConfiguration baseConfig) throws IOException { + EventsConfiguration config = makeEventsConfig(baseConfig.allAttributesPrivate, baseConfig.privateAttributes); + EventOutputFormatter f = new EventOutputFormatter(config); + + Event.Identify identifyEvent = identifyEvent(context); + LDValue outputEvent = getSingleOutputEvent(f, identifyEvent); + assertJsonEquals(LDValue.ofNull(), outputEvent.get("contextKeys")); + assertJsonEquals(expectedJsonValue, outputEvent.get("context")); + + Event.Index indexEvent = new Event.Index(0, context); + outputEvent = getSingleOutputEvent(f, indexEvent); + assertJsonEquals(LDValue.ofNull(), outputEvent.get("contextKeys")); + assertJsonEquals(expectedJsonValue, outputEvent.get("context")); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/EventSummarizerTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/EventSummarizerTest.java new file mode 100644 index 0000000..cce28d4 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/events/EventSummarizerTest.java @@ -0,0 +1,198 @@ +package com.launchdarkly.sdk.internal.events; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.ContextKind; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.internal.events.EventSummarizer.CounterValue; +import com.launchdarkly.sdk.internal.events.EventSummarizer.EventSummary; +import com.launchdarkly.sdk.internal.events.EventSummarizer.FlagInfo; +import com.launchdarkly.sdk.internal.events.EventSummarizer.SimpleIntKeyedMap; + +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class EventSummarizerTest { + private static final LDContext context = LDContext.create("key"); + + @Test + public void summarizerCanBeCleared() { + EventSummarizer es = new EventSummarizer(); + assertTrue(es.isEmpty()); + + es.summarizeEvent(1000, "flagkey", 1, 0, LDValue.ofNull(), LDValue.ofNull(), context); + + assertFalse(es.isEmpty()); + + es.clear(); + + assertTrue(es.isEmpty()); + } + + @Test + public void summarizeEventSetsStartAndEndDates() { + EventSummarizer es = new EventSummarizer(); + + for (long timestamp: new long[] { 2000, 1000, 1500 }) { + es.summarizeEvent(timestamp, "flagkey", 1, 0, LDValue.ofNull(), LDValue.ofNull(), context); + } + + EventSummarizer.EventSummary data = es.getSummaryAndReset(); + + assertEquals(1000, data.startDate); + assertEquals(2000, data.endDate); + } + + @Test + public void summarizeEventIncrementsCounters() { + EventSummarizer es = new EventSummarizer(); + String flagKey1 = "key1", flagKey2 = "key2", unknownFlagKey = "badkey"; + int flagVersion1 = 11, flagVersion2 = 22; + LDValue value1 = LDValue.of("value1"), value2 = LDValue.of("value2"), value99 = LDValue.of("value99"), + default1 = LDValue.of("default1"), default2 = LDValue.of("default2"), default3 = LDValue.of("default3"); + LDContext multiKindContext = LDContext.createMulti( + context, LDContext.create(ContextKind.of("kind2"), "key2")); + long timestamp = 1000; + + es.summarizeEvent(timestamp, flagKey1, flagVersion1, 1, value1, default1, context); + es.summarizeEvent(timestamp, flagKey1, flagVersion1, 2, value2, default1, context); + es.summarizeEvent(timestamp, flagKey2, flagVersion2, 1, value99, default2, multiKindContext); + es.summarizeEvent(timestamp, flagKey1, flagVersion1, 1, value1, default1, context); + es.summarizeEvent(timestamp, unknownFlagKey, -1, -1, default3, default3, context); + + EventSummarizer.EventSummary data = es.getSummaryAndReset(); + + assertThat(data.counters, equalTo(ImmutableMap.builder() + .put(flagKey1, new FlagInfo(default1, + new SimpleIntKeyedMap>() + .put(flagVersion1, new SimpleIntKeyedMap() + .put(1, new CounterValue(2, value1)) + .put(2, new CounterValue(1, value2)) + ), + ImmutableSet.of("user"))) + .put(flagKey2, new FlagInfo(default2, + new SimpleIntKeyedMap>() + .put(flagVersion2, new SimpleIntKeyedMap() + .put(1, new CounterValue(1, value99)) + ), + ImmutableSet.of("user", "kind2"))) + .put(unknownFlagKey, new FlagInfo(default3, + new SimpleIntKeyedMap>() + .put(-1, new SimpleIntKeyedMap() + .put(-1, new CounterValue(1, default3)) + ), + ImmutableSet.of("user"))) + .build())); + } + + // The following implementations are used only in debug/test code, but may as well test them + + @Test + public void counterValueEquality() { + CounterValue value1 = new CounterValue(1, LDValue.of("a")); + CounterValue value2 = new CounterValue(1, LDValue.of("a")); + assertEquals(value1, value2); + assertEquals(value2, value1); + + for (CounterValue notEqualValue: new CounterValue[] { + new CounterValue(2, LDValue.of("a")), + new CounterValue(1, LDValue.of("b")) + }) { + assertNotEquals(value1, notEqualValue); + assertNotEquals(notEqualValue, value1); + + assertNotEquals(value1, null); + assertNotEquals(value1, "x"); + } + } + + @Test + public void counterValueToString() { + assertEquals("(1,\"a\")", new CounterValue(1, LDValue.of("a")).toString()); + } + + @Test + public void eventSummaryEquality() { + String key1 = "key1", key2 = "key2"; + int variation1 = 0, variation2 = 1, variation3 = 2, version1 = 10, version2 = 20; + LDValue value1 = LDValue.of(1), value2 = LDValue.of(2), value3 = LDValue.of(3), + default1 = LDValue.of(-1), default2 = LDValue.of(-2); + + EventSummarizer es1 = new EventSummarizer(); + es1.summarizeEvent(1000, key1, version1, variation1, value1, default1, context); + es1.summarizeEvent(1000, key1, version1, variation1, value1, default1, context); + es1.summarizeEvent(1000, key1, version2, variation2, value2, default1, context); + es1.summarizeEvent(2000, key2, version2, variation3, value3, default2, context); + + EventSummarizer es2 = new EventSummarizer(); // same operations in different order + es2.summarizeEvent(2000, key2, version2, variation3, value3, default2, context); + es2.summarizeEvent(1000, key1, version1, variation1, value1, default1, context); + es2.summarizeEvent(1000, key1, version2, variation2, value2, default1, context); + es2.summarizeEvent(1000, key1, version1, variation1, value1, default1, context); + + EventSummarizer es3 = new EventSummarizer(); // same operations with different start time + es3.summarizeEvent(1100, key1, version1, variation1, value1, default1, context); + es3.summarizeEvent(1100, key1, version1, variation1, value1, default1, context); + es3.summarizeEvent(1100, key1, version2, variation2, value2, default1, context); + es3.summarizeEvent(2000, key2, version2, variation3, value3, default2, context); + + EventSummarizer es4 = new EventSummarizer(); // same operations with different end time + es4.summarizeEvent(1000, key1, version1, variation1, value1, default1, context); + es4.summarizeEvent(1000, key1, version1, variation1, value1, default1, context); + es4.summarizeEvent(1000, key1, version2, variation2, value2, default1, context); + es4.summarizeEvent(2100, key2, version2, variation3, value3, default2, context); + + EventSummary summary1 = es1.getSummaryAndReset(); + EventSummary summary2 = es2.getSummaryAndReset(); + EventSummary summary3 = es3.getSummaryAndReset(); + EventSummary summary4 = es4.getSummaryAndReset(); + + assertEquals(summary1, summary2); + assertEquals(summary2, summary1); + + assertEquals(0, summary1.hashCode()); // see comment on hashCode + + assertNotEquals(summary1, summary3); + assertNotEquals(summary1, summary4); + + assertNotEquals(summary1, null); + assertNotEquals(summary1, "x"); + } + + @Test + public void simpleIntKeyedMapBehavior() { + // Tests the behavior of the inner class that we use instead of a Map. + SimpleIntKeyedMap m = new SimpleIntKeyedMap<>(); + int initialCapacity = m.capacity(); + + assertEquals(0, m.size()); + assertNotEquals(0, initialCapacity); + assertNull(m.get(1)); + + for (int i = 0; i < initialCapacity; i++) { + m.put(i * 100, "value" + i); + } + + assertEquals(initialCapacity, m.size()); + assertEquals(initialCapacity, m.capacity()); + + for (int i = 0; i < initialCapacity; i++) { + assertEquals("value" + i, m.get(i * 100)); + } + assertNull(m.get(33)); + + m.put(33, "other"); + assertNotEquals(initialCapacity, m.capacity()); + assertEquals(initialCapacity + 1, m.size()); + assertEquals("other", m.get(33)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/http/HttpPropertiesTest.java b/src/test/java/com/launchdarkly/sdk/internal/http/HttpPropertiesTest.java new file mode 100644 index 0000000..38a183c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/http/HttpPropertiesTest.java @@ -0,0 +1,37 @@ +package com.launchdarkly.sdk.internal.http; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +import okhttp3.OkHttpClient; + +@SuppressWarnings("javadoc") +public class HttpPropertiesTest { + @Test + public void testConnectTimeout() { + HttpProperties hp = new HttpProperties( + 100000, + null, null, null, null, 0, null, null); + OkHttpClient httpClient = hp.toHttpClientBuilder().build(); + try { + assertEquals(100000, httpClient.connectTimeoutMillis()); + } finally { + HttpProperties.shutdownHttpClient(httpClient); + } + } + + @Test + public void testSocketTimeout() { + HttpProperties hp = new HttpProperties( + 0, null, null, null, null, + 100000, + null, null); + OkHttpClient httpClient = hp.toHttpClientBuilder().build(); + try { + assertEquals(100000, httpClient.readTimeoutMillis()); + } finally { + HttpProperties.shutdownHttpClient(httpClient); + } + } +} From 3e64009ffa444babb9df57a017bb6d3ed01788eb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Aug 2022 18:11:17 -0700 Subject: [PATCH 14/57] all tests must descend from BaseTest for the Android test job to work --- .../java/com/launchdarkly/sdk/internal/BaseTest.java | 2 +- src/test/java/com/launchdarkly/sdk/internal/BaseTest.java | 8 +++++--- .../launchdarkly/sdk/internal/events/BaseEventTest.java | 4 ++-- .../sdk/internal/events/DiagnosticEventTest.java | 3 ++- .../sdk/internal/events/DiagnosticIdTest.java | 3 ++- .../sdk/internal/events/DiagnosticStoreTest.java | 3 ++- .../sdk/internal/events/EventContextFormatterTest.java | 3 ++- .../sdk/internal/events/EventSummarizerTest.java | 3 ++- .../sdk/internal/http/HttpPropertiesTest.java | 4 +++- 9 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java b/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java index bfd5d63..f3cb3c4 100644 --- a/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java +++ b/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java @@ -8,5 +8,5 @@ * the correct test runner. */ @RunWith(AndroidJUnit4.class) -public abstract class BaseTest { +public abstract class BaseTest extends BaseInternalTest { } diff --git a/src/test/java/com/launchdarkly/sdk/internal/BaseTest.java b/src/test/java/com/launchdarkly/sdk/internal/BaseTest.java index 7eda47a..50ad404 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/BaseTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/BaseTest.java @@ -2,8 +2,10 @@ /** * The only purpose of this class is to support the somewhat roundabout mechanism we use in CI to run - * all of our unit tests in an Android environment too. All unit tests in this project should have this - * as a base class. + * all of our unit tests in an Android environment too. All unit tests in this project should be either + * directly or indirectly descended from this class. Then, when we run the Android tests, we replace + * this class with another version (from src/androidTest/java) that has the necessary Android test + * runner annotation on it. */ -public abstract class BaseTest { +public abstract class BaseTest extends BaseInternalTest { } diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java index 0725e70..91d0f4a 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java @@ -8,7 +8,7 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.ObjectBuilder; -import com.launchdarkly.sdk.internal.BaseInternalTest; +import com.launchdarkly.sdk.internal.BaseTest; import com.launchdarkly.sdk.internal.http.HttpProperties; import com.launchdarkly.testhelpers.JsonAssertions; import com.launchdarkly.testhelpers.JsonTestValue; @@ -42,7 +42,7 @@ import static org.junit.Assert.assertNotNull; @SuppressWarnings("javadoc") -public abstract class BaseEventTest extends BaseInternalTest { +public abstract class BaseEventTest extends BaseTest { public static final String SDK_KEY = "SDK_KEY"; public static final long FAKE_TIME = 100000; public static final String FLAG_KEY = "flagkey"; diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticEventTest.java index dab6091..9eb8071 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticEventTest.java @@ -2,6 +2,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import com.launchdarkly.sdk.internal.BaseTest; import org.junit.Test; @@ -14,7 +15,7 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class DiagnosticEventTest { +public class DiagnosticEventTest extends BaseTest { private static List testStreamInits = Collections.singletonList(new DiagnosticEvent.StreamInit(1500, 100, true)); diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticIdTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticIdTest.java index b49e799..49f249d 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticIdTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticIdTest.java @@ -2,6 +2,7 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; +import com.launchdarkly.sdk.internal.BaseTest; import org.junit.Test; @@ -11,7 +12,7 @@ import static org.junit.Assert.assertNotNull; @SuppressWarnings("javadoc") -public class DiagnosticIdTest { +public class DiagnosticIdTest extends BaseTest { private static final Gson gson = new Gson(); @Test diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticStoreTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticStoreTest.java index 8297c3e..6e2b917 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticStoreTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticStoreTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.internal.events; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.internal.BaseTest; import com.launchdarkly.sdk.internal.events.DiagnosticStore.SdkDiagnosticParams; import org.junit.Test; @@ -23,7 +24,7 @@ import static org.junit.Assert.assertSame; @SuppressWarnings("javadoc") -public class DiagnosticStoreTest { +public class DiagnosticStoreTest extends BaseTest { private static final String SDK_KEY = "key-abcdefg"; private static final String SDK_NAME = "fake-sdk"; private static final String SDK_VERSION = "1.2.3"; diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java index dca4f90..02af7ee 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java @@ -6,6 +6,7 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.internal.BaseTest; import org.junit.Test; import org.junit.runner.RunWith; @@ -20,7 +21,7 @@ @SuppressWarnings("javadoc") @RunWith(Parameterized.class) -public class EventContextFormatterTest { +public class EventContextFormatterTest extends BaseTest { private final LDContext context; private final boolean allAttributesPrivate; private final AttributeRef[] globalPrivateAttributes; diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/EventSummarizerTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/EventSummarizerTest.java index cce28d4..8426512 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/EventSummarizerTest.java @@ -5,6 +5,7 @@ import com.launchdarkly.sdk.ContextKind; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.internal.BaseTest; import com.launchdarkly.sdk.internal.events.EventSummarizer.CounterValue; import com.launchdarkly.sdk.internal.events.EventSummarizer.EventSummary; import com.launchdarkly.sdk.internal.events.EventSummarizer.FlagInfo; @@ -21,7 +22,7 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class EventSummarizerTest { +public class EventSummarizerTest extends BaseTest { private static final LDContext context = LDContext.create("key"); @Test diff --git a/src/test/java/com/launchdarkly/sdk/internal/http/HttpPropertiesTest.java b/src/test/java/com/launchdarkly/sdk/internal/http/HttpPropertiesTest.java index 38a183c..9464598 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/http/HttpPropertiesTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/http/HttpPropertiesTest.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.internal.http; +import com.launchdarkly.sdk.internal.BaseTest; + import org.junit.Test; import static org.junit.Assert.assertEquals; @@ -7,7 +9,7 @@ import okhttp3.OkHttpClient; @SuppressWarnings("javadoc") -public class HttpPropertiesTest { +public class HttpPropertiesTest extends BaseTest { @Test public void testConnectTimeout() { HttpProperties hp = new HttpProperties( From 18421a2363239b7d15e58e81d9bd5867c7f90cce Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Aug 2022 18:16:26 -0700 Subject: [PATCH 15/57] copy correct version of test code --- .../DefaultEventProcessorOutputTest.java | 801 +++++++++--------- 1 file changed, 380 insertions(+), 421 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java index f0fe1dd..b993e25 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java @@ -1,423 +1,382 @@ package com.launchdarkly.sdk.internal.events; -//import com.launchdarkly.sdk.EvaluationDetail; -//import com.launchdarkly.sdk.EvaluationReason; -//import com.launchdarkly.sdk.LDContext; -//import com.launchdarkly.sdk.LDValue; -//import com.launchdarkly.sdk.internal.events.DefaultEventProcessor; -//import com.launchdarkly.sdk.internal.events.Event; -//import com.launchdarkly.sdk.internal.events.EventContextDeduplicator; -//import com.launchdarkly.sdk.internal.events.EventSender; -//import com.launchdarkly.sdk.server.DataModel; -// -//import org.hamcrest.Matchers; -//import org.junit.Test; -// -//import java.util.Date; -// -//import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; -//import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; -//import static org.hamcrest.MatcherAssert.assertThat; -//import static org.hamcrest.Matchers.allOf; -//import static org.hamcrest.Matchers.contains; -// -///** -// * These DefaultEventProcessor tests cover the specific content that should appear in event payloads. -// */ -//@SuppressWarnings("javadoc") -//public class DefaultEventProcessorOutputTest extends EventTestUtil { -// private static final LDContext invalidContext = LDContext.create(null); -// private static final String FLAG_KEY = "flagkey"; -// private static final int FLAG_VERSION = 11; -// private static final EvaluationDetail SOME_RESULT = EvaluationDetail.fromValue( -// LDValue.of("value"), 1, EvaluationReason.off()); -// -// // Note: context deduplication behavior has been abstracted out of DefaultEventProcessor, so that -// // by default it does not generate any index events. Test cases in this file that are not -// // specifically related to index events use this default behavior, and do not expect to see any. -// // When we are specifically testing this behavior, we substitute a mock EventContextDeduplicator -// // so we can verify how its outputs affect DefaultEventProcessor. -// -// @Test -// public void identifyEventIsQueued() throws Exception { -// MockEventSender es = new MockEventSender(); -// Event e = makeIdentifyEvent(user); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { -// ep.sendEvent(e); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// isIdentifyEvent(e, userJson) -// )); -// } -// -// @Test -// public void userIsFilteredInIdentifyEvent() throws Exception { -// MockEventSender es = new MockEventSender(); -// Event e = makeIdentifyEvent(user); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).allAttributesPrivate(true))) { -// ep.sendEvent(e); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// isIdentifyEvent(e, filteredUserJson) -// )); -// } -// -// @Test -// public void identifyEventWithNullContextOrInvalidContextDoesNotCauseError() throws Exception { -// // This should never happen because LDClient.identify() rejects such a user, but just in case, -// // we want to make sure it doesn't blow up the event processor. -// MockEventSender es = new MockEventSender(); -// Event event1 = makeIdentifyEvent(invalidContext); -// Event event2 = makeIdentifyEvent(null); -// Event event3 = makeIdentifyEvent(user); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { -// ep.sendEvent(event1); -// ep.sendEvent(event2); -// ep.sendEvent(event3); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// isIdentifyEvent(event3, userJson) -// )); -// } -// -// @SuppressWarnings("unchecked") -// @Test -// public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { -// MockEventSender es = new MockEventSender(); -// Event.FeatureRequest fe = makeFeatureRequestEvent(FLAG_KEY, user, FLAG_VERSION, -// SOME_RESULT, LDValue.ofNull()); -// -// EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatAlwaysSaysKeysAreNew(); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).contextDeduplicator(contextDeduplicator))) { -// ep.sendEvent(fe); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// isIndexEvent(fe, userJson), -// isFeatureEvent(fe, FLAG_KEY, FLAG_VERSION, false, null), -// isSummaryEvent() -// )); -// } -// -// @SuppressWarnings("unchecked") -// @Test -// public void userIsFilteredInIndexEvent() throws Exception { -// MockEventSender es = new MockEventSender(); -// Event.FeatureRequest fe = makeFeatureRequestEvent(FLAG_KEY, user, FLAG_VERSION, -// SOME_RESULT, LDValue.ofNull()); -// -// EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatAlwaysSaysKeysAreNew(); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).allAttributesPrivate(true).contextDeduplicator(contextDeduplicator))) { -// ep.sendEvent(fe); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// isIndexEvent(fe, filteredUserJson), -// isFeatureEvent(fe, flag, false, null), -// isSummaryEvent() -// )); -// } -// -// @SuppressWarnings("unchecked") -// @Test -// public void featureEventCanBeForPrerequisite() throws Exception { -// MockEventSender es = new MockEventSender(); -// DataModel.FeatureFlag mainFlag = flagBuilder("flagkey").version(11).build(); -// DataModel.FeatureFlag prereqFlag = flagBuilder("prereqkey").version(12).trackEvents(true).build(); -// Event.FeatureRequest fe = makePrerequisiteEvent(prereqFlag, user, -// simpleEvaluation(1, LDValue.of("value")), -// mainFlag); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { -// ep.sendEvent(fe); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// allOf(isFeatureEvent(fe, prereqFlag, false, null), isPrerequisiteOf(mainFlag.getKey())), -// isSummaryEvent() -// )); -// } -// -// @Test -// public void featureEventWithNullContextOrInvalidContextIsIgnored() throws Exception { -// // This should never happen because LDClient rejects such a user, but just in case, -// // we want to make sure it doesn't blow up the event processor. -// MockEventSender es = new MockEventSender(); -// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).build(); -// Event.FeatureRequest event1 = makeFeatureRequestEvent(flag, invalidContext, -// simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); -// Event.FeatureRequest event2 = makeFeatureRequestEvent(flag, null, -// simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es) -// .allAttributesPrivate(true))) { -// ep.sendEvent(event1); -// ep.sendEvent(event2); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// isSummaryEvent() -// )); -// } -// -// @SuppressWarnings("unchecked") -// @Test -// public void featureEventCanContainReason() throws Exception { -// MockEventSender es = new MockEventSender(); -// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); -// EvaluationReason reason = EvaluationReason.ruleMatch(1, null); -// Event.FeatureRequest fe = makeFeatureRequestEvent(flag, user, -// EvalResult.of(LDValue.of("value"), 1, reason), LDValue.ofNull(), true); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { -// ep.sendEvent(fe); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// isFeatureEvent(fe, flag, false, null, reason), -// isSummaryEvent() -// )); -// } -// -// @SuppressWarnings("unchecked") -// @Test -// public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { -// MockEventSender es = new MockEventSender(); -// long futureTime = System.currentTimeMillis() + 1000000; -// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); -// Event.FeatureRequest fe = makeFeatureRequestEvent(flag, user, -// simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { -// ep.sendEvent(fe); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// isFeatureEvent(fe, flag, true, userJson), -// isSummaryEvent() -// )); -// } -// -// @SuppressWarnings("unchecked") -// @Test -// public void eventCanBeBothTrackedAndDebugged() throws Exception { -// MockEventSender es = new MockEventSender(); -// long futureTime = System.currentTimeMillis() + 1000000; -// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true) -// .debugEventsUntilDate(futureTime).build(); -// Event.FeatureRequest fe = makeFeatureRequestEvent(flag, user, -// simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { -// ep.sendEvent(fe); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// isFeatureEvent(fe, flag, false, null), -// isFeatureEvent(fe, flag, true, userJson), -// isSummaryEvent() -// )); -// } -// -// @Test -// public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() throws Exception { -// MockEventSender es = new MockEventSender(); -// -// // Pick a server time that is somewhat behind the client time -// long serverTime = System.currentTimeMillis() - 20000; -// es.result = new EventSender.Result(true, false, new Date(serverTime)); -// -// long debugUntil = serverTime + 1000; -// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); -// Event.FeatureRequest fe = makeFeatureRequestEvent(flag, user, -// simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { -// // Send and flush an event we don't care about, just so we'll receive "resp1" which sets the last server time -// ep.sendEvent(new Event.Identify(FAKE_TIME, LDContext.create("otherUser"))); -// ep.flush(); -// ep.waitUntilInactive(); // this ensures that it has received the first response, with the date -// -// es.receivedParams.clear(); -// es.result = new EventSender.Result(true, false, null); -// -// // Now send an event with debug mode on, with a "debug until" time that is further in -// // the future than the server time, but in the past compared to the client. -// ep.sendEvent(fe); -// } -// -// // Should get a summary event only, not a full feature event -// assertThat(es.getEventsFromLastRequest(), contains( -// isSummaryEvent(fe.getCreationDate(), fe.getCreationDate()) -// )); -// } -// -// @Test -// public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() throws Exception { -// MockEventSender es = new MockEventSender(); -// -// // Pick a server time that is somewhat ahead of the client time -// long serverTime = System.currentTimeMillis() + 20000; -// es.result = new EventSender.Result(true, false, new Date(serverTime)); -// -// long debugUntil = serverTime - 1000; -// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); -// Event.FeatureRequest fe = makeFeatureRequestEvent(flag, user, -// simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { -// // Send and flush an event we don't care about, just to set the last server time -// ep.sendEvent(makeIdentifyEvent(LDContext.create("otherUser"))); -// ep.flush(); -// ep.waitUntilInactive(); // this ensures that it has received the first response, with the date -// -// es.receivedParams.clear(); -// es.result = new EventSender.Result(true, false, null); -// -// // Now send an event with debug mode on, with a "debug until" time that is further in -// // the future than the client time, but in the past compared to the server. -// ep.sendEvent(fe); -// } -// -// // Should get a summary event only, not a full feature event -// assertThat(es.getEventsFromLastRequest(), contains( -// isSummaryEvent(fe.getCreationDate(), fe.getCreationDate()) -// )); -// } -// -// @SuppressWarnings("unchecked") -// @Test -// public void twoFeatureEventsForSameContextGenerateOnlyOneIndexEvent() throws Exception { -// // More accurately, this is testing that DefaultEventProcessor respects whatever the -// // EventContextDeduplicator says about whether a context key is new or not. We will set up -// // an EventContextDeduplicator that reports "new" on the first call and "not new" on the 2nd. -// EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatSaysKeyIsNewOnFirstCallOnly(); -// -// MockEventSender es = new MockEventSender(); -// DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); -// DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).trackEvents(true).build(); -// LDValue value = LDValue.of("value"); -// Event.FeatureRequest fe1 = makeFeatureRequestEvent(flag1, user, -// simpleEvaluation(1, value), LDValue.ofNull()); -// Event.FeatureRequest fe2 = makeFeatureRequestEvent(flag2, user, -// simpleEvaluation(1, value), LDValue.ofNull()); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).contextDeduplicator(contextDeduplicator))) { -// ep.sendEvent(fe1); -// ep.sendEvent(fe2); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// isIndexEvent(fe1, userJson), -// isFeatureEvent(fe1, flag1, false, null), -// isFeatureEvent(fe2, flag2, false, null), -// isSummaryEvent(fe1.getCreationDate(), fe2.getCreationDate()) -// )); -// } -// -// @SuppressWarnings("unchecked") -// @Test -// public void identifyEventMakesIndexEventUnnecessary() throws Exception { -// MockEventSender es = new MockEventSender(); -// Event ie = new Event.Identify(FAKE_TIME, user); -// DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); -// Event.FeatureRequest fe = makeFeatureRequestEvent(flag, user, -// simpleEvaluation(1, LDValue.of("value")), null); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { -// ep.sendEvent(ie); -// ep.sendEvent(fe); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// isIdentifyEvent(ie, userJson), -// isFeatureEvent(fe, flag, false, null), -// isSummaryEvent() -// )); -// } -// -// -// @SuppressWarnings("unchecked") -// @Test -// public void nonTrackedEventsAreSummarized() throws Exception { -// MockEventSender es = new MockEventSender(); -// DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).build(); -// DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).build(); -// LDValue value1 = LDValue.of("value1"); -// LDValue value2 = LDValue.of("value2"); -// LDValue default1 = LDValue.of("default1"); -// LDValue default2 = LDValue.of("default2"); -// Event fe1a = makeFeatureRequestEvent(flag1, user, -// simpleEvaluation(1, value1), default1); -// Event fe1b = makeFeatureRequestEvent(flag1, user, -// simpleEvaluation(1, value1), default1); -// Event fe1c = makeFeatureRequestEvent(flag1, user, -// simpleEvaluation(2, value2), default1); -// Event fe2 = makeFeatureRequestEvent(flag2, user, -// simpleEvaluation(2, value2), default2); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { -// ep.sendEvent(fe1a); -// ep.sendEvent(fe1b); -// ep.sendEvent(fe1c); -// ep.sendEvent(fe2); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// allOf( -// isSummaryEvent(fe1a.getCreationDate(), fe2.getCreationDate()), -// hasSummaryFlag(flag1.getKey(), default1, -// Matchers.containsInAnyOrder( -// isSummaryEventCounter(flag1, 1, value1, 2), -// isSummaryEventCounter(flag1, 2, value2, 1) -// )), -// hasSummaryFlag(flag2.getKey(), default2, -// contains(isSummaryEventCounter(flag2, 2, value2, 1))) -// ) -// )); -// } -// -// @Test -// public void customEventIsQueuedWithUser() throws Exception { -// MockEventSender es = new MockEventSender(); -// LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); -// double metric = 1.5; -// Event.Custom ce = new Event.Custom(FAKE_TIME, "eventkey", user, data, metric); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { -// ep.sendEvent(ce); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// isCustomEvent(ce) -// )); -// } -// -// @Test -// public void customEventWithNullContextOrInvalidContextDoesNotCauseError() throws Exception { -// // This should never happen because LDClient rejects such a user, but just in case, -// // we want to make sure it doesn't blow up the event processor. -// MockEventSender es = new MockEventSender(); -// Event.Custom event1 = new Event.Custom(FAKE_TIME, "eventkey", invalidContext, null, null); -// Event.Custom event2 = new Event.Custom(FAKE_TIME, "eventkey", null, null, null); -// Event.Custom event3 = new Event.Custom(FAKE_TIME, "eventkey", user, null, null); -// -// try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { -// ep.sendEvent(event1); -// ep.sendEvent(event2); -// ep.sendEvent(event3); -// } -// -// assertThat(es.getEventsFromLastRequest(), contains( -// isCustomEvent(event3) -// )); -// } -//} +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; + +import org.hamcrest.Matchers; +import org.junit.Test; + +import java.util.Date; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.contains; + +/** + * These DefaultEventProcessor tests cover the specific content that should appear in event payloads. + */ +@SuppressWarnings("javadoc") +public class DefaultEventProcessorOutputTest extends BaseEventTest { + private static final LDContext invalidContext = LDContext.create(null); + + // Note: context deduplication behavior has been abstracted out of DefaultEventProcessor, so that + // by default it does not generate any index events. Test cases in this file that are not + // specifically related to index events use this default behavior, and do not expect to see any. + // When we are specifically testing this behavior, we substitute a mock EventContextDeduplicator + // so we can verify how its outputs affect DefaultEventProcessor. + + @Test + public void identifyEventIsQueued() throws Exception { + MockEventSender es = new MockEventSender(); + Event e = identifyEvent(user); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(e); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isIdentifyEvent(e, userJson) + )); + } + + @Test + public void userIsFilteredInIdentifyEvent() throws Exception { + MockEventSender es = new MockEventSender(); + Event e = identifyEvent(user); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).allAttributesPrivate(true))) { + ep.sendEvent(e); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isIdentifyEvent(e, filteredUserJson) + )); + } + + @Test + public void identifyEventWithNullContextOrInvalidContextDoesNotCauseError() throws Exception { + // This should never happen because LDClient.identify() rejects such a user, but just in case, + // we want to make sure it doesn't blow up the event processor. + MockEventSender es = new MockEventSender(); + Event event1 = identifyEvent(invalidContext); + Event event2 = identifyEvent(null); + Event event3 = identifyEvent(user); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(event1); + ep.sendEvent(event2); + ep.sendEvent(event3); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isIdentifyEvent(event3, userJson) + )); + } + + @SuppressWarnings("unchecked") + @Test + public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { + MockEventSender es = new MockEventSender(); + Event.FeatureRequest fe = featureEvent(user, FLAG_KEY).trackEvents(true).build(); + + EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatAlwaysSaysKeysAreNew(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).contextDeduplicator(contextDeduplicator))) { + ep.sendEvent(fe); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe, userJson), + isFeatureEvent(fe), + isSummaryEvent() + )); + } + + @SuppressWarnings("unchecked") + @Test + public void userIsFilteredInIndexEvent() throws Exception { + MockEventSender es = new MockEventSender(); + Event.FeatureRequest fe = featureEvent(user, FLAG_KEY).build(); + + EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatAlwaysSaysKeysAreNew(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).allAttributesPrivate(true).contextDeduplicator(contextDeduplicator))) { + ep.sendEvent(fe); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe, filteredUserJson), + isSummaryEvent() + )); + } + + @SuppressWarnings("unchecked") + @Test + public void featureEventCanBeForPrerequisite() throws Exception { + MockEventSender es = new MockEventSender(); + String prereqKey = "prereqkey"; + Event.FeatureRequest fe = featureEvent(user, prereqKey).prereqOf(FLAG_KEY).trackEvents(true).build(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(fe); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isFeatureEvent(fe), + isSummaryEvent() + )); + } + + @Test + public void featureEventWithNullContextOrInvalidContextIsIgnored() throws Exception { + // This should never happen because LDClient rejects such a user, but just in case, + // we want to make sure it doesn't blow up the event processor. + MockEventSender es = new MockEventSender(); + Event.FeatureRequest event1 = featureEvent(invalidContext, FLAG_KEY).build(); + Event.FeatureRequest event2 = featureEvent(null, FLAG_KEY).build(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es) + .allAttributesPrivate(true))) { + ep.sendEvent(event1); + ep.sendEvent(event2); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isSummaryEvent() + )); + } + + @SuppressWarnings("unchecked") + @Test + public void featureEventCanContainReason() throws Exception { + MockEventSender es = new MockEventSender(); + EvaluationReason reason = EvaluationReason.ruleMatch(1, null); + Event.FeatureRequest fe = featureEvent(user, FLAG_KEY).reason(reason).trackEvents(true).build(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(fe); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isFeatureEvent(fe), + isSummaryEvent() + )); + } + + @SuppressWarnings("unchecked") + @Test + public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { + MockEventSender es = new MockEventSender(); + long futureTime = System.currentTimeMillis() + 1000000; + Event.FeatureRequest fe = featureEvent(user, FLAG_KEY).debugEventsUntilDate(futureTime).build(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(fe); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isDebugEvent(fe, userJson), + isSummaryEvent() + )); + } + + @SuppressWarnings("unchecked") + @Test + public void eventCanBeBothTrackedAndDebugged() throws Exception { + MockEventSender es = new MockEventSender(); + long futureTime = System.currentTimeMillis() + 1000000; + Event.FeatureRequest fe = featureEvent(user, FLAG_KEY).trackEvents(true).debugEventsUntilDate(futureTime).build(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(fe); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isFeatureEvent(fe), + isDebugEvent(fe, userJson), + isSummaryEvent() + )); + } + + @Test + public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() throws Exception { + MockEventSender es = new MockEventSender(); + + // Pick a server time that is somewhat behind the client time + long serverTime = System.currentTimeMillis() - 20000; + es.result = new EventSender.Result(true, false, new Date(serverTime)); + + long debugUntil = serverTime + 1000; + Event.FeatureRequest fe = featureEvent(user, FLAG_KEY).debugEventsUntilDate(debugUntil).build(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + // Send and flush an event we don't care about, just so we'll receive "resp1" which sets the last server time + ep.sendEvent(identifyEvent(LDContext.create("otherUser"))); + ep.flush(); + ep.waitUntilInactive(); // this ensures that it has received the first response, with the date + + es.receivedParams.clear(); + es.result = new EventSender.Result(true, false, null); + + // Now send an event with debug mode on, with a "debug until" time that is further in + // the future than the server time, but in the past compared to the client. + ep.sendEvent(fe); + } + + // Should get a summary event only, not a full feature event + assertThat(es.getEventsFromLastRequest(), contains( + isSummaryEvent(fe.getCreationDate(), fe.getCreationDate()) + )); + } + + @Test + public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() throws Exception { + MockEventSender es = new MockEventSender(); + + // Pick a server time that is somewhat ahead of the client time + long serverTime = System.currentTimeMillis() + 20000; + es.result = new EventSender.Result(true, false, new Date(serverTime)); + + long debugUntil = serverTime - 1000; + Event.FeatureRequest fe = featureEvent(user, FLAG_KEY).debugEventsUntilDate(debugUntil).build(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + // Send and flush an event we don't care about, just to set the last server time + ep.sendEvent(identifyEvent(LDContext.create("otherUser"))); + ep.flush(); + ep.waitUntilInactive(); // this ensures that it has received the first response, with the date + + es.receivedParams.clear(); + es.result = new EventSender.Result(true, false, null); + + // Now send an event with debug mode on, with a "debug until" time that is further in + // the future than the client time, but in the past compared to the server. + ep.sendEvent(fe); + } + + // Should get a summary event only, not a full feature event + assertThat(es.getEventsFromLastRequest(), contains( + isSummaryEvent(fe.getCreationDate(), fe.getCreationDate()) + )); + } + + @SuppressWarnings("unchecked") + @Test + public void twoFeatureEventsForSameContextGenerateOnlyOneIndexEvent() throws Exception { + // More accurately, this is testing that DefaultEventProcessor respects whatever the + // EventContextDeduplicator says about whether a context key is new or not. We will set up + // an EventContextDeduplicator that reports "new" on the first call and "not new" on the 2nd. + EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatSaysKeyIsNewOnFirstCallOnly(); + + MockEventSender es = new MockEventSender(); + Event.FeatureRequest fe1 = featureEvent(user, "flagkey1").trackEvents(true).build(); + Event.FeatureRequest fe2 = featureEvent(user, "flagkey2").trackEvents(true).build(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).contextDeduplicator(contextDeduplicator))) { + ep.sendEvent(fe1); + ep.sendEvent(fe2); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe1, userJson), + isFeatureEvent(fe1), + isFeatureEvent(fe2), + isSummaryEvent(fe1.getCreationDate(), fe2.getCreationDate()) + )); + } + + @SuppressWarnings("unchecked") + @Test + public void identifyEventMakesIndexEventUnnecessary() throws Exception { + MockEventSender es = new MockEventSender(); + Event ie = new Event.Identify(FAKE_TIME, user); + Event.FeatureRequest fe = featureEvent(user, FLAG_KEY).trackEvents(true).build(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(ie); + ep.sendEvent(fe); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isIdentifyEvent(ie, userJson), + isFeatureEvent(fe), + isSummaryEvent() + )); + } + + + @SuppressWarnings("unchecked") + @Test + public void nonTrackedEventsAreSummarized() throws Exception { + MockEventSender es = new MockEventSender(); + String flagkey1 = "flagkey1", flagkey2 = "flagkey2"; + int version1 = 11, version2 = 22; + LDValue value1 = LDValue.of("value1"), value2 = LDValue.of("value2"); + LDValue default1 = LDValue.of("default1"), default2 = LDValue.of("default2"); + Event fe1a = featureEvent(user, flagkey1).flagVersion(version1) + .variation(1).value(value1).defaultValue(default1).build(); + Event fe1b = featureEvent(user, flagkey1).flagVersion(version1) + .variation(1).value(value1).defaultValue(default1).build(); + Event fe1c = featureEvent(user, flagkey1).flagVersion(version1) + .variation(2).value(value2).defaultValue(default1).build(); + Event fe2 = featureEvent(user, flagkey2).flagVersion(version2) + .variation(2).value(value2).defaultValue(default2).build(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(fe1a); + ep.sendEvent(fe1b); + ep.sendEvent(fe1c); + ep.sendEvent(fe2); + } + + assertThat(es.getEventsFromLastRequest(), contains( + allOf( + isSummaryEvent(fe1a.getCreationDate(), fe2.getCreationDate()), + hasSummaryFlag(flagkey1, default1, + Matchers.containsInAnyOrder( + isSummaryEventCounter(version1, 1, value1, 2), + isSummaryEventCounter(version1, 2, value2, 1) + )), + hasSummaryFlag(flagkey2, default2, + contains(isSummaryEventCounter(version2, 2, value2, 1))) + ) + )); + } + + @Test + public void customEventIsQueuedWithUser() throws Exception { + MockEventSender es = new MockEventSender(); + LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); + double metric = 1.5; + Event.Custom ce = customEvent(user, "eventkey").data(data).metricValue(metric).build(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(ce); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isCustomEvent(ce) + )); + } + + @Test + public void customEventWithNullContextOrInvalidContextDoesNotCauseError() throws Exception { + // This should never happen because LDClient rejects such a user, but just in case, + // we want to make sure it doesn't blow up the event processor. + MockEventSender es = new MockEventSender(); + Event.Custom event1 = customEvent(invalidContext, "eventkey").build(); + Event.Custom event2 = customEvent(null, "eventkey").build(); + Event.Custom event3 = customEvent(user, "eventkey").build(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(event1); + ep.sendEvent(event2); + ep.sendEvent(event3); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isCustomEvent(event3) + )); + } +} From 413e97d6af7e7fa5996322157a37bcb35938f1f0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Aug 2022 18:30:53 -0700 Subject: [PATCH 16/57] disable test coverage enforcement for now --- .circleci/config.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2cb624d..71c7599 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -75,9 +75,11 @@ jobs: ./gradlew jacocoTestReport mkdir -p coverage/ cp -r build/reports/jacoco/test/* ./coverage - - run: - name: Enforce test coverage - command: ./gradlew jacocoTestCoverageVerification + # Test coverage enforcement is temporarily disabled because the test code that was + # moved from java-server-sdk did not have a 100% coverage requirement. + # - run: + # name: Enforce test coverage + # command: ./gradlew jacocoTestCoverageVerification - run: name: Save test results command: | From d9efa50993ec0f97cab2f93f4c7a45e39b90e6ca Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Aug 2022 18:44:05 -0700 Subject: [PATCH 17/57] can't use HTTP test helpers in Android currently --- build-android.gradle | 9 +++++++++ build.gradle.kts | 3 +++ buildSrc/src/main/kotlin/Dependencies.kt | 6 ++++-- .../sdk/internal/BaseInternalTest.java | 15 +++++++++++++++ .../internal/events/DefaultEventSenderTest.java | 8 ++++++++ 5 files changed, 39 insertions(+), 2 deletions(-) diff --git a/build-android.gradle b/build-android.gradle index d5a7490..522ac82 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -66,4 +66,13 @@ dependencies { // see Dependencies.kt in buildSrc Libs.implementation.each { implementation(it) } Libs.javaTestImplementation.forEach { testImplementation(it) } Libs.androidTestImplementation.each { androidTestImplementation(it) } + + // The HTTP tools in com.launchdarkly:test-helpers currently use a Jetty-based + // implementation that doesn't work in Android. Until we have a better solution, + // we need to exclude the Jetty-related content from this dependency, and not + // try to run the tests that use those tools in Android. + androidTestImplementation("com.launchdarkly:test-helpers:${Versions.testHelpers}") { + exclude group: "org.eclipse.jetty" // the HTTP helpers currently don't work in Android + exclude group: "com.squareup.okhttp3" + } } diff --git a/build.gradle.kts b/build.gradle.kts index 50c75d6..4a18ffd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,6 +44,9 @@ java { dependencies { // see Dependencies.kt in buildSrc Libs.implementation.forEach { implementation(it)} Libs.javaTestImplementation.forEach { testImplementation(it) } + + testImplementation("com.launchdarkly:test-helpers:${Versions.testHelpers}") + // see build-android.gradle about the reason for special-casing this } checkstyle { diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 5b3ed02..94a44b1 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -9,6 +9,7 @@ object Versions { const val launchdarklyLogging = "1.1.1" const val okhttp = "4.9.1" const val slf4j = "1.7.21" + const val testHelpers = "1.2.0" } object PluginVersions { @@ -30,8 +31,9 @@ object Libs { val javaTestImplementation = listOf( "junit:junit:4.12", - "org.hamcrest:hamcrest-library:1.3", - "com.launchdarkly:test-helpers:1.2.0" + "org.hamcrest:hamcrest-library:1.3" + // "com.launchdarkly:test-helpers:${Versions.testHelpers}" + // test-helpers is special-cased in build.gradle.kts and build-android.gradle ) val androidTestImplementation = javaTestImplementation + listOf( diff --git a/src/test/java/com/launchdarkly/sdk/internal/BaseInternalTest.java b/src/test/java/com/launchdarkly/sdk/internal/BaseInternalTest.java index 932256d..5dec117 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/BaseInternalTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/BaseInternalTest.java @@ -9,6 +9,8 @@ import org.junit.rules.TestWatcher; import org.junit.runner.Description; +import static org.junit.Assume.assumeFalse; + @SuppressWarnings("javadoc") public class BaseInternalTest { @Rule public DumpLogIfTestFails dumpLogIfTestFails; @@ -18,12 +20,25 @@ public class BaseInternalTest { protected final LogCapture logCapture; protected BaseInternalTest() { + if (!enableTestInAndroid()) { + assumeFalse("skipping test that isn't compatible with Android", isInAndroid()); + } logCapture = Logs.capture(); testLogging = logCapture; testLogger = LDLogger.withAdapter(testLogging, ""); dumpLogIfTestFails = new DumpLogIfTestFails(); } + protected boolean enableTestInAndroid() { + // Override this for tests that currently cannot run in our Android CI test job. + return true; + } + + protected boolean isInAndroid() { + String javaVendor = System.getProperty("java.vendor"); + return javaVendor != null && javaVendor.contains("Android"); + } + class DumpLogIfTestFails extends TestWatcher { @Override protected void failed(Throwable e, Description description) { diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java index 42cbc97..cbca6d9 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java @@ -36,6 +36,14 @@ public class DefaultEventSenderTest extends BaseEventTest { private static final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US); private static final long BRIEF_RETRY_DELAY_MILLIS = 50; + + @Override + protected boolean enableTestInAndroid() { + // Currently our use of com.launchdarkly.testhelpers.httptest makes this test file + // unusable in our Android CI test job. This is the only test file in the events + // package that performs end-to-end HTTP. + return false; + } private EventSender makeEventSender() { return makeEventSender(defaultHttpProperties()); From 0f02a42fa7b6c59d763aec5624150d06cb4abf87 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Aug 2022 18:55:13 -0700 Subject: [PATCH 18/57] change Android test configuration to match what we're doing in android-client-sdk-private --- build-android.gradle | 26 +++++++++++++++++------- buildSrc/src/main/kotlin/Dependencies.kt | 7 +++++-- src/androidTest/AndroidManifest.xml | 6 +++++- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/build-android.gradle b/build-android.gradle index 522ac82..11c1618 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -1,5 +1,3 @@ -apply plugin: 'com.android.library' -//apply plugin: 'com.github.dcendents.android-maven' buildscript { repositories { @@ -15,6 +13,11 @@ buildscript { // that the project is Android-compatible. We do not publish an Android build - that is done in // the android-client-sdk project. +plugins { + id("com.android.library") + id("com.getkeepsafe.dexcount") +} + repositories { mavenLocal() // Before LaunchDarkly release artifacts get synced to Maven Central they are here along with snapshots: @@ -34,11 +37,13 @@ android { buildToolsVersion '28.0.3' defaultConfig { - minSdkVersion 16 + minSdkVersion 21 targetSdkVersion 26 versionCode 1 versionName version - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles 'consumer-proguard-rules.pro' // The following argument makes the Android Test Orchestrator run its @@ -52,14 +57,19 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + packagingOptions { exclude 'META-INF/**' exclude 'META-INF/**' } - dexOptions { - javaMaxHeapSize "4g" - } + useLibrary("android.test.runner") + useLibrary("android.test.base") + useLibrary("android.test.mock") } dependencies { // see Dependencies.kt in buildSrc @@ -75,4 +85,6 @@ dependencies { // see Dependencies.kt in buildSrc exclude group: "org.eclipse.jetty" // the HTTP helpers currently don't work in Android exclude group: "com.squareup.okhttp3" } + + androidTestUtil("androidx.test:orchestrator:1.4.1") } diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 94a44b1..938f92d 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -36,8 +36,11 @@ object Libs { // test-helpers is special-cased in build.gradle.kts and build-android.gradle ) - val androidTestImplementation = javaTestImplementation + listOf( - "com.android.support.test:runner:1.0.2" + val androidTestImplementation = javaTestImplementation + listOf( + "androidx.test:core:1.4.0", + "androidx.test:runner:1.4.0", + "androidx.test:rules:1.4.0", + "androidx.test.ext:junit:1.1.3" ) val javaBuiltInGradlePlugins = listOf( diff --git a/src/androidTest/AndroidManifest.xml b/src/androidTest/AndroidManifest.xml index 3824bf1..6a528ee 100644 --- a/src/androidTest/AndroidManifest.xml +++ b/src/androidTest/AndroidManifest.xml @@ -9,6 +9,10 @@ + android:targetPackage="com.launchdarkly.sdk.internal.events" /> + + \ No newline at end of file From 4794aaa43c510815c2187791a93e3b25dbae0465 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Aug 2022 19:13:42 -0700 Subject: [PATCH 19/57] more Android test fixes --- build-android.gradle | 20 +++++++------------ gradle.properties | 3 +++ .../launchdarkly/sdk/internal/BaseTest.java | 2 +- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/build-android.gradle b/build-android.gradle index 11c1618..e3bb4c2 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -1,3 +1,5 @@ +apply plugin: "com.android.library" // for some reason Gradle complains if this is in the plugins section +//apply plugin: "com.getkeepsafe.dexcount" buildscript { repositories { @@ -13,11 +15,6 @@ buildscript { // that the project is Android-compatible. We do not publish an Android build - that is done in // the android-client-sdk project. -plugins { - id("com.android.library") - id("com.getkeepsafe.dexcount") -} - repositories { mavenLocal() // Before LaunchDarkly release artifacts get synced to Maven Central they are here along with snapshots: @@ -33,14 +30,12 @@ allprojects { } android { - compileSdkVersion 26 - buildToolsVersion '28.0.3' + compileSdkVersion(30) + buildToolsVersion "30.0.3" defaultConfig { - minSdkVersion 21 - targetSdkVersion 26 - versionCode 1 - versionName version + minSdkVersion(21) + targetSdkVersion(30) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -64,7 +59,6 @@ android { packagingOptions { exclude 'META-INF/**' - exclude 'META-INF/**' } useLibrary("android.test.runner") @@ -85,6 +79,6 @@ dependencies { // see Dependencies.kt in buildSrc exclude group: "org.eclipse.jetty" // the HTTP helpers currently don't work in Android exclude group: "com.squareup.okhttp3" } - + androidTestUtil("androidx.test:orchestrator:1.4.1") } diff --git a/gradle.properties b/gradle.properties index 62092fd..703b346 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,3 +3,6 @@ version=1.0.0 # and should not be needed for typical development purposes (including by third-party developers). ossrhUsername= ossrhPassword= + +# Used only in Android CI test build: +android.useAndroidX=true diff --git a/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java b/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java index f3cb3c4..af7768b 100644 --- a/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java +++ b/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java @@ -1,6 +1,6 @@ package com.launchdarkly.sdk.internal; -import android.support.test.runner.AndroidJUnit4; +import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.runner.RunWith; /** From cd4859fd4bb44a2708a54bf070e47b3a7ffa2a21 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Aug 2022 19:26:50 -0700 Subject: [PATCH 20/57] store more test result files --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 71c7599..c572f06 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -155,3 +155,4 @@ jobs: - store_test_results: path: ./build/outputs/androidTest-results + path: ./build/reports/androidTests/connected From 66b41467c200c370e3829df12bedeb78d2a1acbe Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Aug 2022 19:28:30 -0700 Subject: [PATCH 21/57] fix CI --- .circleci/config.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index c572f06..ed5cc27 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -155,4 +155,6 @@ jobs: - store_test_results: path: ./build/outputs/androidTest-results + + - store_test_results: path: ./build/reports/androidTests/connected From f54bc1f7abdb206856555857b1bb39ac1073084a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 2 Aug 2022 10:50:08 -0700 Subject: [PATCH 22/57] don't use Hamcrest matcher "samePropertyValuesAs" which isn't supported in Android --- .../events/DefaultEventProcessorDiagnosticsTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java index 753d822..c5dee57 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java @@ -9,7 +9,6 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.samePropertyValuesAs; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -60,7 +59,8 @@ public void initialDiagnosticEventHasInitBody() throws Exception { assertNotNull(initEvent); assertThat(initEvent.kind, equalTo("diagnostic-init")); - assertThat(initEvent.id, samePropertyValuesAs(diagnosticId)); + assertThat(initEvent.id.diagnosticId, equalTo(diagnosticId.diagnosticId)); + assertThat(initEvent.id.sdkKeySuffix, equalTo(diagnosticId.sdkKeySuffix)); assertNotNull(initEvent.configuration); assertNotNull(initEvent.sdk); assertNotNull(initEvent.platform); @@ -82,7 +82,8 @@ public void periodicDiagnosticEventHasStatisticsBody() throws Exception { assertNotNull(statsEvent); assertThat(statsEvent.kind, equalTo("diagnostic")); - assertThat(statsEvent.id, samePropertyValuesAs(diagnosticId)); + assertThat(statsEvent.id.diagnosticId, equalTo(diagnosticId.diagnosticId)); + assertThat(statsEvent.id.sdkKeySuffix, equalTo(diagnosticId.sdkKeySuffix)); assertThat(statsEvent.dataSinceDate, equalTo(dataSinceDate)); assertThat(statsEvent.creationDate, equalTo(diagnosticStore.getDataSinceDate())); assertThat(statsEvent.deduplicatedUsers, equalTo(0L)); From 2213a732c07104ada64f03ab87af54f3cad24616 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 2 Aug 2022 11:05:46 -0700 Subject: [PATCH 23/57] fix tests --- .../internal/events/DefaultEventProcessorDiagnosticsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java index c5dee57..3cdde56 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java @@ -23,7 +23,6 @@ public class DefaultEventProcessorDiagnosticsTest extends BaseEventTest { private DiagnosticStore diagnosticStore; public DefaultEventProcessorDiagnosticsTest() { - diagnosticId = new DiagnosticId(SDK_KEY); diagnosticStore = new DiagnosticStore( new DiagnosticStore.SdkDiagnosticParams( SDK_KEY, @@ -34,6 +33,7 @@ public DefaultEventProcessorDiagnosticsTest() { null, null )); + diagnosticId = diagnosticStore.getDiagnosticId(); } @Test From 7c63d629f165c75c105f058e80b510670e0a3a57 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 2 Aug 2022 11:44:11 -0700 Subject: [PATCH 24/57] can't use nulls in parameterized tests due to Android bug --- .../internal/events/EventContextFormatterTest.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java index 02af7ee..2d1511a 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java @@ -40,6 +40,9 @@ public EventContextFormatterTest( this.expectedJson = expectedJson; } + // Note, due to a known issue with the Android test orchestrator (https://github.com/android/android-test/issues/837), + // none of the parameters for this parameterized test can be null. + @Parameterized.Parameters(name = "{0}") public static Iterable data() { return ImmutableList.of( @@ -50,7 +53,7 @@ public static Iterable data() { .set("attr1", "value1") .build(), false, - null, + new AttributeRef[0], "{\"kind\": \"org\", \"key\": \"my-key\", \"name\": \"my-name\", \"attr1\": \"value1\"}" }, new Object[] { @@ -65,7 +68,7 @@ public static Iterable data() { .build() ), false, - null, + new AttributeRef[0], "{" + "\"kind\": \"multi\"," + "\"org\": {\"key\": \"org-key\", \"name\": \"org-name\"}," + @@ -76,14 +79,14 @@ public static Iterable data() { "anonymous", LDContext.builder("my-key").kind("org").anonymous(true).build(), false, - null, + new AttributeRef[0], "{\"kind\": \"org\", \"key\": \"my-key\", \"anonymous\": true}" }, new Object[] { "secondary", LDContext.builder("my-key").kind("org").secondary("x").build(), false, - null, + new AttributeRef[0], "{\"kind\": \"org\", \"key\": \"my-key\", \"_meta\": {\"secondary\": \"x\"}}" }, new Object[] { @@ -93,7 +96,7 @@ public static Iterable data() { .set("attr1", "value1") .build(), true, - null, + new AttributeRef[0], "{" + "\"kind\": \"org\"," + "\"key\": \"my-key\"," + From 83b18bf368dcef6b7a72351fe77ed6eeacdd4d89 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 2 Aug 2022 12:00:43 -0700 Subject: [PATCH 25/57] revise Android stuff to do things more like we're doing them in android-client-sdk --- .circleci/config.yml | 26 +++++++---- build-android.gradle | 44 ++++++++++--------- build.gradle.kts | 17 +++---- buildSrc/src/main/kotlin/Dependencies.kt | 11 ++++- gradle.properties | 3 ++ .../launchdarkly/sdk/internal/BaseTest.java | 2 +- .../sdk/internal/package-info.java | 11 ++--- .../launchdarkly/sdk/internal/BaseTest.java | 6 ++- 8 files changed, 69 insertions(+), 51 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2cb624d..3b35190 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,14 +24,18 @@ workflows: with-coverage: true requires: - build-linux - - build-test-windows: + - test-windows: name: Java 11 - Windows - OpenJDK openjdk-version: 11.0.2.01 - - build-test-windows: + requires: + - build-linux + - test-windows: name: Java 17 - Windows - OpenJDK openjdk-version: 17.0.1 - build-test-android: name: Android + requires: + - build-linux jobs: build-linux: @@ -61,7 +65,6 @@ jobs: - image: <> steps: - checkout - - run: cp gradle.properties.example gradle.properties - attach_workspace: at: build - run: java -version @@ -75,9 +78,10 @@ jobs: ./gradlew jacocoTestReport mkdir -p coverage/ cp -r build/reports/jacoco/test/* ./coverage - - run: - name: Enforce test coverage - command: ./gradlew jacocoTestCoverageVerification + # TEMPORARY: disable coverage enforcement while this code is being refactored + # - run: + # name: Enforce test coverage + # command: ./gradlew jacocoTestCoverageVerification - run: name: Save test results command: | @@ -94,7 +98,7 @@ jobs: - store_artifacts: path: coverage - build-test-windows: + test-windows: parameters: openjdk-version: type: string @@ -109,10 +113,11 @@ jobs: - run: name: install OpenJDK command: choco install openjdk --version <> + - attach_workspace: + at: build - run: - name: build and test + name: run tests command: | - cp gradle.properties.example gradle.properties .\gradlew.bat --no-daemon test # must use --no-daemon because CircleCI in Windows will hang if there's a daemon running - run: name: save test results @@ -153,3 +158,6 @@ jobs: - store_test_results: path: ./build/outputs/androidTest-results + + - store_test_results: + path: ./build/reports/androidTests/connected diff --git a/build-android.gradle b/build-android.gradle index 79953a0..6c81614 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -1,5 +1,5 @@ -apply plugin: 'com.android.library' -//apply plugin: 'com.github.dcendents.android-maven' +apply plugin: "com.android.library" // for some reason Gradle complains if this is in the plugins section +//apply plugin: "com.getkeepsafe.dexcount" buildscript { repositories { @@ -30,15 +30,15 @@ allprojects { } android { - compileSdkVersion 26 - buildToolsVersion '28.0.3' + compileSdkVersion(30) + buildToolsVersion "30.0.3" defaultConfig { - minSdkVersion 16 - targetSdkVersion 26 - versionCode 1 - versionName version - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + minSdkVersion(21) + targetSdkVersion(30) + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles 'consumer-proguard-rules.pro' // The following argument makes the Android Test Orchestrator run its @@ -52,24 +52,28 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + packagingOptions { exclude 'META-INF/**' - exclude 'META-INF/**' } - dexOptions { - javaMaxHeapSize "4g" - } + useLibrary("android.test.runner") + useLibrary("android.test.base") + useLibrary("android.test.mock") } dependencies { // see Dependencies.kt in buildSrc - // See note in Dependencies.kt in buildSrc on the purpose of "privateImplementation". - // The Android Gradle plugin doesn't seem to have a good way to customize the classpath - // of the compile and test tasks, but since we're not actually publishing any Android - // artifacts from this project (we use this build only for CI testing), we can simply - // copy the dependencies from "privateImplementation" into the standard "implementation" - // for the Android build. Libs.implementation.each { implementation(it) } - Libs.javaTestImplementation.forEach { testImplementation(it) } + Libs.javaTestImplementation.forEach { androidTestImplementation(it) } Libs.androidTestImplementation.each { androidTestImplementation(it) } + + androidTestImplementation("com.launchdarkly:test-helpers:${Versions.testHelpers}") { + exclude group: "org.eclipse.jetty" // we don't use the HTTP helpers and they don't work in Android + exclude group: "com.squareup.okhttp3" // also unused, causes dex limit to be exceeded + } + + androidTestUtil("androidx.test:orchestrator:1.4.1") } diff --git a/build.gradle.kts b/build.gradle.kts index 1e4b5cb..7d24ea1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,27 +41,20 @@ java { targetCompatibility = JavaVersion.VERSION_1_8 } -// See Dependencies.kt in buildSrc for the purpose of "privateImplementation" -val privateImplementation by configurations.creating - dependencies { // see Dependencies.kt in buildSrc - Libs.implementation.forEach { implementation(it)} + Libs.implementation.forEach { api(it)} Libs.javaTestImplementation.forEach { testImplementation(it) } + + testImplementation("com.launchdarkly:test-helpers:${Versions.testHelpers}") } checkstyle { configFile = file("${project.rootDir}/checkstyle.xml") } -tasks.compileJava { - // See note in Dependencies.kt in buildSrc on "privateImplementation" - classpath = configurations["privateImplementation"] -} - -helpers.Javadoc.configureTask(tasks.javadoc, configurations["privateImplementation"]) // see Javadoc.kt in buildSrc +helpers.Javadoc.configureTask(tasks.javadoc, null) // see Javadoc.kt in buildSrc -helpers.Test.configureTask(tasks.compileTestJava, tasks.test, - configurations["privateImplementation"]) // see Test.kt in buildSrc +helpers.Test.configureTask(tasks.compileTestJava, tasks.test, null) // see Test.kt in buildSrc helpers.Jacoco.configureTasks( // see Jacoco.kt in buildSrc tasks.jacocoTestReport, diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 652c797..e1abf99 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -8,6 +8,7 @@ object Versions { const val launchdarklyJavaSdkCommon = "1.3.0" const val okhttp = "4.9.1" const val slf4j = "1.7.21" + const val testHelpers = "1.2.0" } object PluginVersions { @@ -29,10 +30,16 @@ object Libs { val javaTestImplementation = listOf( "junit:junit:4.12", "org.hamcrest:hamcrest-library:1.3" + + // "com.launchdarkly:test-helpers:${Versions.testHelpers}" + // test-helpers is special-cased in build.gradle.kts and build-android.gradle ) - val androidTestImplementation = javaTestImplementation + listOf( - "com.android.support.test:runner:1.0.2" + val androidTestImplementation = javaTestImplementation + listOf( + "androidx.test:core:1.4.0", + "androidx.test:runner:1.4.0", + "androidx.test:rules:1.4.0", + "androidx.test.ext:junit:1.1.3" ) val javaBuiltInGradlePlugins = listOf( diff --git a/gradle.properties b/gradle.properties index 62092fd..703b346 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,3 +3,6 @@ version=1.0.0 # and should not be needed for typical development purposes (including by third-party developers). ossrhUsername= ossrhPassword= + +# Used only in Android CI test build: +android.useAndroidX=true diff --git a/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java b/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java index bfd5d63..66b71d0 100644 --- a/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java +++ b/src/androidTest/java/com/launchdarkly/sdk/internal/BaseTest.java @@ -1,6 +1,6 @@ package com.launchdarkly.sdk.internal; -import android.support.test.runner.AndroidJUnit4; +import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.runner.RunWith; /** diff --git a/src/main/java/com/launchdarkly/sdk/internal/package-info.java b/src/main/java/com/launchdarkly/sdk/internal/package-info.java index 11767f4..e926d82 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/internal/package-info.java @@ -1,9 +1,10 @@ /** - * General-purpose helper types and methods for use in LaunchDarkly SDK code. + * This package contains general-purpose helpers that can be used by both the Java SDK and the + * Android SDK. *

- * All symbols in this package are for internal SDK use only. They are subject to change - * without notice, and any direct reference to them in application code is unsupported. - * They have public scope only because they need to be available to LaunchDarkly SDK - * code in other packages. + * All types in this package are for internal LaunchDarkly use only, and are subject to change. + * They are not part of the public supported API of the SDKs, and they should not be referenced + * by application code. They have public scope only because they need to be available to + * LaunchDarkly SDK code in other packages. */ package com.launchdarkly.sdk.internal; diff --git a/src/test/java/com/launchdarkly/sdk/internal/BaseTest.java b/src/test/java/com/launchdarkly/sdk/internal/BaseTest.java index 7eda47a..70a8e09 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/BaseTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/BaseTest.java @@ -2,8 +2,10 @@ /** * The only purpose of this class is to support the somewhat roundabout mechanism we use in CI to run - * all of our unit tests in an Android environment too. All unit tests in this project should have this - * as a base class. + * all of our unit tests in an Android environment too. All unit tests in this project should be either + * directly or indirectly descended from this class. Then, when we run the Android tests, we replace + * this class with another version (from src/androidTest/java) that has the necessary Android test + * runner annotation on it. */ public abstract class BaseTest { } From ecb0eeb95ee3f415cacd2d91657de6bc8337a9eb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 2 Aug 2022 12:53:28 -0700 Subject: [PATCH 26/57] add note on dependencies --- CONTRIBUTING.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f15c8d6..6b5f35b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,7 +57,7 @@ To build the project and run all unit tests: ./gradlew test ``` -## Note on Java version and Android support +## Note on Java version, Android support, and dependencies This project can be used both in server-side Java and in Android. Its minimum Java version is 8, but not all Java 8 APIs and syntax are supported in Android. The CI jobs for this project include an Android job that runs all of the unit tests in Android, to verify that no unsupported APIs are being used. @@ -72,3 +72,7 @@ Sometimes a gap in coverage is unavoidable, usually because the compiler require * Mark the code with an explanatory comment beginning with "COVERAGE:". * Run the code coverage task with `./gradlew jacocoTestCoverageVerification`. It should fail and indicate how many lines of missed coverage exist in the method you modified. * Add an item in the `knownMissedLinesForMethods` map in `build.gradle` that specifies that number of missed lines for that method signature. + +## Note on dependencies + +Because this project can be used in Android, it's important to avoid heavyweight runtime dependencies. For instance, as convenient as Guava can be, we should not use Guava at all (except possibly in _test_ code) because it is a large library-- and also because if the application does use Guava, we don't want to have to worry about conflicting with whatever version they're using. From 3163e0be05ab6d5292f5a765b52c03d1cd6b27a0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 2 Aug 2022 13:22:28 -0700 Subject: [PATCH 27/57] Android JUnit seems to have a problem with commas in parameterized test names --- .../sdk/internal/events/EventContextFormatterTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java index 2d1511a..73c4ec0 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java @@ -47,7 +47,7 @@ public EventContextFormatterTest( public static Iterable data() { return ImmutableList.of( new Object[] { - "no attributes private, single kind", + "no attributes private - single kind", LDContext.builder("my-key").kind("org") .name("my-name") .set("attr1", "value1") @@ -57,7 +57,7 @@ public static Iterable data() { "{\"kind\": \"org\", \"key\": \"my-key\", \"name\": \"my-name\", \"attr1\": \"value1\"}" }, new Object[] { - "no attributes private, multi-kind", + "no attributes private - multi-kind", LDContext.createMulti( LDContext.builder("org-key").kind("org") .name("org-name") From 60a57f8cafdf01605162efe4546f14e09a6d6da4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 5 Aug 2022 16:27:45 -0700 Subject: [PATCH 28/57] clarify comment --- .../sdk/internal/events/EventContextFormatter.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java index ebedfb0..73f2e9e 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java @@ -163,11 +163,13 @@ private List writeRedactedValue( // path (that is, it has the current path as a prefix, but the depth is greater), then we return // it, to tell us that we'll need to recurse to redact subproperties. // - // The previousMatchRef parameter is what we use instead of a stack to keep track of where we're - // at in recursive calls. It starts out as null at the top level. Then, every time we recurse to - // redact subproperties of an object, we set previousMatchRef to any AttributeRef that has the - // current subpath as a prefix; such an AttributeRef is guaranteed to exist, because we wouldn't - // have bothered to recurse if we hadn't found one. + // The previousMatchRef parameter is how we to keep track of the previous path segments we have + // already matched when recursing. It starts out as null at the top level. Then, every time we + // recurse to redact subproperties of an object, we set previousMatchRef to *any* AttributeRef + // we've seen that has the current subpath as a prefix; such an AttributeRef is guaranteed to + // exist, because we wouldn't have bothered to recurse if we hadn't found one, and we will only + // be comparing components 0 through depth-1 of it (see matchPrivateRef). This shortcut allows + // us to avoid allocating a variable-length mutable data structure such as a stack. private AttributeRef findPrivateRef(LDContext c, int depth, String attrName, AttributeRef previousMatchRef) { AttributeRef nonExactMatch = null; if (globalPrivateAttributes.length != 0) { // minor optimization to avoid creating an iterator if it's empty From 5c67725a327a3c9d7e21c2ec9784409ce17c443b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 18 Aug 2022 14:48:56 -0700 Subject: [PATCH 29/57] make more event processor behaviors configurable to support Android (offline, background mode, worker threads) --- .../events/ConnectionStatusMonitor.java | 22 ++++ .../events/DefaultEventProcessor.java | 108 ++++++++++-------- .../internal/events/EventsConfiguration.java | 19 +++ .../sdk/internal/events/BaseEventTest.java | 76 +++++++++--- .../DefaultEventProcessorDiagnosticsTest.java | 49 ++++++-- .../events/DefaultEventProcessorTest.java | 25 ++++ 6 files changed, 223 insertions(+), 76 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/ConnectionStatusMonitor.java diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/ConnectionStatusMonitor.java b/src/main/java/com/launchdarkly/sdk/internal/events/ConnectionStatusMonitor.java new file mode 100644 index 0000000..8e23c86 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/ConnectionStatusMonitor.java @@ -0,0 +1,22 @@ +package com.launchdarkly.sdk.internal.events; + +/** + * Callback interface for determining whether the network is available. + *

+ * The server-side SDK will not provide any implementation of this, because it assumes that it is + * always online. The client-side SDK will provide an implementation that tells DefaultEventProcessor + * we are offline if the network is not available, or if the client has been deliberately configured + * to be offline. + */ +public interface ConnectionStatusMonitor { + /** + * Returns true if the network is available (as far we know) and the SDK is supposed to be online. + * DefaultEventProcessor will skip trying to deliver any events if this returns false. + *

+ * This method must be thread-safe. It will be called every time DefaultEventProcessor is + * considering sending some events. + * + * @return true if connected, false if offline for any reason + */ + boolean isConnected(); +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java index 89c4212..19662ce 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java @@ -51,6 +51,7 @@ public final class DefaultEventProcessor implements Closeable { private final BlockingQueue inbox; private final ScheduledExecutorService scheduler; + private final AtomicBoolean inBackground; private final AtomicBoolean closed = new AtomicBoolean(false); private final List> scheduledTasks = new ArrayList<>(); private volatile boolean inputCapacityExceeded = false; @@ -75,11 +76,14 @@ public DefaultEventProcessor( scheduler = sharedExecutor; this.logger = logger; + inBackground = new AtomicBoolean(eventsConfig.initiallyInBackground); + new EventDispatcher( eventsConfig, sharedExecutor, threadPriority, inbox, + inBackground, closed, logger ); @@ -121,6 +125,16 @@ public void flush() { } } + /** + * Tells the event processor whether we should be in background mode. This is only applicable in the client-side + * (Android) SDK. In background mode, events mostly work the same but we do not send any periodic diagnostic events. + * + * @param inBackground true if we should be in background mode + */ + public void setInBackground(boolean inBackground) { + this.inBackground.getAndSet(inBackground); + } + public void close() throws IOException { if (closed.compareAndSet(false, true)) { for (ScheduledFuture task: scheduledTasks) { @@ -229,20 +243,20 @@ void waitForCompletion() { * on its own thread. */ static final class EventDispatcher { - private static final int MAX_FLUSH_THREADS = 5; private static final int MESSAGE_BATCH_SIZE = 50; final EventsConfiguration eventsConfig; // visible for testing private final BlockingQueue inbox; + private final AtomicBoolean inBackground; private final AtomicBoolean closed; private final List flushWorkers; private final AtomicInteger busyFlushWorkersCount; private final AtomicLong lastKnownPastTime = new AtomicLong(0); private final AtomicBoolean disabled = new AtomicBoolean(false); + private final AtomicBoolean didSendInitEvent = new AtomicBoolean(false); final DiagnosticStore diagnosticStore; // visible for testing private final EventContextDeduplicator contextDeduplicator; private final ExecutorService sharedExecutor; - private final SendDiagnosticTaskFactory sendDiagnosticTaskFactory; private final LDLogger logger; private long deduplicatedUsers = 0; @@ -252,11 +266,13 @@ private EventDispatcher( ExecutorService sharedExecutor, int threadPriority, BlockingQueue inbox, + AtomicBoolean inBackground, AtomicBoolean closed, LDLogger logger ) { this.eventsConfig = eventsConfig; this.inbox = inbox; + this.inBackground = inBackground; this.closed = closed; this.sharedExecutor = sharedExecutor; this.diagnosticStore = eventsConfig.diagnosticStore; @@ -295,7 +311,7 @@ public void run() { flushWorkers = new ArrayList<>(); EventResponseListener listener = this::handleResponse; - for (int i = 0; i < MAX_FLUSH_THREADS; i++) { + for (int i = 0; i < eventsConfig.eventSendingThreadPoolSize; i++) { SendEventsTask task = new SendEventsTask( eventsConfig, listener, @@ -307,12 +323,8 @@ public void run() { flushWorkers.add(task); } - if (diagnosticStore != null) { - // Set up diagnostics - this.sendDiagnosticTaskFactory = new SendDiagnosticTaskFactory(eventsConfig, this::handleResponse, logger); - sharedExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticStore.getInitEvent())); - } else { - sendDiagnosticTaskFactory = null; + if (diagnosticStore != null && canSendEvents() && !inBackground.get()) { + sharedExecutor.submit(createSendDiagnosticTask(diagnosticStore.getInitEvent())); } } @@ -361,7 +373,9 @@ private void runMainLoop( processEvent(message.event, outbox); break; case FLUSH: - triggerFlush(outbox, payloadQueue); + if (canSendEvents()) { + triggerFlush(outbox, payloadQueue); + } break; case FLUSH_USERS: if (contextDeduplicator != null) { @@ -369,7 +383,12 @@ private void runMainLoop( } break; case DIAGNOSTIC: - sendAndResetDiagnostics(outbox); + if (canSendEvents() && !inBackground.get()) { + if (!didSendInitEvent.get()) { + sharedExecutor.submit(createSendDiagnosticTask(diagnosticStore.getInitEvent())); + } + sendAndResetDiagnostics(outbox); + } break; case SYNC: // this is used only by unit tests waitUntilAllFlushWorkersInactive(); @@ -389,6 +408,10 @@ private void runMainLoop( } } + private boolean canSendEvents() { + return eventsConfig.connectionStatusMonitor == null || eventsConfig.connectionStatusMonitor.isConnected(); + } + private void sendAndResetDiagnostics(EventBuffer outbox) { if (disabled.get()) { return; @@ -397,7 +420,7 @@ private void sendAndResetDiagnostics(EventBuffer outbox) { // We pass droppedEvents and deduplicatedUsers as parameters here because they are updated frequently in the main loop so we want to avoid synchronization on them. DiagnosticEvent diagnosticEvent = diagnosticStore.createEventAndReset(droppedEvents, deduplicatedUsers); deduplicatedUsers = 0; - sharedExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticEvent)); + sharedExecutor.submit(createSendDiagnosticTask(diagnosticEvent)); } private void doShutdown() { @@ -537,6 +560,29 @@ private void handleResponse(EventSender.Result result) { disabled.set(true); } } + + private Runnable createSendDiagnosticTask(final DiagnosticEvent diagnosticEvent) { + return new Runnable() { + @Override + public void run() { + try { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(INITIAL_OUTPUT_BUFFER_SIZE); + Writer writer = new BufferedWriter(new OutputStreamWriter(buffer, Charset.forName("UTF-8")), INITIAL_OUTPUT_BUFFER_SIZE); + gson.toJson(diagnosticEvent, writer); + writer.flush(); + EventSender.Result result = eventsConfig.eventSender.sendDiagnosticEvent( + buffer.toByteArray(), eventsConfig.eventsUri); + handleResponse(result); + if (diagnosticEvent instanceof DiagnosticEvent.Init) { + didSendInitEvent.set(true); + } + } catch (Exception e) { + logger.error("Unexpected error in event processor: {}", e.toString()); + logger.debug(e.toString(), e); + } + } + }; + } } private static final class EventBuffer { @@ -678,42 +724,4 @@ void stop() { thread.interrupt(); } } - - private static final class SendDiagnosticTaskFactory { - private final EventsConfiguration eventsConfig; - private final EventResponseListener eventResponseListener; - private final LDLogger logger; - - SendDiagnosticTaskFactory( - EventsConfiguration eventsConfig, - EventResponseListener eventResponseListener, - LDLogger logger - ) { - this.eventsConfig = eventsConfig; - this.eventResponseListener = eventResponseListener; - this.logger = logger; - } - - Runnable createSendDiagnosticTask(final DiagnosticEvent diagnosticEvent) { - return new Runnable() { - @Override - public void run() { - try { - ByteArrayOutputStream buffer = new ByteArrayOutputStream(INITIAL_OUTPUT_BUFFER_SIZE); - Writer writer = new BufferedWriter(new OutputStreamWriter(buffer, Charset.forName("UTF-8")), INITIAL_OUTPUT_BUFFER_SIZE); - gson.toJson(diagnosticEvent, writer); - writer.flush(); - EventSender.Result result = eventsConfig.eventSender.sendDiagnosticEvent( - buffer.toByteArray(), eventsConfig.eventsUri); - if (eventResponseListener != null) { - eventResponseListener.handleResponse(result); - } - } catch (Exception e) { - logger.error("Unexpected error in event processor: {}", e.toString()); - logger.debug(e.toString(), e); - } - } - }; - } - } } diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java index d1dbe23..185ebb7 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java @@ -13,14 +13,22 @@ * This class is not exposed in the public SDK API. */ public final class EventsConfiguration { + /** + * Default number of event-sending worker threads. + */ + public static final int DEFAULT_EVENT_SENDING_THREAD_POOL_SIZE = 5; + final boolean allAttributesPrivate; final int capacity; + final ConnectionStatusMonitor connectionStatusMonitor; final EventContextDeduplicator contextDeduplicator; final long diagnosticRecordingIntervalMillis; final DiagnosticStore diagnosticStore; final EventSender eventSender; + final int eventSendingThreadPoolSize; final URI eventsUri; final long flushIntervalMillis; + final boolean initiallyInBackground; final List privateAttributes; /** @@ -28,34 +36,45 @@ public final class EventsConfiguration { * * @param allAttributesPrivate true if all attributes are private * @param capacity event buffer capacity (if zero or negative, a value of 1 is used to prevent errors) + * @param connectionStatusMonitor optional ConnectionStatusMonitor; null for server-side SDK * @param contextDeduplicator optional EventContextDeduplicator; null for client-side SDK * @param diagnosticRecordingIntervalMillis diagnostic recording interval * @param diagnosticStore optional DiagnosticStore; null if diagnostics are disabled * @param eventSender event delivery component; must not be null + * @param eventSendingThreadPoolSize number of worker threads for event delivery; zero to use the default * @param eventsUri events base URI * @param flushIntervalMillis event flush interval + * @param initiallyInBackground true if we should start out in background mode (see + * {@link DefaultEventProcessor#setInBackground(boolean)}) * @param privateAttributes list of private attribute references; may be null */ public EventsConfiguration( boolean allAttributesPrivate, int capacity, + ConnectionStatusMonitor connectionStatusMonitor, EventContextDeduplicator contextDeduplicator, long diagnosticRecordingIntervalMillis, DiagnosticStore diagnosticStore, EventSender eventSender, + int eventSendingThreadPoolSize, URI eventsUri, long flushIntervalMillis, + boolean initiallyInBackground, Collection privateAttributes ) { super(); this.allAttributesPrivate = allAttributesPrivate; this.capacity = capacity >= 0 ? capacity : 1; + this.connectionStatusMonitor = connectionStatusMonitor; this.contextDeduplicator = contextDeduplicator; this.diagnosticRecordingIntervalMillis = diagnosticRecordingIntervalMillis; this.diagnosticStore = diagnosticStore; this.eventSender = eventSender; + this.eventSendingThreadPoolSize = eventSendingThreadPoolSize >= 0 ? eventSendingThreadPoolSize : + DEFAULT_EVENT_SENDING_THREAD_POOL_SIZE; this.eventsUri = eventsUri; this.flushIntervalMillis = flushIntervalMillis; + this.initiallyInBackground = initiallyInBackground; this.privateAttributes = privateAttributes == null ? Collections.emptyList() : new ArrayList<>(privateAttributes); } } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java index 91d0f4a..5ca8901 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java @@ -28,6 +28,7 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; @@ -40,6 +41,8 @@ import static org.hamcrest.Matchers.allOf; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") public abstract class BaseEventTest extends BaseTest { @@ -92,23 +95,29 @@ public static EventsConfiguration defaultEventsConfig() { public static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, Collection privateAttributes) { - return new EventsConfiguration( - allAttributesPrivate, - 0, - null, - 100000, // arbitrary long flush interval - null, - null, - null, - 100000, // arbitrary long flush interval - privateAttributes - ); + return new EventsConfigurationBuilder() + .allAttributesPrivate(allAttributesPrivate) + .privateAttributes(privateAttributes == null ? null : new HashSet<>(privateAttributes)) + .build(); } public static EvaluationDetail simpleEvaluation(int variation, LDValue value) { return EvaluationDetail.fromValue(value, variation, EvaluationReason.off()); } + public static final class MockConnectionStatusMonitor implements ConnectionStatusMonitor { + private final AtomicBoolean connected = new AtomicBoolean(true); + + @Override + public boolean isConnected() { + return connected.get(); + } + + public void setConnected(boolean connected) { + this.connected.set(connected); + } + } + public static final class MockEventSender implements EventSender { volatile boolean closed; volatile Result result = new Result(true, false, null); @@ -174,6 +183,18 @@ private Result receive(boolean diagnostic, byte[] data, int eventCount, URI even Params awaitRequest() { return awaitValue(receivedParams, 5, TimeUnit.SECONDS); } + + Params awaitAnalytics() { + Params p = awaitValue(receivedParams, 5, TimeUnit.SECONDS); + assertFalse("expected analytics event but got diagnostic event instead", p.diagnostic); + return p; + } + + Params awaitDiagnostic() { + Params p = awaitValue(receivedParams, 5, TimeUnit.SECONDS); + assertTrue("expected a diagnostic event but got analytics events instead", p.diagnostic); + return p; + } void expectNoRequests(long timeoutMillis) { assertNoMoreValues(receivedParams, timeoutMillis, TimeUnit.MILLISECONDS); @@ -320,24 +341,30 @@ public static Event.Identify identifyEvent(LDContext context) { public static class EventsConfigurationBuilder { private boolean allAttributesPrivate = false; private int capacity = 1000; + private ConnectionStatusMonitor connectionStatusMonitor = null; private EventContextDeduplicator contextDeduplicator = null; private long diagnosticRecordingIntervalMillis = 1000000; private DiagnosticStore diagnosticStore = null; + private EventSender eventSender = null; + private int eventSendingThreadPoolSize = EventsConfiguration.DEFAULT_EVENT_SENDING_THREAD_POOL_SIZE; private URI eventsUri = URI.create("not-valid"); private long flushIntervalMillis = 1000000; + private boolean initiallyInBackground = false; private Set privateAttributes = new HashSet<>(); - private EventSender eventSender = null; public EventsConfiguration build() { return new EventsConfiguration( allAttributesPrivate, capacity, + connectionStatusMonitor, contextDeduplicator, diagnosticRecordingIntervalMillis, diagnosticStore, eventSender, + eventSendingThreadPoolSize, eventsUri, flushIntervalMillis, + initiallyInBackground, privateAttributes ); } @@ -351,6 +378,11 @@ public EventsConfigurationBuilder capacity(int capacity) { this.capacity = capacity; return this; } + + public EventsConfigurationBuilder connectionStatusMonitor(ConnectionStatusMonitor connectionStatusMonitor) { + this.connectionStatusMonitor = connectionStatusMonitor; + return this; + } public EventsConfigurationBuilder contextDeduplicator(EventContextDeduplicator contextDeduplicator) { this.contextDeduplicator = contextDeduplicator; @@ -367,6 +399,16 @@ public EventsConfigurationBuilder diagnosticStore(DiagnosticStore diagnosticStor return this; } + public EventsConfigurationBuilder eventSender(EventSender eventSender) { + this.eventSender = eventSender; + return this; + } + + public EventsConfigurationBuilder eventSendingThreadPoolSize(int eventSendingThreadPoolSize) { + this.eventSendingThreadPoolSize = eventSendingThreadPoolSize; + return this; + } + public EventsConfigurationBuilder eventsUri(URI eventsUri) { this.eventsUri = eventsUri; return this; @@ -376,14 +418,14 @@ public EventsConfigurationBuilder flushIntervalMillis(long flushIntervalMillis) this.flushIntervalMillis = flushIntervalMillis; return this; } - - public EventsConfigurationBuilder privateAttributes(Set privateAttributes) { - this.privateAttributes = privateAttributes; + + public EventsConfigurationBuilder initiallyInBackground(boolean initiallyInBackground) { + this.initiallyInBackground = initiallyInBackground; return this; } - public EventsConfigurationBuilder eventSender(EventSender eventSender) { - this.eventSender = eventSender; + public EventsConfigurationBuilder privateAttributes(Set privateAttributes) { + this.privateAttributes = privateAttributes; return this; } } diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java index 3cdde56..e9892df 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java @@ -40,9 +40,9 @@ public DefaultEventProcessorDiagnosticsTest() { public void diagnosticEventsSentToDiagnosticEndpoint() throws Exception { MockEventSender es = new MockEventSender(); try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).diagnosticStore(diagnosticStore))) { - MockEventSender.Params initReq = es.awaitRequest(); + MockEventSender.Params initReq = es.awaitDiagnostic(); ep.postDiagnostic(); - MockEventSender.Params periodicReq = es.awaitRequest(); + MockEventSender.Params periodicReq = es.awaitDiagnostic(); assertThat(initReq.diagnostic, is(true)); assertThat(periodicReq.diagnostic, is(true)); @@ -53,7 +53,7 @@ public void diagnosticEventsSentToDiagnosticEndpoint() throws Exception { public void initialDiagnosticEventHasInitBody() throws Exception { MockEventSender es = new MockEventSender(); try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).diagnosticStore(diagnosticStore))) { - MockEventSender.Params req = es.awaitRequest(); + MockEventSender.Params req = es.awaitDiagnostic(); DiagnosticEvent.Init initEvent = gson.fromJson(req.data, DiagnosticEvent.Init.class); @@ -73,9 +73,9 @@ public void periodicDiagnosticEventHasStatisticsBody() throws Exception { long dataSinceDate = diagnosticStore.getDataSinceDate(); try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).diagnosticStore(diagnosticStore))) { // Ignore the initial diagnostic event - es.awaitRequest(); + es.awaitDiagnostic(); ep.postDiagnostic(); - MockEventSender.Params periodicReq = es.awaitRequest(); + MockEventSender.Params periodicReq = es.awaitDiagnostic(); assertNotNull(periodicReq); DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); @@ -104,13 +104,13 @@ public void periodicDiagnosticEventGetsEventsInLastBatchAndDeduplicatedUsers() t try (DefaultEventProcessor ep = makeEventProcessor( baseConfig(es).contextDeduplicator(contextDeduplicator).diagnosticStore(diagnosticStore))) { // Ignore the initial diagnostic event - es.awaitRequest(); + es.awaitDiagnostic(); ep.sendEvent(fe1); ep.sendEvent(fe2); ep.flush(); // Ignore normal events - es.awaitRequest(); + es.awaitAnalytics(); ep.postDiagnostic(); MockEventSender.Params periodicReq = es.awaitRequest(); @@ -133,7 +133,7 @@ public void periodicDiagnosticEventsAreSentAutomatically() throws Exception { try (DefaultEventProcessor ep = makeEventProcessor(eventsConfig.diagnosticStore(diagnosticStore))) { // Ignore the initial diagnostic event - es.awaitRequest(); + es.awaitDiagnostic(); MockEventSender.Params periodicReq = es.awaitRequest(); @@ -143,6 +143,37 @@ public void periodicDiagnosticEventsAreSentAutomatically() throws Exception { } } + @Test + public void periodicDiagnosticEventsAreNotSentWhenInBackground() throws Exception { + MockEventSender es = new MockEventSender(); + + EventsConfigurationBuilder eventsConfig = makeEventsConfigurationWithBriefDiagnosticInterval(es); + + try (DefaultEventProcessor ep = makeEventProcessor(eventsConfig.diagnosticStore(diagnosticStore))) { + // Ignore the initial diagnostic event + es.awaitDiagnostic(); + + // Expect a periodic diagnostic event + es.awaitDiagnostic(); + + // Now turn on background mode, which should make periodic events stop. + ep.setInBackground(true); + + try { + es.expectNoRequests(200); + } catch (AssertionError e) { + // Might have been a race condition where an event got scheduled before the background mode change; + // if so, there should be a gap with no events after that, so try the assertion again. + es.expectNoRequests(200); + } + + // Turn off background mode; periodic events should resume + ep.setInBackground(false); + + es.awaitDiagnostic(); + } + } + private EventsConfigurationBuilder makeEventsConfigurationWithBriefDiagnosticInterval(EventSender es) { return baseConfig(es).diagnosticRecordingIntervalMillis(50); } @@ -161,7 +192,7 @@ public void diagnosticEventsStopAfter401Error() throws Exception { try (DefaultEventProcessor ep = makeEventProcessor(eventsConfig.diagnosticStore(diagnosticStore))) { // Ignore the initial diagnostic event - es.awaitRequest(); + es.awaitDiagnostic(); es.expectNoRequests(100); } diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java index 2a9ee6e..2e33908 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java @@ -65,6 +65,31 @@ public void eventsAreFlushedAutomatically() throws Exception { isCustomEvent(ce) )); } + + @SuppressWarnings("unchecked") + @Test + public void eventsAreNotFlushedWhenNotConnected() throws Exception { + MockConnectionStatusMonitor connectionStatusMonitor = new MockConnectionStatusMonitor(); + connectionStatusMonitor.setConnected(false); + MockEventSender es = new MockEventSender(); + long briefFlushInterval = 50; + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es) + .connectionStatusMonitor(connectionStatusMonitor) + .flushIntervalMillis(briefFlushInterval))) { + Event.Custom event1 = customEvent(user, "event1").build(); + Event.Custom event2 = customEvent(user, "event2").build(); + ep.sendEvent(event1); + ep.sendEvent(event2); + + es.expectNoRequests(200); + + connectionStatusMonitor.setConnected(true); + + List payload1 = es.getEventsFromLastRequest(); + assertThat(payload1, contains(isCustomEvent(event1), isCustomEvent(event2))); + } + } @Test public void closingEventProcessorForcesSynchronousFlush() throws Exception { From f61bada32352fad271bf45a2758d4b51b93f76ad Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 18 Aug 2022 18:37:01 -0700 Subject: [PATCH 30/57] better system for switching various event functionality on or off --- .../events/ConnectionStatusMonitor.java | 22 --- .../events/DefaultEventProcessor.java | 137 ++++++++++++++---- .../internal/events/EventsConfiguration.java | 9 +- .../sdk/internal/events/BaseEventTest.java | 74 ++++------ .../DefaultEventProcessorDiagnosticsTest.java | 14 +- .../events/DefaultEventProcessorTest.java | 10 +- 6 files changed, 152 insertions(+), 114 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/ConnectionStatusMonitor.java diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/ConnectionStatusMonitor.java b/src/main/java/com/launchdarkly/sdk/internal/events/ConnectionStatusMonitor.java deleted file mode 100644 index 8e23c86..0000000 --- a/src/main/java/com/launchdarkly/sdk/internal/events/ConnectionStatusMonitor.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.launchdarkly.sdk.internal.events; - -/** - * Callback interface for determining whether the network is available. - *

- * The server-side SDK will not provide any implementation of this, because it assumes that it is - * always online. The client-side SDK will provide an implementation that tells DefaultEventProcessor - * we are offline if the network is not available, or if the client has been deliberately configured - * to be offline. - */ -public interface ConnectionStatusMonitor { - /** - * Returns true if the network is available (as far we know) and the SDK is supposed to be online. - * DefaultEventProcessor will skip trying to deliver any events if this returns false. - *

- * This method must be thread-safe. It will be called every time DefaultEventProcessor is - * considering sending some events. - * - * @return true if connected, false if offline for any reason - */ - boolean isConnected(); -} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java index 19662ce..02e2e96 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java @@ -49,11 +49,17 @@ public final class DefaultEventProcessor implements Closeable { private static final Gson gson = new Gson(); + private final EventsConfiguration eventsConfig; private final BlockingQueue inbox; private final ScheduledExecutorService scheduler; + private final AtomicBoolean offline; private final AtomicBoolean inBackground; + private final AtomicBoolean diagnosticInitSent = new AtomicBoolean(false); private final AtomicBoolean closed = new AtomicBoolean(false); - private final List> scheduledTasks = new ArrayList<>(); + private final Object stateLock = new Object(); + private ScheduledFuture eventFlushTask; + private ScheduledFuture contextKeysFlushTask; + private ScheduledFuture periodicDiagnosticEventTask; private volatile boolean inputCapacityExceeded = false; private final LDLogger logger; @@ -71,12 +77,14 @@ public DefaultEventProcessor( int threadPriority, LDLogger logger ) { + this.eventsConfig = eventsConfig; inbox = new ArrayBlockingQueue<>(eventsConfig.capacity); scheduler = sharedExecutor; this.logger = logger; inBackground = new AtomicBoolean(eventsConfig.initiallyInBackground); + offline = new AtomicBoolean(eventsConfig.initiallyOffline); new EventDispatcher( eventsConfig, @@ -84,24 +92,19 @@ public DefaultEventProcessor( threadPriority, inbox, inBackground, + offline, closed, logger ); // we don't need to save a reference to this - we communicate with it entirely through the inbox queue. - Runnable flusher = postMessageRunnable(MessageType.FLUSH, null); - scheduledTasks.add(this.scheduler.scheduleAtFixedRate(flusher, eventsConfig.flushIntervalMillis, - eventsConfig.flushIntervalMillis, TimeUnit.MILLISECONDS)); + // Decide whether to start scheduled tasks that depend on the background/offline state. + updateScheduledTasks(eventsConfig.initiallyInBackground, eventsConfig.initiallyOffline); + + // The context keys flush task should always be scheduled, if a contextDeduplicator exists. if (eventsConfig.contextDeduplicator != null && eventsConfig.contextDeduplicator.getFlushInterval() != null) { - Runnable userKeysFlusher = postMessageRunnable(MessageType.FLUSH_USERS, null); - long intervalMillis = eventsConfig.contextDeduplicator.getFlushInterval().longValue(); - scheduledTasks.add(this.scheduler.scheduleAtFixedRate(userKeysFlusher, intervalMillis, - intervalMillis, TimeUnit.MILLISECONDS)); - } - if (eventsConfig.diagnosticStore != null) { - Runnable diagnosticsTrigger = postMessageRunnable(MessageType.DIAGNOSTIC, null); - scheduledTasks.add(this.scheduler.scheduleAtFixedRate(diagnosticsTrigger, eventsConfig.diagnosticRecordingIntervalMillis, - eventsConfig.diagnosticRecordingIntervalMillis, TimeUnit.MILLISECONDS)); + contextKeysFlushTask = enableOrDisableTask(true, null, + eventsConfig.contextDeduplicator.getFlushInterval().longValue(), MessageType.FLUSH_USERS); } } @@ -132,25 +135,97 @@ public void flush() { * @param inBackground true if we should be in background mode */ public void setInBackground(boolean inBackground) { - this.inBackground.getAndSet(inBackground); + synchronized (stateLock) { + if (this.inBackground.getAndSet(inBackground) == inBackground) { + // value was unchanged - nothing to do + return; + } + updateScheduledTasks(inBackground, offline.get()); + } + } + + /** + * Tells the event processor whether we should be in background mode. This is only applicable in the client-side + * (Android) SDK; in the server-side Java SDK, offline mode does not change dynamically and so we don't even + * bother to create an event processor if we're offline. In offline mode, events are enqueued but never flushed, + * and diagnostic events are not sent. + * + * @param offline true if we should be in offline mode + */ + public void setOffline(boolean offline) { + synchronized (stateLock) { + if (this.offline.getAndSet(offline) == offline) { + // value was unchanged - nothing to do + return; + } + updateScheduledTasks(inBackground.get(), offline); + } } public void close() throws IOException { if (closed.compareAndSet(false, true)) { - for (ScheduledFuture task: scheduledTasks) { - task.cancel(false); + synchronized (stateLock) { + eventFlushTask = enableOrDisableTask(false, eventFlushTask, 0, null); + contextKeysFlushTask = enableOrDisableTask(false, contextKeysFlushTask, 0, null); + periodicDiagnosticEventTask = enableOrDisableTask(false, periodicDiagnosticEventTask, 0, null); } postMessageAsync(MessageType.FLUSH, null); postMessageAndWait(MessageType.SHUTDOWN, null); } } + void updateScheduledTasks(boolean inBackground, boolean offline) { + // The event flush task should be scheduled unless we're offline. + eventFlushTask = enableOrDisableTask( + !offline, + eventFlushTask, + eventsConfig.flushIntervalMillis, + MessageType.FLUSH + ); + + // The periodic diagnostic event task should be scheduled unless we're offline or in the background + // or there is no diagnostic store. + periodicDiagnosticEventTask = enableOrDisableTask( + !offline && !inBackground && eventsConfig.diagnosticStore != null, + periodicDiagnosticEventTask, + eventsConfig.diagnosticRecordingIntervalMillis, + MessageType.DIAGNOSTIC_STATS + ); + + if (!inBackground && !offline && !diagnosticInitSent.get() && eventsConfig.diagnosticStore != null) { + // Trigger a diagnostic init event if we never had the chance to send one before + postMessageAsync(MessageType.DIAGNOSTIC_INIT, null); + } + } + + ScheduledFuture enableOrDisableTask( + boolean shouldEnable, + ScheduledFuture currentTask, + long intervalMillis, + MessageType messageType + ) { + if (shouldEnable) { + if (currentTask != null) { + return currentTask; + } + ScheduledFuture task = this.scheduler.scheduleAtFixedRate( + postMessageRunnable(messageType, null), + intervalMillis, intervalMillis, TimeUnit.MILLISECONDS); + return task; + } else { + if (currentTask != null) { + currentTask.cancel(false); + } + return null; + } + } + void waitUntilInactive() throws IOException { // visible for testing postMessageAndWait(MessageType.SYNC, null); } void postDiagnostic() { // visible for testing - postMessageAsync(MessageType.DIAGNOSTIC, null); + postMessageAsync(MessageType.DIAGNOSTIC_STATS, null); } private void postMessageAsync(MessageType type, Event event) { @@ -194,7 +269,8 @@ private static enum MessageType { EVENT, FLUSH, FLUSH_USERS, - DIAGNOSTIC, + DIAGNOSTIC_INIT, + DIAGNOSTIC_STATS, SYNC, SHUTDOWN } @@ -248,6 +324,7 @@ static final class EventDispatcher { final EventsConfiguration eventsConfig; // visible for testing private final BlockingQueue inbox; private final AtomicBoolean inBackground; + private final AtomicBoolean offline; private final AtomicBoolean closed; private final List flushWorkers; private final AtomicInteger busyFlushWorkersCount; @@ -267,12 +344,14 @@ private EventDispatcher( int threadPriority, BlockingQueue inbox, AtomicBoolean inBackground, + AtomicBoolean offline, AtomicBoolean closed, LDLogger logger ) { this.eventsConfig = eventsConfig; this.inbox = inbox; this.inBackground = inBackground; + this.offline = offline; this.closed = closed; this.sharedExecutor = sharedExecutor; this.diagnosticStore = eventsConfig.diagnosticStore; @@ -322,10 +401,6 @@ public void run() { ); flushWorkers.add(task); } - - if (diagnosticStore != null && canSendEvents() && !inBackground.get()) { - sharedExecutor.submit(createSendDiagnosticTask(diagnosticStore.getInitEvent())); - } } private void onUncaughtException(Thread thread, Throwable e) { @@ -373,7 +448,7 @@ private void runMainLoop( processEvent(message.event, outbox); break; case FLUSH: - if (canSendEvents()) { + if (!offline.get()) { triggerFlush(outbox, payloadQueue); } break; @@ -382,11 +457,13 @@ private void runMainLoop( contextDeduplicator.flush(); } break; - case DIAGNOSTIC: - if (canSendEvents() && !inBackground.get()) { - if (!didSendInitEvent.get()) { - sharedExecutor.submit(createSendDiagnosticTask(diagnosticStore.getInitEvent())); - } + case DIAGNOSTIC_INIT: + if (!offline.get() && !inBackground.get() && !didSendInitEvent.get()) { + sharedExecutor.submit(createSendDiagnosticTask(diagnosticStore.getInitEvent())); + } + break; + case DIAGNOSTIC_STATS: + if (!offline.get() && !inBackground.get()) { sendAndResetDiagnostics(outbox); } break; @@ -408,10 +485,6 @@ private void runMainLoop( } } - private boolean canSendEvents() { - return eventsConfig.connectionStatusMonitor == null || eventsConfig.connectionStatusMonitor.isConnected(); - } - private void sendAndResetDiagnostics(EventBuffer outbox) { if (disabled.get()) { return; diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java index 185ebb7..c0c2088 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java @@ -20,7 +20,6 @@ public final class EventsConfiguration { final boolean allAttributesPrivate; final int capacity; - final ConnectionStatusMonitor connectionStatusMonitor; final EventContextDeduplicator contextDeduplicator; final long diagnosticRecordingIntervalMillis; final DiagnosticStore diagnosticStore; @@ -29,6 +28,7 @@ public final class EventsConfiguration { final URI eventsUri; final long flushIntervalMillis; final boolean initiallyInBackground; + final boolean initiallyOffline; final List privateAttributes; /** @@ -36,7 +36,6 @@ public final class EventsConfiguration { * * @param allAttributesPrivate true if all attributes are private * @param capacity event buffer capacity (if zero or negative, a value of 1 is used to prevent errors) - * @param connectionStatusMonitor optional ConnectionStatusMonitor; null for server-side SDK * @param contextDeduplicator optional EventContextDeduplicator; null for client-side SDK * @param diagnosticRecordingIntervalMillis diagnostic recording interval * @param diagnosticStore optional DiagnosticStore; null if diagnostics are disabled @@ -46,12 +45,13 @@ public final class EventsConfiguration { * @param flushIntervalMillis event flush interval * @param initiallyInBackground true if we should start out in background mode (see * {@link DefaultEventProcessor#setInBackground(boolean)}) + * @param initiallyOffline true if we should start out in offline mode (see + * {@link DefaultEventProcessor#setOffline(boolean)}) * @param privateAttributes list of private attribute references; may be null */ public EventsConfiguration( boolean allAttributesPrivate, int capacity, - ConnectionStatusMonitor connectionStatusMonitor, EventContextDeduplicator contextDeduplicator, long diagnosticRecordingIntervalMillis, DiagnosticStore diagnosticStore, @@ -60,12 +60,12 @@ public EventsConfiguration( URI eventsUri, long flushIntervalMillis, boolean initiallyInBackground, + boolean initiallyOffline, Collection privateAttributes ) { super(); this.allAttributesPrivate = allAttributesPrivate; this.capacity = capacity >= 0 ? capacity : 1; - this.connectionStatusMonitor = connectionStatusMonitor; this.contextDeduplicator = contextDeduplicator; this.diagnosticRecordingIntervalMillis = diagnosticRecordingIntervalMillis; this.diagnosticStore = diagnosticStore; @@ -75,6 +75,7 @@ public EventsConfiguration( this.eventsUri = eventsUri; this.flushIntervalMillis = flushIntervalMillis; this.initiallyInBackground = initiallyInBackground; + this.initiallyOffline = initiallyOffline; this.privateAttributes = privateAttributes == null ? Collections.emptyList() : new ArrayList<>(privateAttributes); } } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java index 5ca8901..b7adfd0 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java @@ -28,7 +28,6 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; @@ -40,8 +39,8 @@ import static java.util.concurrent.Executors.newSingleThreadScheduledExecutor; import static org.hamcrest.Matchers.allOf; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") @@ -105,20 +104,22 @@ public static EvaluationDetail simpleEvaluation(int variation, LDValue return EvaluationDetail.fromValue(value, variation, EvaluationReason.off()); } - public static final class MockConnectionStatusMonitor implements ConnectionStatusMonitor { - private final AtomicBoolean connected = new AtomicBoolean(true); - - @Override - public boolean isConnected() { - return connected.get(); - } + static final class CapturedPayload { + final boolean diagnostic; + final String data; + final int eventCount; + final URI eventsBaseUri; - public void setConnected(boolean connected) { - this.connected.set(connected); + CapturedPayload(boolean diagnostic, String data, int eventCount, URI eventsBaseUri) { + this.diagnostic = diagnostic; + this.data = data; + this.eventCount = eventCount; + assertNotNull(eventsBaseUri); + this.eventsBaseUri = eventsBaseUri; } } - - public static final class MockEventSender implements EventSender { + + public final class MockEventSender implements EventSender { volatile boolean closed; volatile Result result = new Result(true, false, null); volatile RuntimeException fakeError = null; @@ -126,30 +127,17 @@ public static final class MockEventSender implements EventSender { volatile CountDownLatch receivedCounter = null; volatile Object waitSignal = null; - final BlockingQueue receivedParams = new LinkedBlockingQueue<>(); + final BlockingQueue receivedParams = new LinkedBlockingQueue<>(); - static final class Params { - final boolean diagnostic; - final String data; - final int eventCount; - final URI eventsBaseUri; - - Params(boolean diagnostic, String data, int eventCount, URI eventsBaseUri) { - this.diagnostic = diagnostic; - this.data = data; - this.eventCount = eventCount; - assertNotNull(eventsBaseUri); - this.eventsBaseUri = eventsBaseUri; - } - } - @Override public Result sendAnalyticsEvents(byte[] data, int eventCount, URI eventsBaseUri) { + testLogger.debug("[MockEventSender] received {} events: {}", eventCount, new String(data)); return receive(false, data, eventCount, eventsBaseUri); } @Override public Result sendDiagnosticEvent(byte[] data, URI eventsBaseUri) { + testLogger.debug("[MockEventSender] received diagnostic event: {}", new String(data)); return receive(true, data, 1, eventsBaseUri); } @@ -162,7 +150,7 @@ public void close() throws IOException { } private Result receive(boolean diagnostic, byte[] data, int eventCount, URI eventsBaseUri) { - receivedParams.add(new Params(diagnostic, new String(data, Charset.forName("UTF-8")), eventCount, eventsBaseUri)); + receivedParams.add(new CapturedPayload(diagnostic, new String(data, Charset.forName("UTF-8")), eventCount, eventsBaseUri)); if (waitSignal != null) { // this is used in DefaultEventProcessorTest.eventsAreKeptInBufferIfAllFlushWorkersAreBusy synchronized (waitSignal) { @@ -180,18 +168,18 @@ private Result receive(boolean diagnostic, byte[] data, int eventCount, URI even return result; } - Params awaitRequest() { + CapturedPayload awaitRequest() { return awaitValue(receivedParams, 5, TimeUnit.SECONDS); } - Params awaitAnalytics() { - Params p = awaitValue(receivedParams, 5, TimeUnit.SECONDS); + CapturedPayload awaitAnalytics() { + CapturedPayload p = awaitValue(receivedParams, 5, TimeUnit.SECONDS); assertFalse("expected analytics event but got diagnostic event instead", p.diagnostic); return p; } - Params awaitDiagnostic() { - Params p = awaitValue(receivedParams, 5, TimeUnit.SECONDS); + CapturedPayload awaitDiagnostic() { + CapturedPayload p = awaitValue(receivedParams, 5, TimeUnit.SECONDS); assertTrue("expected a diagnostic event but got analytics events instead", p.diagnostic); return p; } @@ -201,7 +189,7 @@ void expectNoRequests(long timeoutMillis) { } List getEventsFromLastRequest() { - Params p = awaitRequest(); + CapturedPayload p = awaitRequest(); LDValue a = LDValue.parse(p.data); assertEquals(p.eventCount, a.size()); List ret = new ArrayList<>(); @@ -341,7 +329,6 @@ public static Event.Identify identifyEvent(LDContext context) { public static class EventsConfigurationBuilder { private boolean allAttributesPrivate = false; private int capacity = 1000; - private ConnectionStatusMonitor connectionStatusMonitor = null; private EventContextDeduplicator contextDeduplicator = null; private long diagnosticRecordingIntervalMillis = 1000000; private DiagnosticStore diagnosticStore = null; @@ -350,13 +337,13 @@ public static class EventsConfigurationBuilder { private URI eventsUri = URI.create("not-valid"); private long flushIntervalMillis = 1000000; private boolean initiallyInBackground = false; + private boolean initiallyOffline = false; private Set privateAttributes = new HashSet<>(); public EventsConfiguration build() { return new EventsConfiguration( allAttributesPrivate, capacity, - connectionStatusMonitor, contextDeduplicator, diagnosticRecordingIntervalMillis, diagnosticStore, @@ -365,6 +352,7 @@ public EventsConfiguration build() { eventsUri, flushIntervalMillis, initiallyInBackground, + initiallyOffline, privateAttributes ); } @@ -379,11 +367,6 @@ public EventsConfigurationBuilder capacity(int capacity) { return this; } - public EventsConfigurationBuilder connectionStatusMonitor(ConnectionStatusMonitor connectionStatusMonitor) { - this.connectionStatusMonitor = connectionStatusMonitor; - return this; - } - public EventsConfigurationBuilder contextDeduplicator(EventContextDeduplicator contextDeduplicator) { this.contextDeduplicator = contextDeduplicator; return this; @@ -423,6 +406,11 @@ public EventsConfigurationBuilder initiallyInBackground(boolean initiallyInBackg this.initiallyInBackground = initiallyInBackground; return this; } + + public EventsConfigurationBuilder initiallyOffline(boolean initiallyOffline) { + this.initiallyOffline = initiallyOffline; + return this; + } public EventsConfigurationBuilder privateAttributes(Set privateAttributes) { this.privateAttributes = privateAttributes; diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java index e9892df..d9c4f78 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java @@ -40,9 +40,9 @@ public DefaultEventProcessorDiagnosticsTest() { public void diagnosticEventsSentToDiagnosticEndpoint() throws Exception { MockEventSender es = new MockEventSender(); try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).diagnosticStore(diagnosticStore))) { - MockEventSender.Params initReq = es.awaitDiagnostic(); + CapturedPayload initReq = es.awaitDiagnostic(); ep.postDiagnostic(); - MockEventSender.Params periodicReq = es.awaitDiagnostic(); + CapturedPayload periodicReq = es.awaitDiagnostic(); assertThat(initReq.diagnostic, is(true)); assertThat(periodicReq.diagnostic, is(true)); @@ -53,7 +53,7 @@ public void diagnosticEventsSentToDiagnosticEndpoint() throws Exception { public void initialDiagnosticEventHasInitBody() throws Exception { MockEventSender es = new MockEventSender(); try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).diagnosticStore(diagnosticStore))) { - MockEventSender.Params req = es.awaitDiagnostic(); + CapturedPayload req = es.awaitDiagnostic(); DiagnosticEvent.Init initEvent = gson.fromJson(req.data, DiagnosticEvent.Init.class); @@ -75,7 +75,7 @@ public void periodicDiagnosticEventHasStatisticsBody() throws Exception { // Ignore the initial diagnostic event es.awaitDiagnostic(); ep.postDiagnostic(); - MockEventSender.Params periodicReq = es.awaitDiagnostic(); + CapturedPayload periodicReq = es.awaitDiagnostic(); assertNotNull(periodicReq); DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); @@ -113,7 +113,7 @@ public void periodicDiagnosticEventGetsEventsInLastBatchAndDeduplicatedUsers() t es.awaitAnalytics(); ep.postDiagnostic(); - MockEventSender.Params periodicReq = es.awaitRequest(); + CapturedPayload periodicReq = es.awaitRequest(); assertNotNull(periodicReq); DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); @@ -135,7 +135,7 @@ public void periodicDiagnosticEventsAreSentAutomatically() throws Exception { // Ignore the initial diagnostic event es.awaitDiagnostic(); - MockEventSender.Params periodicReq = es.awaitRequest(); + CapturedPayload periodicReq = es.awaitRequest(); assertNotNull(periodicReq); DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); @@ -206,7 +206,7 @@ public void customBaseUriIsPassedToEventSenderForDiagnosticEvents() throws Excep try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).eventsUri(uri).diagnosticStore(diagnosticStore))) { } - MockEventSender.Params p = es.awaitRequest(); + CapturedPayload p = es.awaitRequest(); assertThat(p.eventsBaseUri, equalTo(uri)); } } diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java index 2e33908..97b38c8 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java @@ -69,14 +69,12 @@ public void eventsAreFlushedAutomatically() throws Exception { @SuppressWarnings("unchecked") @Test public void eventsAreNotFlushedWhenNotConnected() throws Exception { - MockConnectionStatusMonitor connectionStatusMonitor = new MockConnectionStatusMonitor(); - connectionStatusMonitor.setConnected(false); MockEventSender es = new MockEventSender(); long briefFlushInterval = 50; try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es) - .connectionStatusMonitor(connectionStatusMonitor) - .flushIntervalMillis(briefFlushInterval))) { + .flushIntervalMillis(briefFlushInterval) + .initiallyOffline(true))) { Event.Custom event1 = customEvent(user, "event1").build(); Event.Custom event2 = customEvent(user, "event2").build(); ep.sendEvent(event1); @@ -84,7 +82,7 @@ public void eventsAreNotFlushedWhenNotConnected() throws Exception { es.expectNoRequests(200); - connectionStatusMonitor.setConnected(true); + ep.setOffline(false); List payload1 = es.getEventsFromLastRequest(); assertThat(payload1, contains(isCustomEvent(event1), isCustomEvent(event2))); @@ -171,7 +169,7 @@ public void customBaseUriIsPassedToEventSenderForAnalyticsEvents() throws Except ep.sendEvent(e); } - MockEventSender.Params p = es.awaitRequest(); + CapturedPayload p = es.awaitRequest(); assertThat(p.eventsBaseUri, equalTo(uri)); } From 49e8baec502cc74425ff23f3ddc88de93b0767b9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 18 Aug 2022 18:37:18 -0700 Subject: [PATCH 31/57] fix dependencies: don't want Guava or SLF4J --- buildSrc/src/main/kotlin/Dependencies.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 9cd1f79..4372eec 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -4,11 +4,9 @@ object Versions { const val gson = "2.8.9" - const val guava = "30.1-jre" const val launchdarklyJavaSdkCommon = "2.0.0-SNAPSHOT" const val launchdarklyLogging = "1.1.1" const val okhttp = "4.9.1" - const val slf4j = "1.7.21" const val testHelpers = "1.2.0" } @@ -22,16 +20,15 @@ object Libs { // We would put anything here that we want to go into the Gradle "implementation" // configuration, if and only if we want those things to show up in pom.xml. "com.google.code.gson:gson:${Versions.gson}", - "com.google.guava:guava:${Versions.guava}", "com.launchdarkly:launchdarkly-java-sdk-common:${Versions.launchdarklyJavaSdkCommon}", "com.launchdarkly:launchdarkly-logging:${Versions.launchdarklyLogging}", - "com.squareup.okhttp3:okhttp:${Versions.okhttp}", - "org.slf4j:slf4j-api:${Versions.slf4j}" + "com.squareup.okhttp3:okhttp:${Versions.okhttp}" ) val javaTestImplementation = listOf( "junit:junit:4.12", - "org.hamcrest:hamcrest-library:1.3" + "org.hamcrest:hamcrest-library:1.3", + "com.google.guava:guava:30.1-jre" // "com.launchdarkly:test-helpers:${Versions.testHelpers}" // test-helpers is special-cased in build.gradle.kts and build-android.gradle From 78f090d75afc853a014d61246d288c87f873fed2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 19 Aug 2022 14:34:52 -0700 Subject: [PATCH 32/57] add HTTP properties required by Android (header transformer, shared client) --- .../internal/events/DefaultEventSender.java | 13 ++- .../sdk/internal/http/HeadersTransformer.java | 15 +++ .../sdk/internal/http/HttpProperties.java | 106 ++++++++++++++++-- .../sdk/internal/events/BaseEventTest.java | 5 - .../events/DefaultEventSenderTest.java | 31 ++++- .../sdk/internal/http/HttpPropertiesTest.java | 63 ++++++++++- 6 files changed, 213 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/internal/http/HeadersTransformer.java diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java index 1e960bc..6bff559 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java @@ -42,6 +42,7 @@ public final class DefaultEventSender implements EventSender { private static final Object HTTP_DATE_FORMAT_LOCK = new Object(); // synchronize on this because DateFormat isn't thread-safe private final OkHttpClient httpClient; + private final boolean shouldCloseHttpClient; private final Headers baseHeaders; final long retryDelayMillis; // visible for testing private final LDLogger logger; @@ -58,7 +59,13 @@ public DefaultEventSender( long retryDelayMillis, LDLogger logger ) { - this.httpClient = httpProperties.toHttpClientBuilder().build(); + if (httpProperties.getSharedHttpClient() == null) { + this.httpClient = httpProperties.toHttpClientBuilder().build(); + shouldCloseHttpClient = true; + } else { + this.httpClient = httpProperties.getSharedHttpClient(); + shouldCloseHttpClient = false; + } this.logger = logger; this.baseHeaders = httpProperties.toHeadersBuilder() @@ -70,7 +77,9 @@ public DefaultEventSender( @Override public void close() throws IOException { - HttpProperties.shutdownHttpClient(httpClient); + if (shouldCloseHttpClient) { + HttpProperties.shutdownHttpClient(httpClient); + } } @Override diff --git a/src/main/java/com/launchdarkly/sdk/internal/http/HeadersTransformer.java b/src/main/java/com/launchdarkly/sdk/internal/http/HeadersTransformer.java new file mode 100644 index 0000000..944a2c6 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/http/HeadersTransformer.java @@ -0,0 +1,15 @@ +package com.launchdarkly.sdk.internal.http; + +import java.util.Map; + +/** + * Callback interface for dynamically configuring HTTP headers on a per-request basis. + */ +public interface HeadersTransformer { + /** + * Transforms the headers that will be added to a request. + * + * @param headers The unmodified headers the SDK prepared for the request + */ + void updateHeaders(Map headers); +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/http/HttpProperties.java b/src/main/java/com/launchdarkly/sdk/internal/http/HttpProperties.java index 5ad4ba5..3a7c1e4 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/http/HttpProperties.java +++ b/src/main/java/com/launchdarkly/sdk/internal/http/HttpProperties.java @@ -28,9 +28,11 @@ public final class HttpProperties { private final long connectTimeoutMillis; private final Map defaultHeaders; + private final HeadersTransformer headersTransformer; private final Proxy proxy; private final Authenticator proxyAuth; - private final SocketFactory socketFactory; + private final OkHttpClient sharedHttpClient; + private final SocketFactory socketFactory; private final long socketTimeoutMillis; private final SSLSocketFactory sslSocketFactory; private final X509TrustManager trustManager; @@ -40,6 +42,7 @@ public final class HttpProperties { * * @param connectTimeoutMillis connection timeout milliseconds * @param defaultHeaders headers to add to all requests + * @param headersTransformer optional callback to modify headers * @param proxy optional proxy * @param proxyAuth optional proxy authenticator * @param socketFactory optional socket factory @@ -47,19 +50,54 @@ public final class HttpProperties { * @param sslSocketFactory optional SSL socket factory * @param trustManager optional SSL trust manager */ - public HttpProperties(long connectTimeoutMillis, Map defaultHeaders, Proxy proxy, - Authenticator proxyAuth, SocketFactory socketFactory, long socketTimeoutMillis, SSLSocketFactory sslSocketFactory, - X509TrustManager trustManager) { + public HttpProperties( + long connectTimeoutMillis, + Map defaultHeaders, + HeadersTransformer headersTransformer, + Proxy proxy, + Authenticator proxyAuth, + SocketFactory socketFactory, + long socketTimeoutMillis, + SSLSocketFactory sslSocketFactory, + X509TrustManager trustManager + ) { super(); this.connectTimeoutMillis = connectTimeoutMillis <= 0 ? DEFAULT_TIMEOUT : connectTimeoutMillis; this.defaultHeaders = defaultHeaders == null ? Collections.emptyMap() : new HashMap<>(defaultHeaders); + this.headersTransformer = headersTransformer; this.proxy = proxy; this.proxyAuth = proxyAuth; + this.sharedHttpClient = null; this.socketFactory = socketFactory; this.socketTimeoutMillis = socketTimeoutMillis <= 0 ? DEFAULT_TIMEOUT : socketTimeoutMillis; this.sslSocketFactory = sslSocketFactory; this.trustManager = trustManager; } + + /** + * Constructs an instance with a preconfigured shared HTTP client. + * + * @param sharedHttpClient an existing HTTP client instance + * @param defaultHeaders headers to add to all requests + * @param headersTransformer optional callback to modify headers + */ + public HttpProperties( + OkHttpClient sharedHttpClient, + Map defaultHeaders, + HeadersTransformer headersTransformer + ) { + super(); + this.defaultHeaders = defaultHeaders == null ? Collections.emptyMap() : new HashMap<>(defaultHeaders); + this.headersTransformer = headersTransformer; + this.sharedHttpClient = sharedHttpClient; + this.connectTimeoutMillis = DEFAULT_TIMEOUT; + this.proxy = null; + this.proxyAuth = null; + this.socketFactory = null; + this.socketTimeoutMillis = DEFAULT_TIMEOUT; + this.sslSocketFactory = null; + this.trustManager = null; + } /** * Returns a minimal set of properties. @@ -67,20 +105,67 @@ public HttpProperties(long connectTimeoutMillis, Map defaultHead * @return a default instance */ public static HttpProperties defaults() { - return new HttpProperties(0, null, null, null, null, 0, null, null); + return new HttpProperties(0, null, null, null, null, null, 0, null, null); } /** - * Returns an immutable view of the default headers. + * Returns an immutable view of the default headers. This does not include applying + * the configured {@link HeadersTransformer}, if any. * * @return the default headers + * @see #toHeadersBuilder() */ public Iterable> getDefaultHeaders() { return defaultHeaders.entrySet(); } + /** + * Returns an immutable view of the headers to add to a request. This includes applying + * the configured {@link HeadersTransformer}, if any. + * + * @return the default headers + * @see #toHeadersBuilder() + */ + public Iterable> getTransformedDefaultHeaders() { + if (headersTransformer == null) { + return defaultHeaders.entrySet(); + } + Map ret = new HashMap<>(defaultHeaders); + headersTransformer.updateHeaders(ret); + return ret.entrySet(); + } + + /** + * Returns the callback for transforming headers, if any. + * + * @return a {@link HeadersTransformer} or null + * @see #toHeadersBuilder() + */ + public HeadersTransformer getHeadersTransformer() { + return headersTransformer; + } + + /** + * Returns a preconfigured shared HTTP client, if one was defined. + *

+ * SDK components that use {@link HttpProperties} should check this method first before + * attempting to build their own client. If it returns a non-null value, they should use + * that client; in that case, no other properties except the default headers are relevant, + * and they should not take ownership of the client (that is, do not close the client when + * the component is closed). + * + * @return an HTTP client or null + */ + public OkHttpClient getSharedHttpClient() { + return sharedHttpClient; + } + /** * Applies the configured properties to an OkHttp client builder. + *

+ * SDK components that use {@link HttpProperties} should check {@link #getSharedHttpClient()} + * first before attempting to build their own client. The {@link #applyToHttpClientBuilder(okhttp3.OkHttpClient.Builder)} + * method will not provide a correct configuration if a shared client was specified. * * @param builder the client builder */ @@ -113,6 +198,10 @@ public void applyToHttpClientBuilder(OkHttpClient.Builder builder) { /** * Returns an OkHttp client builder initialized with the configured properties. + *

+ * SDK components that use {@link HttpProperties} should check {@link #getSharedHttpClient()} + * first before attempting to build their own client. The {@link #toHttpClientBuilder()} method + * will not provide a correct configuration if a shared client was specified. * * @return a client builder */ @@ -123,13 +212,14 @@ public OkHttpClient.Builder toHttpClientBuilder() { } /** - * Returns an OkHttp Headers builder initialized with the default headers. + * Returns an OkHttp Headers builder initialized with the default headers. This includes + * calling the configured {@link HeadersTransformer}, if any. * * @return a Headers builder */ public Headers.Builder toHeadersBuilder() { Headers.Builder builder = new Headers.Builder(); - for (Map.Entry kv: getDefaultHeaders()) { + for (Map.Entry kv: getTransformedDefaultHeaders()) { builder.add(kv.getKey(), kv.getValue()); } return builder; diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java index b7adfd0..0f894ef 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java @@ -9,7 +9,6 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.ObjectBuilder; import com.launchdarkly.sdk.internal.BaseTest; -import com.launchdarkly.sdk.internal.http.HttpProperties; import com.launchdarkly.testhelpers.JsonAssertions; import com.launchdarkly.testhelpers.JsonTestValue; @@ -68,10 +67,6 @@ public static EventsConfigurationBuilder baseConfig(EventSender es) { return new EventsConfigurationBuilder().eventSender(es); } - public static HttpProperties defaultHttpProperties() { - return new HttpProperties(0, null, null, null, null, 0, null, null); - } - public DefaultEventProcessor makeEventProcessor(EventsConfigurationBuilder ec) { return makeEventProcessor(ec, null); } diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java index cbca6d9..dc135ae 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.internal.events; +import com.launchdarkly.sdk.internal.http.HeadersTransformer; import com.launchdarkly.sdk.internal.http.HttpProperties; import com.launchdarkly.testhelpers.httptest.Handler; import com.launchdarkly.testhelpers.httptest.Handlers; @@ -46,7 +47,7 @@ protected boolean enableTestInAndroid() { } private EventSender makeEventSender() { - return makeEventSender(defaultHttpProperties()); + return makeEventSender(HttpProperties.defaults()); } private EventSender makeEventSender(HttpProperties httpProperties) { @@ -92,7 +93,7 @@ public void headersAreSentForAnalytics() throws Exception { Map headers = new HashMap<>(); headers.put("name1", "value1"); headers.put("name2", "value2"); - HttpProperties httpProperties = new HttpProperties(0, headers, null, null, null, 0, null, null); + HttpProperties httpProperties = new HttpProperties(0, headers, null, null, null, null, 0, null, null); try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { try (EventSender es = makeEventSender(httpProperties)) { @@ -111,7 +112,7 @@ public void headersAreSentForDiagnostics() throws Exception { Map headers = new HashMap<>(); headers.put("name1", "value1"); headers.put("name2", "value2"); - HttpProperties httpProperties = new HttpProperties(0, headers, null, null, null, 0, null, null); + HttpProperties httpProperties = new HttpProperties(0, headers, null, null, null, null, 0, null, null); try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { try (EventSender es = makeEventSender(httpProperties)) { @@ -125,6 +126,30 @@ public void headersAreSentForDiagnostics() throws Exception { } } + @Test + public void headersTransformerIsApplied() throws Exception { + Map headers = new HashMap<>(); + headers.put("name1", "value1"); + headers.put("name2", "value2"); + HeadersTransformer headersTransformer = new HeadersTransformer() { + @Override + public void updateHeaders(Map h) { + h.put("name1", h.get("name1") + "a"); + } + }; + HttpProperties httpProperties = new HttpProperties(0, headers, headersTransformer, null, null, null, 0, null, null); + + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { + try (EventSender es = makeEventSender(httpProperties)) { + es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, server.getUri()); + } + + RequestInfo req = server.getRecorder().requireRequest(); + assertThat(req.getHeader("name1"), equalTo("value1a")); + assertThat(req.getHeader("name2"), equalTo("value2")); + } + } + @Test public void eventSchemaIsSentForAnalytics() throws Exception { try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { diff --git a/src/test/java/com/launchdarkly/sdk/internal/http/HttpPropertiesTest.java b/src/test/java/com/launchdarkly/sdk/internal/http/HttpPropertiesTest.java index 9464598..b73111a 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/http/HttpPropertiesTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/http/HttpPropertiesTest.java @@ -1,11 +1,17 @@ package com.launchdarkly.sdk.internal.http; +import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.internal.BaseTest; import org.junit.Test; +import java.util.HashMap; +import java.util.Map; + import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import okhttp3.Headers; import okhttp3.OkHttpClient; @SuppressWarnings("javadoc") @@ -14,7 +20,7 @@ public class HttpPropertiesTest extends BaseTest { public void testConnectTimeout() { HttpProperties hp = new HttpProperties( 100000, - null, null, null, null, 0, null, null); + null, null, null, null, null, 0, null, null); OkHttpClient httpClient = hp.toHttpClientBuilder().build(); try { assertEquals(100000, httpClient.connectTimeoutMillis()); @@ -26,7 +32,7 @@ public void testConnectTimeout() { @Test public void testSocketTimeout() { HttpProperties hp = new HttpProperties( - 0, null, null, null, null, + 0, null, null, null, null, null, 100000, null, null); OkHttpClient httpClient = hp.toHttpClientBuilder().build(); @@ -36,4 +42,57 @@ public void testSocketTimeout() { HttpProperties.shutdownHttpClient(httpClient); } } + + @Test + public void testDefaultHeaders() { + Map headers = new HashMap<>(); + headers.put("name1", "value1"); + headers.put("name2", "value2"); + HttpProperties hp = new HttpProperties( + 0, + headers, + null, null, null, null, 0, null, null); + + Map configured = ImmutableMap.copyOf(hp.getDefaultHeaders()); + assertEquals(headers, configured); + + Headers built = hp.toHeadersBuilder().build(); + assertEquals("value1", built.get("name1")); + assertEquals("value2", built.get("name2")); + } + + @Test + public void testTransformedDefaultHeaders() { + Map headers = new HashMap<>(); + headers.put("name1", "value1"); + headers.put("name2", "value2"); + HeadersTransformer headersTransformer = new HeadersTransformer() { + @Override + public void updateHeaders(Map h) { + h.put("name1", h.get("name1") + "a"); + } + }; + HttpProperties hp = new HttpProperties( + 0, + headers, headersTransformer, + null, null, null, 0, null, null); + + Map configured = ImmutableMap.copyOf(hp.getDefaultHeaders()); + assertEquals(headers, configured); + + Map transformed = ImmutableMap.copyOf(hp.getTransformedDefaultHeaders()); + assertEquals("value1a", transformed.get("name1")); + assertEquals("value2", transformed.get("name2")); + + Headers built = hp.toHeadersBuilder().build(); + assertEquals("value1a", built.get("name1")); + assertEquals("value2", built.get("name2")); + } + + @Test + public void testSharedHttpClient() { + OkHttpClient httpClient = new OkHttpClient(); + HttpProperties hp = new HttpProperties(httpClient, null, null); + assertSame(httpClient, hp.getSharedHttpClient()); + } } From 8303621cf3afd3e86386a5dabc48026263db55d9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 19 Aug 2022 14:48:34 -0700 Subject: [PATCH 33/57] set snapshot version to avoid accidentally releasing 1.0.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 703b346..61ef394 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.0.0 +version=1.0.0-SNAPSHOT # The following empty ossrh properties are used by LaunchDarkly's internal integration testing framework # and should not be needed for typical development purposes (including by third-party developers). ossrhUsername= From 41968e5a7afed851fa2047b0f42c1715793502ab Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 19 Aug 2022 15:06:07 -0700 Subject: [PATCH 34/57] surface blocking flush method --- .../events/DefaultEventProcessor.java | 11 +++++++++- .../DefaultEventProcessorDiagnosticsTest.java | 2 +- .../DefaultEventProcessorOutputTest.java | 8 +++---- .../events/DefaultEventProcessorTest.java | 22 +++++++++---------- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java index 02e2e96..6d195ec 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java @@ -122,12 +122,21 @@ public void sendEvent(Event e) { /** * Schedules an asynchronous flush. */ - public void flush() { + public void flushAsync() { if (!closed.get()) { postMessageAsync(MessageType.FLUSH, null); } } + /** + * Flushes and blocks until the flush is done. + */ + public void flushBlocking() { + if (!closed.get()) { + postMessageAndWait(MessageType.FLUSH, null); + } + } + /** * Tells the event processor whether we should be in background mode. This is only applicable in the client-side * (Android) SDK. In background mode, events mostly work the same but we do not send any periodic diagnostic events. diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java index d9c4f78..010b0ce 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java @@ -108,7 +108,7 @@ public void periodicDiagnosticEventGetsEventsInLastBatchAndDeduplicatedUsers() t ep.sendEvent(fe1); ep.sendEvent(fe2); - ep.flush(); + ep.flushAsync(); // Ignore normal events es.awaitAnalytics(); diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java index b993e25..8bbebba 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java @@ -213,8 +213,8 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { // Send and flush an event we don't care about, just so we'll receive "resp1" which sets the last server time ep.sendEvent(identifyEvent(LDContext.create("otherUser"))); - ep.flush(); - ep.waitUntilInactive(); // this ensures that it has received the first response, with the date + ep.flushBlocking(); // wait till flush is done so we know we received the first response, with the date + es.awaitRequest(); es.receivedParams.clear(); es.result = new EventSender.Result(true, false, null); @@ -244,8 +244,8 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { // Send and flush an event we don't care about, just to set the last server time ep.sendEvent(identifyEvent(LDContext.create("otherUser"))); - ep.flush(); - ep.waitUntilInactive(); // this ensures that it has received the first response, with the date + ep.flushBlocking(); // wait till flush is done so we know we received the first response, with the date + es.awaitRequest(); es.receivedParams.clear(); es.result = new EventSender.Result(true, false, null); diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java index 97b38c8..0350275 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorTest.java @@ -191,7 +191,7 @@ public void eventCapacityIsEnforced() throws Exception { // delay to keep EventDispatcher from being overwhelmed Thread.sleep(10); } - ep.flush(); + ep.flushAsync(); assertThat(es.getEventsFromLastRequest(), Matchers.iterableWithSize(capacity)); } } @@ -219,7 +219,7 @@ public void eventCapacityDoesNotPreventSummaryEventFromBeingSent() throws Except Thread.sleep(10); } - ep.flush(); + ep.flushAsync(); List eventsReceived = es.getEventsFromLastRequest(); assertThat(eventsReceived, Matchers.iterableWithSize(capacity + 1)); @@ -234,14 +234,14 @@ public void noMoreEventsAreProcessedAfterUnrecoverableError() throws Exception { try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { ep.sendEvent(identifyEvent(user)); - ep.flush(); + ep.flushAsync(); es.awaitRequest(); // allow a little time for the event processor to pass the "must shut down" signal back from the sender Thread.sleep(50); ep.sendEvent(identifyEvent(user)); - ep.flush(); + ep.flushAsync(); es.expectNoRequests(100); } } @@ -254,7 +254,7 @@ public void noMoreEventsAreProcessedAfterClosingEventProcessor() throws Exceptio ep.close(); ep.sendEvent(identifyEvent(user)); - ep.flush(); + ep.flushAsync(); es.expectNoRequests(100); } @@ -268,14 +268,14 @@ public void uncheckedExceptionFromEventSenderDoesNotStopWorkerThread() throws Ex es.fakeError = new RuntimeException("sorry"); ep.sendEvent(identifyEvent(user)); - ep.flush(); + ep.flushAsync(); es.awaitRequest(); // MockEventSender now throws an unchecked exception up to EventProcessor's flush worker - // verify that a subsequent flush still works es.fakeError = null; ep.sendEvent(identifyEvent(user)); - ep.flush(); + ep.flushAsync(); es.awaitRequest(); } } @@ -306,7 +306,7 @@ public void eventsAreKeptInBufferIfAllFlushWorkersAreBusy() throws Exception { try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { for (int i = 0; i < 5; i++) { ep.sendEvent(identifyEvent(user)); - ep.flush(); + ep.flushAsync(); es.awaitRequest(); // we don't need to see this payload, just throw it away } @@ -320,14 +320,14 @@ public void eventsAreKeptInBufferIfAllFlushWorkersAreBusy() throws Exception { // becomes free. Event.Identify event1 = identifyEvent(testUser1); ep.sendEvent(event1); - ep.flush(); + ep.flushAsync(); // Do an additional flush with another event. This time, the event processor should see that there's // no space available and simply ignore the flush request. There's no way to verify programmatically // that this has happened, so just give it a short delay. Event.Identify event2 = identifyEvent(testUser2); ep.sendEvent(event2); - ep.flush(); + ep.flushAsync(); Thread.sleep(100); // Enqueue a third event. The current payload should now be event2 + event3. @@ -343,7 +343,7 @@ public void eventsAreKeptInBufferIfAllFlushWorkersAreBusy() throws Exception { assertThat(es.getEventsFromLastRequest(), contains(isIdentifyEvent(event1, testUserJson1))); // Now a flush should succeed and send the current payload. - ep.flush(); + ep.flushAsync(); assertThat(es.getEventsFromLastRequest(), contains( isIdentifyEvent(event2, testUserJson2), isIdentifyEvent(event3, testUserJson3))); From d0c871fe97c84a2ac4723cc04aa630b4fdcd3712 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 19 Aug 2022 16:05:03 -0700 Subject: [PATCH 35/57] add interface to allow mocking --- .../events/DefaultEventProcessor.java | 32 +++----------- .../sdk/internal/events/EventProcessor.java | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/EventProcessor.java diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java index 6d195ec..c5c4f8b 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java @@ -44,7 +44,7 @@ * incrementing summary counters. When events are ready to deliver, it uses an * implementation of EventSender (normally DefaultEventSender) to deliver the JSON data. */ -public final class DefaultEventProcessor implements Closeable { +public final class DefaultEventProcessor implements Closeable, EventProcessor { private static final int INITIAL_OUTPUT_BUFFER_SIZE = 2000; private static final Gson gson = new Gson(); @@ -108,41 +108,28 @@ public DefaultEventProcessor( } } - /** - * Enqueues an event. - * - * @param e the input data - */ + @Override public void sendEvent(Event e) { if (!closed.get()) { postMessageAsync(MessageType.EVENT, e); } } - /** - * Schedules an asynchronous flush. - */ + @Override public void flushAsync() { if (!closed.get()) { postMessageAsync(MessageType.FLUSH, null); } } - /** - * Flushes and blocks until the flush is done. - */ + @Override public void flushBlocking() { if (!closed.get()) { postMessageAndWait(MessageType.FLUSH, null); } } - /** - * Tells the event processor whether we should be in background mode. This is only applicable in the client-side - * (Android) SDK. In background mode, events mostly work the same but we do not send any periodic diagnostic events. - * - * @param inBackground true if we should be in background mode - */ + @Override public void setInBackground(boolean inBackground) { synchronized (stateLock) { if (this.inBackground.getAndSet(inBackground) == inBackground) { @@ -153,14 +140,7 @@ public void setInBackground(boolean inBackground) { } } - /** - * Tells the event processor whether we should be in background mode. This is only applicable in the client-side - * (Android) SDK; in the server-side Java SDK, offline mode does not change dynamically and so we don't even - * bother to create an event processor if we're offline. In offline mode, events are enqueued but never flushed, - * and diagnostic events are not sent. - * - * @param offline true if we should be in offline mode - */ + @Override public void setOffline(boolean offline) { synchronized (stateLock) { if (this.offline.getAndSet(offline) == offline) { diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventProcessor.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventProcessor.java new file mode 100644 index 0000000..2f26e2f --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventProcessor.java @@ -0,0 +1,42 @@ +package com.launchdarkly.sdk.internal.events; + +/** + * Interface describing the {@link DefaultEventProcessor} methods. There will normally only ever + * be one implementation of this, but having an interface allows for mocking in tests. + */ +public interface EventProcessor { + /** + * Enqueues an event. + * + * @param e the input data + */ + void sendEvent(Event e); + + /** + * Schedules an asynchronous flush. + */ + void flushAsync(); + + /** + * Flushes and blocks until the flush is done. + */ + void flushBlocking(); + + /** + * Tells the event processor whether we should be in background mode. This is only applicable in the client-side + * (Android) SDK. In background mode, events mostly work the same but we do not send any periodic diagnostic events. + * + * @param inBackground true if we should be in background mode + */ + void setInBackground(boolean inBackground); + + /** + * Tells the event processor whether we should be in background mode. This is only applicable in the client-side + * (Android) SDK; in the server-side Java SDK, offline mode does not change dynamically and so we don't even + * bother to create an event processor if we're offline. In offline mode, events are enqueued but never flushed, + * and diagnostic events are not sent. + * + * @param offline true if we should be in offline mode + */ + void setOffline(boolean offline); +} From 4296d3a2d80d75f6a3b5e965e96b3e13c22d6795 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 24 Aug 2022 20:05:23 -0700 Subject: [PATCH 36/57] make event request paths configurable --- .../internal/events/DefaultEventSender.java | 31 ++++++++++++++++--- .../events/DefaultEventSenderTest.java | 24 ++++++++++++-- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java index 6bff559..0113645 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java @@ -30,9 +30,21 @@ * interface for the sake of testability. */ public final class DefaultEventSender implements EventSender { - static final long DEFAULT_RETRY_DELAY_MILLIS = 1000; - private static final String ANALYTICS_EVENTS_POST_REQUEST_PATH = "/bulk"; - private static final String DIAGNOSTIC_EVENTS_POST_REQUEST_PATH = "/diagnostic"; + /** + * Default value for {@code retryDelayMillis} parameter. + */ + public static final long DEFAULT_RETRY_DELAY_MILLIS = 1000; + + /** + * Default value for {@code analyticsRequestPath} parameter. + */ + public static final String DEFAULT_ANALYTICS_REQUEST_PATH = "/bulk"; + + /** + * Default value for {@code diagnosticRequestPath} parameter. + */ + public static final String DEFAULT_DIAGNOSTIC_REQUEST_PATH = "/diagnostic"; + private static final String EVENT_SCHEMA_HEADER = "X-LaunchDarkly-Event-Schema"; private static final String EVENT_SCHEMA_VERSION = "4"; private static final String EVENT_PAYLOAD_ID_HEADER = "X-LaunchDarkly-Payload-ID"; @@ -44,6 +56,8 @@ public final class DefaultEventSender implements EventSender { private final OkHttpClient httpClient; private final boolean shouldCloseHttpClient; private final Headers baseHeaders; + private final String analyticsRequestPath; + private final String diagnosticRequestPath; final long retryDelayMillis; // visible for testing private final LDLogger logger; @@ -51,11 +65,15 @@ public final class DefaultEventSender implements EventSender { * Creates an instance. * * @param httpProperties the HTTP configuration + * @param analyticsRequestPath the request path for posting analytics events + * @param diagnosticRequestPath the request path for posting diagnostic events * @param retryDelayMillis retry delay, or zero to use the default * @param logger the logger */ public DefaultEventSender( HttpProperties httpProperties, + String analyticsRequestPath, + String diagnosticRequestPath, long retryDelayMillis, LDLogger logger ) { @@ -72,6 +90,9 @@ public DefaultEventSender( .add("Content-Type", "application/json") .build(); + this.analyticsRequestPath = analyticsRequestPath == null ? DEFAULT_ANALYTICS_REQUEST_PATH : analyticsRequestPath; + this.diagnosticRequestPath = diagnosticRequestPath == null ? DEFAULT_DIAGNOSTIC_REQUEST_PATH : diagnosticRequestPath; + this.retryDelayMillis = retryDelayMillis <= 0 ? DEFAULT_RETRY_DELAY_MILLIS : retryDelayMillis; } @@ -103,10 +124,10 @@ private Result sendEventData(boolean isDiagnostic, byte[] data, int eventCount, String description; if (isDiagnostic) { - path = DIAGNOSTIC_EVENTS_POST_REQUEST_PATH; + path = diagnosticRequestPath; description = "diagnostic event"; } else { - path = ANALYTICS_EVENTS_POST_REQUEST_PATH; + path = analyticsRequestPath; String eventPayloadId = UUID.randomUUID().toString(); headersBuilder.add(EVENT_PAYLOAD_ID_HEADER, eventPayloadId); headersBuilder.add(EVENT_SCHEMA_HEADER, EVENT_SCHEMA_VERSION); diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java index dc135ae..eb83942 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java @@ -51,7 +51,7 @@ private EventSender makeEventSender() { } private EventSender makeEventSender(HttpProperties httpProperties) { - return new DefaultEventSender(httpProperties, BRIEF_RETRY_DELAY_MILLIS, testLogger); + return new DefaultEventSender(httpProperties, null, null, BRIEF_RETRY_DELAY_MILLIS, testLogger); } @Test @@ -65,7 +65,7 @@ public void analyticsDataIsDelivered() throws Exception { } RequestInfo req = server.getRecorder().requireRequest(); - assertEquals("/bulk", req.getPath()); + assertEquals(DefaultEventSender.DEFAULT_ANALYTICS_REQUEST_PATH, req.getPath()); assertThat(req.getHeader("content-type"), equalToIgnoringCase("application/json; charset=utf-8")); assertEquals(FAKE_DATA, req.getBody()); } @@ -82,12 +82,30 @@ public void diagnosticDataIsDelivered() throws Exception { } RequestInfo req = server.getRecorder().requireRequest(); - assertEquals("/diagnostic", req.getPath()); + assertEquals(DefaultEventSender.DEFAULT_DIAGNOSTIC_REQUEST_PATH, req.getPath()); assertThat(req.getHeader("content-type"), equalToIgnoringCase("application/json; charset=utf-8")); assertEquals(FAKE_DATA, req.getBody()); } } + @Test + public void customRequestPaths() throws Exception { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { + try (EventSender es = new DefaultEventSender(HttpProperties.defaults(), + "/custom/path/a", "/custom/path/d", BRIEF_RETRY_DELAY_MILLIS, testLogger)) { + EventSender.Result result = es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, server.getUri()); + assertTrue(result.isSuccess()); + result = es.sendDiagnosticEvent(FAKE_DATA_BYTES, server.getUri()); + assertTrue(result.isSuccess()); + } + + RequestInfo req1 = server.getRecorder().requireRequest(); + assertEquals("/custom/path/a", req1.getPath()); + RequestInfo req2 = server.getRecorder().requireRequest(); + assertEquals("/custom/path/d", req2.getPath()); + } + } + @Test public void headersAreSentForAnalytics() throws Exception { Map headers = new HashMap<>(); From 8c625403a881cf0da78b0c8b17d7c0963ad2fea0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 25 Aug 2022 17:33:00 -0700 Subject: [PATCH 37/57] comments --- .../sdk/internal/events/DefaultEventSender.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java index 0113645..7b6d271 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventSender.java @@ -36,12 +36,14 @@ public final class DefaultEventSender implements EventSender { public static final long DEFAULT_RETRY_DELAY_MILLIS = 1000; /** - * Default value for {@code analyticsRequestPath} parameter. + * Default value for {@code analyticsRequestPath} parameter, for the server-side SDK. + * The Android SDK should modify this value. */ public static final String DEFAULT_ANALYTICS_REQUEST_PATH = "/bulk"; /** - * Default value for {@code diagnosticRequestPath} parameter. + * Default value for {@code diagnosticRequestPath} parameter, for the server-side SDK. + * The Android SDK should modify this value. */ public static final String DEFAULT_DIAGNOSTIC_REQUEST_PATH = "/diagnostic"; From bd0f11510a1e79e300213973467aaaaf555fab14 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 29 Aug 2022 16:36:49 -0700 Subject: [PATCH 38/57] rm unnecessary build job dependency --- .circleci/config.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 711d86a..c575bcd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,8 +27,6 @@ workflows: - test-windows: name: Java 11 - Windows - OpenJDK openjdk-version: 11.0.2.01 - requires: - - build-linux - test-windows: name: Java 17 - Windows - OpenJDK openjdk-version: 17.0.1 From b0b5d9a4bdc987f7e7dd88171f08d28e6c3fa40c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 29 Aug 2022 19:34:26 -0700 Subject: [PATCH 39/57] use newer CircleCI orbs for Android and Windows (#4) --- .circleci/config.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3b35190..5a7d43b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,8 +1,8 @@ version: 2.1 orbs: - win: circleci/windows@1.0.0 - android: circleci/android@1.0 + win: circleci/windows@5.0.0 + android: circleci/android@2.1.2 workflows: test: @@ -103,13 +103,13 @@ jobs: openjdk-version: type: string executor: - name: win/vs2019 + name: win/server-2019 shell: powershell.exe steps: - checkout - run: name: uninstall previous openjdk - command: choco uninstall openjdk + command: choco uninstall -y openjdk - run: name: install OpenJDK command: choco install openjdk --version <> @@ -133,6 +133,7 @@ jobs: executor: name: android/android-machine resource-class: large + tag: 2021.10.1 steps: - checkout From b5b19064fd2e7ff1846bc18520f1fd0042c16408 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 28 Sep 2022 15:02:12 -0700 Subject: [PATCH 40/57] comment typo --- .../launchdarkly/sdk/internal/events/EventContextFormatter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java index 73f2e9e..694910b 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java @@ -131,7 +131,7 @@ private List writeRedactedValue( // If privateRef is non-null, then it is either an exact match for the property we're looking at, // or it refers to a subproperty of it (for instance, if we are redacting property "b" within - // attriute "a", it could be /a/b [depth 2] or /a/b/c [depth 3]). If the depth shows that it's an + // attribute "a", it could be /a/b [depth 2] or /a/b/c [depth 3]). If the depth shows that it's an // exact match, this whole value is redacted and we don't bother recursing. if (privateRef != null && privateRef.getDepth() == depth) { return addOrCreate(redacted, privateRef.toString()); From 7572e26dd5cd8dc1a60e597f676d1f64175013e0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 28 Sep 2022 15:03:48 -0700 Subject: [PATCH 41/57] rm obsolete test --- .../events/DefaultEventSenderTest.java | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java index cbca6d9..012ccba 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventSenderTest.java @@ -249,35 +249,6 @@ public void invalidServerDateIsIgnored() throws Exception { } } -// @Test -// public void testSpecialHttpConfigurations() throws Exception { -// Handler handler = eventsSuccessResponse(); -// -// TestHttpUtil.testWithSpecialHttpConfigurations(handler, -// (targetUri, goodHttpConfig) -> { -// HttpConfiguration config = goodHttpConfig.createHttpConfiguration(clientContext("", LDConfig.DEFAULT)); -// -// try (EventSender es = makeEventSender(ComponentsImpl.toHttpProperties(config))) { -// EventSender.Result result = es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, targetUri); -// -// assertTrue(result.isSuccess()); -// assertFalse(result.isMustShutDown()); -// } -// }, -// -// (targetUri, badHttpConfig) -> { -// HttpConfiguration config = badHttpConfig.createHttpConfiguration(clientContext("", LDConfig.DEFAULT)); -// -// try (EventSender es = makeEventSender(ComponentsImpl.toHttpProperties(config))) { -// EventSender.Result result = es.sendAnalyticsEvents(FAKE_DATA_BYTES, 1, targetUri); -// -// assertFalse(result.isSuccess()); -// assertFalse(result.isMustShutDown()); -// } -// } -// ); -// } - @Test public void baseUriDoesNotNeedTrailingSlash() throws Exception { try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { From eb8a45f3a2c3a806218f8b9e8454af2f9ba1913e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 28 Sep 2022 18:59:52 -0700 Subject: [PATCH 42/57] don't use Gson reflection to write diagnostic events --- .../events/DefaultEventProcessor.java | 4 +- .../sdk/internal/events/DiagnosticEvent.java | 133 +++++++++-------- .../sdk/internal/events/DiagnosticStore.java | 10 +- .../DefaultEventProcessorDiagnosticsTest.java | 60 ++++---- .../internal/events/DiagnosticEventTest.java | 6 +- .../internal/events/DiagnosticStoreTest.java | 139 ++++++++++-------- 6 files changed, 190 insertions(+), 162 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java index c5c4f8b..3bbd217 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java @@ -630,12 +630,12 @@ public void run() { try { ByteArrayOutputStream buffer = new ByteArrayOutputStream(INITIAL_OUTPUT_BUFFER_SIZE); Writer writer = new BufferedWriter(new OutputStreamWriter(buffer, Charset.forName("UTF-8")), INITIAL_OUTPUT_BUFFER_SIZE); - gson.toJson(diagnosticEvent, writer); + gson.toJson(diagnosticEvent.value, writer); writer.flush(); EventSender.Result result = eventsConfig.eventSender.sendDiagnosticEvent( buffer.toByteArray(), eventsConfig.eventsUri); handleResponse(result); - if (diagnosticEvent instanceof DiagnosticEvent.Init) { + if (diagnosticEvent.initEvent) { didSendInitEvent.set(true); } } catch (Exception e) { diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticEvent.java b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticEvent.java index 55e69c0..635d31a 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticEvent.java @@ -1,6 +1,8 @@ package com.launchdarkly.sdk.internal.events; +import com.launchdarkly.sdk.ArrayBuilder; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; import java.util.List; @@ -8,74 +10,79 @@ * Base class for diagnostic events. This class and its subclasses are used only for JSON serialization. */ public class DiagnosticEvent { - final String kind; - final long creationDate; - final DiagnosticId id; - - DiagnosticEvent(String kind, long creationDate, DiagnosticId id) { - this.kind = kind; - this.creationDate = creationDate; - this.id = id; + final boolean initEvent; + final LDValue value; + + private DiagnosticEvent(boolean initEvent, LDValue value) { + this.initEvent = initEvent; + this.value = value; } - - /** - * Stream initialization data in a diagnostic event. - */ - @SuppressWarnings("javadoc") - public static class StreamInit { - public final long timestamp; - public final long durationMillis; - public final boolean failed; + + static DiagnosticEvent makeInit( + long creationDate, + DiagnosticId diagnosticId, + LDValue sdk, + LDValue configuration, + LDValue platform + ) { + return new DiagnosticEvent( + true, + baseBuilder("diagnostic-init", creationDate, diagnosticId) + .put("sdk", sdk) + .put("configuration", configuration) + .put("platform", platform) + .build() + ); + } + + static DiagnosticEvent makeStatistics( + long creationDate, + DiagnosticId diagnosticId, + long dataSinceDate, + long droppedEvents, + long deduplicatedUsers, + long eventsInLastBatch, + List streamInits + ) { + ObjectBuilder b = baseBuilder("diagnostic", creationDate, diagnosticId) + .put("dataSinceDate", dataSinceDate) + .put("droppedEvents", droppedEvents) + .put("deduplicatedUsers", deduplicatedUsers) + .put("eventsInLastBatch", eventsInLastBatch); + ArrayBuilder ab = LDValue.buildArray(); + if (streamInits != null) { + for (StreamInit si: streamInits) { + ab.add(LDValue.buildObject() + .put("timestamp", si.timestamp) + .put("durationMillis", si.durationMillis) + .put("failed", si.failed) + .build()); + } + } + b.put("streamInits", ab.build()); + return new DiagnosticEvent(false, b.build()); + } + + private static ObjectBuilder baseBuilder(String kind, long creationDate, DiagnosticId id) { + return LDValue.buildObject() + .put("kind", kind) + .put("creationDate", creationDate) + .put("id", LDValue.buildObject() + .put("diagnosticId", id.diagnosticId) + .put("sdkKeySuffix", id.sdkKeySuffix) + .build() + ); + } + + static class StreamInit { + final long timestamp; + final long durationMillis; + final boolean failed; StreamInit(long timestamp, long durationMillis, boolean failed) { this.timestamp = timestamp; this.durationMillis = durationMillis; this.failed = failed; } - } - - /** - * A diagnostic statistics event. - */ - @SuppressWarnings("javadoc") - public static class Statistics extends DiagnosticEvent { - public final long dataSinceDate; - public final long droppedEvents; - public final long deduplicatedUsers; - public final long eventsInLastBatch; - public final List streamInits; - - Statistics(long creationDate, DiagnosticId id, long dataSinceDate, long droppedEvents, long deduplicatedUsers, - long eventsInLastBatch, List streamInits) { - super("diagnostic", creationDate, id); - this.dataSinceDate = dataSinceDate; - this.droppedEvents = droppedEvents; - this.deduplicatedUsers = deduplicatedUsers; - this.eventsInLastBatch = eventsInLastBatch; - this.streamInits = streamInits; - } - } - - /** - * A diagnostic initialization event. - */ - @SuppressWarnings("javadoc") - public static class Init extends DiagnosticEvent { - public final LDValue sdk; - public final LDValue configuration; - public final LDValue platform; - - Init( - long creationDate, - DiagnosticId diagnosticId, - LDValue sdk, - LDValue configuration, - LDValue platform - ) { - super("diagnostic-init", creationDate, diagnosticId); - this.sdk = sdk; - this.configuration = configuration; - this.platform = platform; - } - } + } } diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticStore.java b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticStore.java index dfcc102..9f938d5 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticStore.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticStore.java @@ -99,12 +99,12 @@ public long getDataSinceDate() { } /** - * Returns the initial diagnostic event. + * Returns the initial diagnostic event as a JSON object. * * @return the initial event */ - public DiagnosticEvent.Init getInitEvent() { - return new DiagnosticEvent.Init(creationDate, diagnosticId, + public DiagnosticEvent getInitEvent() { + return DiagnosticEvent.makeInit(creationDate, diagnosticId, makeInitEventSdkData(), makeInitEventConfigData(), makeInitEventPlatformData()); } @@ -190,7 +190,7 @@ public void recordEventsInBatch(int eventsInBatch) { * @param deduplicatedContexts number of deduplicated contexts * @return the event */ - public DiagnosticEvent.Statistics createEventAndReset(long droppedEvents, long deduplicatedContexts) { + public DiagnosticEvent createEventAndReset(long droppedEvents, long deduplicatedContexts) { long currentTime = System.currentTimeMillis(); List eventInits; synchronized (streamInitsLock) { @@ -198,7 +198,7 @@ public DiagnosticEvent.Statistics createEventAndReset(long droppedEvents, long d streamInits = new ArrayList<>(); } long eventsInBatch = eventsInLastBatch.getAndSet(0); - DiagnosticEvent.Statistics res = new DiagnosticEvent.Statistics(currentTime, diagnosticId, dataSinceDate, droppedEvents, + DiagnosticEvent res = DiagnosticEvent.makeStatistics(currentTime, diagnosticId, dataSinceDate, droppedEvents, deduplicatedContexts, eventsInBatch, eventInits); dataSinceDate = currentTime; return res; diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java index 010b0ce..3e95ac6 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorDiagnosticsTest.java @@ -6,10 +6,14 @@ import java.net.URI; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonNull; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonProperty; +import static com.launchdarkly.testhelpers.JsonTestValue.jsonOf; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; -import static org.junit.Assert.assertEquals; +import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertNotNull; /** @@ -55,18 +59,18 @@ public void initialDiagnosticEventHasInitBody() throws Exception { try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).diagnosticStore(diagnosticStore))) { CapturedPayload req = es.awaitDiagnostic(); - DiagnosticEvent.Init initEvent = gson.fromJson(req.data, DiagnosticEvent.Init.class); - - assertNotNull(initEvent); - assertThat(initEvent.kind, equalTo("diagnostic-init")); - assertThat(initEvent.id.diagnosticId, equalTo(diagnosticId.diagnosticId)); - assertThat(initEvent.id.sdkKeySuffix, equalTo(diagnosticId.sdkKeySuffix)); - assertNotNull(initEvent.configuration); - assertNotNull(initEvent.sdk); - assertNotNull(initEvent.platform); + assertThat(jsonOf(req.data), allOf( + jsonProperty("kind", "diagnostic-init"), + jsonProperty("id", jsonProperty("diagnosticId", diagnosticId.diagnosticId)), + jsonProperty("id", jsonProperty("sdkKeySuffix", diagnosticId.sdkKeySuffix)), + jsonProperty("configuration", not(jsonNull())), + jsonProperty("sdk", not(jsonNull())), + jsonProperty("platform", not(jsonNull())) + )); } } + @SuppressWarnings("unchecked") @Test public void periodicDiagnosticEventHasStatisticsBody() throws Exception { MockEventSender es = new MockEventSender(); @@ -77,18 +81,16 @@ public void periodicDiagnosticEventHasStatisticsBody() throws Exception { ep.postDiagnostic(); CapturedPayload periodicReq = es.awaitDiagnostic(); - assertNotNull(periodicReq); - DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); - - assertNotNull(statsEvent); - assertThat(statsEvent.kind, equalTo("diagnostic")); - assertThat(statsEvent.id.diagnosticId, equalTo(diagnosticId.diagnosticId)); - assertThat(statsEvent.id.sdkKeySuffix, equalTo(diagnosticId.sdkKeySuffix)); - assertThat(statsEvent.dataSinceDate, equalTo(dataSinceDate)); - assertThat(statsEvent.creationDate, equalTo(diagnosticStore.getDataSinceDate())); - assertThat(statsEvent.deduplicatedUsers, equalTo(0L)); - assertThat(statsEvent.eventsInLastBatch, equalTo(0L)); - assertThat(statsEvent.droppedEvents, equalTo(0L)); + assertThat(jsonOf(periodicReq.data), allOf( + jsonProperty("kind", "diagnostic"), + jsonProperty("id", jsonProperty("diagnosticId", diagnosticId.diagnosticId)), + jsonProperty("id", jsonProperty("sdkKeySuffix", diagnosticId.sdkKeySuffix)), + jsonProperty("dataSinceDate", dataSinceDate), + jsonProperty("creationDate", diagnosticStore.getDataSinceDate()), + jsonProperty("deduplicatedUsers", 0), + jsonProperty("eventsInLastBatch", 0), + jsonProperty("droppedEvents", 0) + )); } } @@ -116,12 +118,12 @@ public void periodicDiagnosticEventGetsEventsInLastBatchAndDeduplicatedUsers() t CapturedPayload periodicReq = es.awaitRequest(); assertNotNull(periodicReq); - DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); - assertNotNull(statsEvent); - assertThat(statsEvent.deduplicatedUsers, equalTo(1L)); - assertThat(statsEvent.eventsInLastBatch, equalTo(2L)); // 1 index event + 1 summary event - assertThat(statsEvent.droppedEvents, equalTo(0L)); + assertThat(jsonOf(periodicReq.data), allOf( + jsonProperty("deduplicatedUsers", 1), + jsonProperty("eventsInLastBatch", 2), // 1 index event + 1 summary event + jsonProperty("droppedEvents", 0) + )); } } @@ -138,8 +140,8 @@ public void periodicDiagnosticEventsAreSentAutomatically() throws Exception { CapturedPayload periodicReq = es.awaitRequest(); assertNotNull(periodicReq); - DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); - assertEquals("diagnostic", statsEvent.kind); + + assertThat(jsonOf(periodicReq.data), jsonProperty("kind", "diagnostic")); } } diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticEventTest.java index 9eb8071..2cb9194 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticEventTest.java @@ -22,10 +22,10 @@ public class DiagnosticEventTest extends BaseTest { @Test public void testSerialization() { DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); - DiagnosticEvent.Statistics diagnosticStatisticsEvent = new DiagnosticEvent.Statistics(2000, diagnosticId, 1000, 1, 2, 3, testStreamInits); - JsonObject jsonObject = gsonInstance().toJsonTree(diagnosticStatisticsEvent).getAsJsonObject(); + DiagnosticEvent diagnosticStatisticsEvent = DiagnosticEvent.makeStatistics(2000, diagnosticId, 1000, 1, 2, 3, testStreamInits); + JsonObject jsonObject = gsonInstance().toJsonTree(diagnosticStatisticsEvent.value).getAsJsonObject(); assertEquals(8, jsonObject.size()); - assertEquals("diagnostic", diagnosticStatisticsEvent.kind); + assertEquals("diagnostic", jsonObject.getAsJsonPrimitive("kind").getAsString()); assertEquals(2000, jsonObject.getAsJsonPrimitive("creationDate").getAsLong()); JsonObject idObject = jsonObject.getAsJsonObject("id"); assertEquals("DK_KEY", idObject.getAsJsonPrimitive("sdkKeySuffix").getAsString()); diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticStoreTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticStoreTest.java index 6e2b917..097ed57 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticStoreTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DiagnosticStoreTest.java @@ -9,6 +9,7 @@ import java.util.Arrays; import java.util.List; +import static com.launchdarkly.testhelpers.JsonAssertions.isJsonArray; import static com.launchdarkly.testhelpers.JsonAssertions.jsonEqualsValue; import static com.launchdarkly.testhelpers.JsonAssertions.jsonProperty; import static com.launchdarkly.testhelpers.JsonAssertions.jsonUndefined; @@ -16,12 +17,13 @@ import static java.util.Collections.singletonMap; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.emptyIterable; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; -import static org.hamcrest.Matchers.notNullValue; -import static org.junit.Assert.assertEquals; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertSame; @SuppressWarnings("javadoc") public class DiagnosticStoreTest extends BaseTest { @@ -34,24 +36,24 @@ public class DiagnosticStoreTest extends BaseTest { public void initEventBasicProperties() { long now = System.currentTimeMillis(); DiagnosticStore store = makeSimpleStore(); - DiagnosticEvent.Init ie = store.getInitEvent(); - assertThat(ie.kind, equalTo("diagnostic-init")); - assertThat(ie.creationDate, greaterThanOrEqualTo(now)); - assertThat(ie.id, notNullValue()); - assertThat(ie.id.diagnosticId, notNullValue()); - assertThat(ie.id.sdkKeySuffix, equalTo("bcdefg")); + DiagnosticEvent ie = store.getInitEvent(); + assertThat(ie.initEvent, is(true)); + assertThat(ie.value.get("creationDate").longValue(), greaterThanOrEqualTo(now)); + assertThat(ie.value.get("id").get("diagnosticId"), not(equalTo(LDValue.ofNull()))); + assertThat(ie.value.get("id").get("sdkKeySuffix").stringValue(), equalTo("bcdefg")); } @Test public void initEventSdkData() { DiagnosticStore store = makeSimpleStore(); - DiagnosticEvent.Init ie = store.getInitEvent(); - assertThat(jsonFromValue(ie.sdk), allOf( - jsonProperty("name", SDK_NAME), - jsonProperty("version", SDK_VERSION), - jsonProperty("wrapperName", jsonUndefined()), - jsonProperty("wrapperVersion", jsonUndefined()) - )); + DiagnosticEvent ie = store.getInitEvent(); + assertThat(jsonFromValue(ie.value), + jsonProperty("sdk", allOf( + jsonProperty("name", SDK_NAME), + jsonProperty("version", SDK_VERSION), + jsonProperty("wrapperName", jsonUndefined()), + jsonProperty("wrapperVersion", jsonUndefined()) + ))); } @Test @@ -61,13 +63,14 @@ public void initEventSdkDataWithWrapperName() { singletonMap("X-LaunchDarkly-Wrapper", "Scala"), null )); - DiagnosticEvent.Init ie = store.getInitEvent(); - assertThat(jsonFromValue(ie.sdk), allOf( - jsonProperty("name", SDK_NAME), - jsonProperty("version", SDK_VERSION), - jsonProperty("wrapperName", "Scala"), - jsonProperty("wrapperVersion", jsonUndefined()) - )); + DiagnosticEvent ie = store.getInitEvent(); + assertThat(jsonFromValue(ie.value), + jsonProperty("sdk", allOf( + jsonProperty("name", SDK_NAME), + jsonProperty("version", SDK_VERSION), + jsonProperty("wrapperName", "Scala"), + jsonProperty("wrapperVersion", jsonUndefined()) + ))); } @Test @@ -77,13 +80,14 @@ public void initEventSdkDataWithWrapperNameAndVersion() { singletonMap("X-LaunchDarkly-Wrapper", "Scala/0.1"), null )); - DiagnosticEvent.Init ie = store.getInitEvent(); - assertThat(jsonFromValue(ie.sdk), allOf( - jsonProperty("name", SDK_NAME), - jsonProperty("version", SDK_VERSION), - jsonProperty("wrapperName", "Scala"), - jsonProperty("wrapperVersion", "0.1") - )); + DiagnosticEvent ie = store.getInitEvent(); + assertThat(jsonFromValue(ie.value), + jsonProperty("sdk", allOf( + jsonProperty("name", SDK_NAME), + jsonProperty("version", SDK_VERSION), + jsonProperty("wrapperName", "Scala"), + jsonProperty("wrapperVersion", "0.1") + ))); } @Test @@ -93,12 +97,13 @@ public void platformDataFromSdk() { LDValue.buildObject().put("prop1", 2).put("prop2", 3).build(), null, null )); - DiagnosticEvent.Init ie = store.getInitEvent(); - assertThat(jsonFromValue(ie.platform), allOf( - jsonProperty("name", PLATFORM_NAME), - jsonProperty("prop1", 2), - jsonProperty("prop2", 3) - )); + DiagnosticEvent ie = store.getInitEvent(); + assertThat(jsonFromValue(ie.value), + jsonProperty("platform", allOf( + jsonProperty("name", PLATFORM_NAME), + jsonProperty("prop1", 2), + jsonProperty("prop2", 3) + ))); } @Test @@ -117,45 +122,57 @@ public void configurationData() { SDK_KEY, SDK_NAME, SDK_VERSION, PLATFORM_NAME, null, null, configValues )); - DiagnosticEvent.Init ie = store.getInitEvent(); - assertThat(jsonFromValue(ie.configuration), jsonEqualsValue( - LDValue.buildObject() - .put(DiagnosticConfigProperty.EVENTS_CAPACITY.name, 1000) - .put(DiagnosticConfigProperty.USER_KEYS_CAPACITY.name, 2000) - .put(DiagnosticConfigProperty.DATA_STORE_TYPE.name, "custom") - .build() - )); + DiagnosticEvent ie = store.getInitEvent(); + assertThat(jsonFromValue(ie.value), + jsonProperty("configuration", jsonEqualsValue( + LDValue.buildObject() + .put(DiagnosticConfigProperty.EVENTS_CAPACITY.name, 1000) + .put(DiagnosticConfigProperty.USER_KEYS_CAPACITY.name, 2000) + .put(DiagnosticConfigProperty.DATA_STORE_TYPE.name, "custom") + .build() + ))); } @Test public void createsDiagnosticStatisticsEvent() { DiagnosticStore store = makeSimpleStore(); long startDate = store.getDataSinceDate(); - DiagnosticEvent.Statistics diagnosticStatisticsEvent = store.createEventAndReset(10, 15); - assertSame(store.getDiagnosticId(), diagnosticStatisticsEvent.id); - assertEquals(10, diagnosticStatisticsEvent.droppedEvents); - assertEquals(15, diagnosticStatisticsEvent.deduplicatedUsers); - assertEquals(0, diagnosticStatisticsEvent.eventsInLastBatch); - assertEquals(startDate, diagnosticStatisticsEvent.dataSinceDate); + DiagnosticEvent statsEvent = store.createEventAndReset(10, 15); + + assertThat(jsonFromValue(statsEvent.value), allOf( + jsonProperty("id", jsonProperty("diagnosticId", store.getDiagnosticId().diagnosticId)), + jsonProperty("droppedEvents", 10), + jsonProperty("deduplicatedUsers", 15), + jsonProperty("eventsInLastBatch", 0), + jsonProperty("dataSinceDate", startDate) + )); } @Test public void canRecordStreamInit() { DiagnosticStore store = makeSimpleStore(); store.recordStreamInit(1000, 200, false); - DiagnosticEvent.Statistics statsEvent = store.createEventAndReset(0, 0); - assertEquals(1, statsEvent.streamInits.size()); - assertEquals(1000, statsEvent.streamInits.get(0).timestamp); - assertEquals(200, statsEvent.streamInits.get(0).durationMillis); - assertEquals(false, statsEvent.streamInits.get(0).failed); + DiagnosticEvent statsEvent = store.createEventAndReset(0, 0); + + assertThat(jsonFromValue(statsEvent.value), + jsonProperty("streamInits", isJsonArray( + contains( + allOf( + jsonProperty("timestamp", 1000), + jsonProperty("durationMillis", 200), + jsonProperty("failed", false) + ) + ) + ))); } @Test public void canRecordEventsInBatch() { DiagnosticStore store = makeSimpleStore(); store.recordEventsInBatch(100); - DiagnosticEvent.Statistics statsEvent = store.createEventAndReset(0, 0); - assertEquals(100, statsEvent.eventsInLastBatch); + DiagnosticEvent statsEvent = store.createEventAndReset(0, 0); + assertThat(jsonFromValue(statsEvent.value), + jsonProperty("eventsInLastBatch", 100)); } @Test @@ -167,9 +184,11 @@ public void resetsStatsOnCreate() throws InterruptedException { Thread.sleep(2); // so that dataSinceDate will be different store.createEventAndReset(0, 0); assertNotEquals(startDate, store.getDataSinceDate()); - DiagnosticEvent.Statistics resetEvent = store.createEventAndReset(0,0); - assertEquals(0, resetEvent.streamInits.size()); - assertEquals(0, resetEvent.eventsInLastBatch); + DiagnosticEvent statsEvent = store.createEventAndReset(0, 0); + assertThat(jsonFromValue(statsEvent.value), allOf( + jsonProperty("eventsInLastBatch", 0), + jsonProperty("streamInits", isJsonArray(emptyIterable())) + )); } private static DiagnosticStore makeSimpleStore() { From a02a856904b04469021b8509a4c81d5601f99fd3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 3 Oct 2022 11:50:46 -0700 Subject: [PATCH 43/57] disable Windows Java 11 build --- .circleci/config.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 42e5462..24186cb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,9 +24,10 @@ workflows: with-coverage: true requires: - build-linux - - test-windows: - name: Java 11 - Windows - OpenJDK - openjdk-version: 11.0.2.01 +# Windows Java 11 build is temporarily disabled - see story 171428 +# - test-windows: +# name: Java 11 - Windows - OpenJDK +# openjdk-version: 11.0.2.01 - test-windows: name: Java 17 - Windows - OpenJDK openjdk-version: 17.0.1 From c566ffacb95e8949235a15fef6c667ca2b948d83 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 6 Oct 2022 17:28:51 -0700 Subject: [PATCH 44/57] allow some access to diagnostic event data for tests --- .../launchdarkly/sdk/internal/events/DiagnosticEvent.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticEvent.java b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticEvent.java index 635d31a..351912e 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticEvent.java @@ -18,6 +18,14 @@ private DiagnosticEvent(boolean initEvent, LDValue value) { this.value = value; } + /** + * Returns the JSON representation of the event. + * @return the JSON representation as an {@link LDValue} + */ + public LDValue getJsonValue() { + return value; + } + static DiagnosticEvent makeInit( long creationDate, DiagnosticId diagnosticId, From 421d70bf57fef0fd12f7fe44ebe0687039c80f04 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 21 Oct 2022 14:59:14 -0700 Subject: [PATCH 45/57] remove secondary meta-attribute --- .../internal/events/EventContextFormatter.java | 18 ++++++------------ .../events/EventContextFormatterTest.java | 7 ------- .../sdk/internal/events/EventOutputTest.java | 4 +--- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java index 694910b..1dcfcad 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java @@ -72,20 +72,14 @@ private void writeSingleKind(LDContext c, JsonWriter w, boolean includeKind) thr redacted = writeOrRedactAttribute(w, c, attrName, c.getValue(attrName), redacted); } - boolean haveRedacted = redacted != null && !redacted.isEmpty(), - haveSecondary = c.getSecondary() != null; - if (haveRedacted || haveSecondary) { + boolean haveRedacted = redacted != null && !redacted.isEmpty(); + if (haveRedacted) { w.name("_meta").beginObject(); - if (haveRedacted) { - w.name("redactedAttributes").beginArray(); - for (String a: redacted) { - w.value(a); - } - w.endArray(); - } - if (haveSecondary) { - w.name("secondary").value(c.getSecondary()); + w.name("redactedAttributes").beginArray(); + for (String a: redacted) { + w.value(a); } + w.endArray(); w.endObject(); } diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java index 73c4ec0..a241939 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java @@ -82,13 +82,6 @@ public static Iterable data() { new AttributeRef[0], "{\"kind\": \"org\", \"key\": \"my-key\", \"anonymous\": true}" }, - new Object[] { - "secondary", - LDContext.builder("my-key").kind("org").secondary("x").build(), - false, - new AttributeRef[0], - "{\"kind\": \"org\", \"key\": \"my-key\", \"_meta\": {\"secondary\": \"x\"}}" - }, new Object[] { "all attributes private globally", LDContext.builder("my-key").kind("org") diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java index afcc6d4..2168de4 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java @@ -30,7 +30,6 @@ public class EventOutputTest extends BaseEventTest { private ContextBuilder contextBuilderWithAllAttributes = LDContext.builder("userkey") .anonymous(true) .name("me") - .secondary("s") .set("custom1", "value1") .set("custom2", "value2"); private static final LDValue contextJsonWithAllAttributes = parseValue("{" + @@ -39,8 +38,7 @@ public class EventOutputTest extends BaseEventTest { "\"anonymous\":true," + "\"custom1\":\"value1\"," + "\"custom2\":\"value2\"," + - "\"name\":\"me\"," + - "\"_meta\":{\"secondary\":\"s\"}" + + "\"name\":\"me\"" + "}"); @Test From 27e6f161ceaeba9d4ec801d236389e8df1929bfb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 9 Nov 2022 16:42:05 -0800 Subject: [PATCH 46/57] allow some more diagnostic config properties that are used in Android --- .../sdk/internal/events/DiagnosticConfigProperty.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticConfigProperty.java b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticConfigProperty.java index 88106b8..e292690 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticConfigProperty.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DiagnosticConfigProperty.java @@ -27,7 +27,15 @@ public enum DiagnosticConfigProperty { USER_KEYS_FLUSH_INTERVAL_MILLIS("userKeysFlushIntervalMillis", LDValueType.NUMBER), USING_PROXY("usingProxy", LDValueType.BOOLEAN), USING_PROXY_AUTHENTICATOR("usingProxyAuthenticator", LDValueType.BOOLEAN), - USING_RELAY_DAEMON("usingRelayDaemon", LDValueType.BOOLEAN); + USING_RELAY_DAEMON("usingRelayDaemon", LDValueType.BOOLEAN), + + // the following properties are used only in Android + BACKGROUND_POLLING_INTERVAL_MILLIS("backgroundPollingIntervalMillis", LDValueType.NUMBER), + BACKGROUND_POLLING_DISABLED("backgroundPollingDisabled", LDValueType.BOOLEAN), + EVALUATION_REASONS_REQUESTED("evaluationReasonsRequested", LDValueType.BOOLEAN), + MAX_CACHED_USERS("maxCachedUsers", LDValueType.NUMBER), + MOBILE_KEY_COUNT("mobileKeyCount", LDValueType.NUMBER), + USE_REPORT("useReport", LDValueType.BOOLEAN); public final String name; public final LDValueType type; From a42011bdac95a6d567dc60bec0bb9bcccd801c62 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 1 Dec 2022 13:20:27 -0800 Subject: [PATCH 47/57] update java-sdk-common dependency --- buildSrc/src/main/kotlin/Dependencies.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 4372eec..1492991 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -4,7 +4,7 @@ object Versions { const val gson = "2.8.9" - const val launchdarklyJavaSdkCommon = "2.0.0-SNAPSHOT" + const val launchdarklyJavaSdkCommon = "2.0.0" const val launchdarklyLogging = "1.1.1" const val okhttp = "4.9.1" const val testHelpers = "1.2.0" From f31d8dff92d39c69d885c7922dbdf6c41448785d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 3 Jan 2023 09:47:50 -0800 Subject: [PATCH 48/57] update Gradle to 7.6 + fix snapshot releases (#11) * update Gradle to 7.3.3 + fix snapshot releases * pin the checkstyle version --- .circleci/config.yml | 6 + .ldrelease/config.yml | 2 +- .ldrelease/publish.sh | 7 +- build.gradle.kts | 1 + checkstyle.xml | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 59536 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 257 ++++++++++++++--------- 8 files changed, 169 insertions(+), 108 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 24186cb..4f520b6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -19,11 +19,17 @@ workflows: requires: - build-linux - test-linux: + # current LTS version name: Java 17 - Linux - OpenJDK docker-image: cimg/openjdk:17.0 with-coverage: true requires: - build-linux + - test-linux: + name: Java 19 - Linux - OpenJDK + docker-image: cimg/openjdk:19.0 + requires: + - build-linux # Windows Java 11 build is temporarily disabled - see story 171428 # - test-windows: # name: Java 11 - Windows - OpenJDK diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 9e85d61..167cc75 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -12,7 +12,7 @@ publications: jobs: - docker: - image: gradle:6.8.3-jdk11 + image: gradle:7.6-jdk11 template: name: gradle diff --git a/.ldrelease/publish.sh b/.ldrelease/publish.sh index a2e9637..677f638 100755 --- a/.ldrelease/publish.sh +++ b/.ldrelease/publish.sh @@ -4,4 +4,9 @@ set -ue # Publish to Sonatype echo "Publishing to Sonatype" -./gradlew publishToSonatype closeAndReleaseRepository || { echo "Gradle publish/release failed" >&2; exit 1; } +if [[ -n "${LD_RELEASE_IS_PRERELEASE}" ]]; then + ./gradlew publishToSonatype || { echo "Gradle publish/release failed" >&2; exit 1; } +else + ./gradlew publishToSonatype closeAndReleaseRepository || { echo "Gradle publish/release failed" >&2; exit 1; } +fi + diff --git a/build.gradle.kts b/build.gradle.kts index fd57c62..61f515e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { // see Dependencies.kt in buildSrc } checkstyle { + toolVersion = "9.3" configFile = file("${project.rootDir}/checkstyle.xml") } diff --git a/checkstyle.xml b/checkstyle.xml index 0b201f9..7800907 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -6,7 +6,7 @@ - + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..7454180f2ae8848c63b8b4dea2cb829da983f2fa 100644 GIT binary patch delta 18435 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 B8dv}T delta 17845 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 X49Luk)baa85H9$5YCsTPT`SVRWMtMW diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2e6e589..070cb70 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0..1b6c787 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# 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. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# 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/master/subprojects/plugins/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 -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +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 -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +APP_BASE_NAME=${0##*/} # 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"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +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 - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 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" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || 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 @@ -106,80 +140,95 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -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" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +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 - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + 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 - i=`expr $i + 1` + # 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 - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# 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. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" From ff56368972f05637efe35b04902ff0ecdd63ffcc Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Wed, 15 Mar 2023 14:41:54 -0500 Subject: [PATCH 49/57] Added addQueryParam in http helpers and some additional tests --- .../sdk/internal/http/HttpConsts.java | 5 ++ .../sdk/internal/http/HttpHelpers.java | 38 +++++++-- .../HttpHelpersAddQueryParamToUriTest.java | 59 ++++++++++++++ .../http/HttpHelpersConcatUriPathTest.java | 80 +++++++++++++++++++ 4 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/internal/http/HttpConsts.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/http/HttpHelpersAddQueryParamToUriTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/internal/http/HttpHelpersConcatUriPathTest.java diff --git a/src/main/java/com/launchdarkly/sdk/internal/http/HttpConsts.java b/src/main/java/com/launchdarkly/sdk/internal/http/HttpConsts.java new file mode 100644 index 0000000..0067d31 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/http/HttpConsts.java @@ -0,0 +1,5 @@ +package com.launchdarkly.sdk.internal.http; + +public final class HttpConsts { + public static final String QUERY_PARAM_FILTER = "filter"; +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/sdk/internal/http/HttpHelpers.java b/src/main/java/com/launchdarkly/sdk/internal/http/HttpHelpers.java index 87a3029..9d5d491 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/http/HttpHelpers.java +++ b/src/main/java/com/launchdarkly/sdk/internal/http/HttpHelpers.java @@ -1,6 +1,9 @@ package com.launchdarkly.sdk.internal.http; import java.net.URI; +import java.util.List; + +import okhttp3.HttpUrl; /** * Helper methods related to HTTP. @@ -12,18 +15,41 @@ public abstract class HttpHelpers { private HttpHelpers() {} /** - * Safely concatenates a path, ensuring that there is exactly one slash between components. + * Safely concatenates a path, ensuring that there is exactly one slash between segments. * - * @param baseUri the base URI + * @param uri the URI * @param path the path to add * @return a new URI */ - public static URI concatenateUriPath(URI baseUri, String path) { - String uriStr = baseUri.toString(); - String addPath = path.startsWith("/") ? path.substring(1) : path; - return URI.create(uriStr + (uriStr.endsWith("/") ? "" : "/") + addPath); + public static URI concatenateUriPath(URI uri, String path) { + HttpUrl.Builder concatBuilder = HttpUrl.get(uri).newBuilder(); + HttpUrl concatted = concatBuilder.addPathSegments(path).build(); + List segments = concatted.pathSegments(); + + // now remove empty segments. go in reverse to preserve indexes during modification + HttpUrl.Builder sanitizedBuilder = concatted.newBuilder(); + for (int i = segments.size() - 1; i >= 0; i--) { + if (segments.get(i).isEmpty()) { + sanitizedBuilder.removePathSegment(i); + } + } + return sanitizedBuilder.build().uri(); } + /** + * Adds the query param to the URI. + * + * @param uri the URI + * @param name the name of the parameter + * @param value the value of the parameter + * @return the modified URI + */ + public static URI addQueryParam(URI uri, String name, String value) { + // it is important to use get(String) instead of get(URI) because get(String) will throw an exception + // that includes useful information for the user to diagnose their URI.j + return HttpUrl.get(uri.toString()).newBuilder().addQueryParameter(name, value).build().uri(); +} + /** * Tests whether a string contains only characters that are safe to use in an HTTP header value. *

diff --git a/src/test/java/com/launchdarkly/sdk/internal/http/HttpHelpersAddQueryParamToUriTest.java b/src/test/java/com/launchdarkly/sdk/internal/http/HttpHelpersAddQueryParamToUriTest.java new file mode 100644 index 0000000..1671b61 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/http/HttpHelpersAddQueryParamToUriTest.java @@ -0,0 +1,59 @@ +package com.launchdarkly.sdk.internal.http; + +import static org.junit.Assert.assertEquals; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collection; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; + +import com.launchdarkly.sdk.internal.BaseTest; + +@RunWith(Parameterized.class) +public class HttpHelpersAddQueryParamToUriTest extends BaseTest { + + @Parameter(0) + public URI inputURI; + @Parameter(1) + public String inputKey; + @Parameter(2) + public String inputValue; + @Parameter(3) + public URI expectedURI; + + @Parameterized.Parameters + public static Collection data() { + // parameters and expected output + return Arrays.asList(new Object[][] { + // nice case + { URI.create("http://1.1.1.1"), "filter", "myFilter", URI.create("http://1.1.1.1/?filter=myFilter") }, + // encoding check + { URI.create("http://1.1.1.1"), "filter", "encoding necessary +! %& ( )", URI.create("http://1.1.1.1/?filter=encoding%20necessary%20%2B%21%20%25%26%20%28%20%29") }, + // existing param + { URI.create("http://1.1.1.1/?withReasons=true"), "filter", "myFilter", URI.create("http://1.1.1.1/?withReasons=true&filter=myFilter") }, + // order affects result (just including this for determinism, not a spec point) + { URI.create("http://1.1.1.1/?filter=myFilter"), "withReasons", "true", URI.create("http://1.1.1.1/?filter=myFilter&withReasons=true") }, + + // below are weird cases that we aren't expecting to encounter, just including for documentation of behavior + // adding param again + { URI.create("http://1.1.1.1/?filter=myFilter"), "filter", "anotherFilter", URI.create("http://1.1.1.1/?filter=myFilter&filter=anotherFilter") }, + // adding empty params and values + { URI.create("http://1.1.1.1/?filter=myFilter"), "", "", URI.create("http://1.1.1.1/?filter=myFilter&=") }, + }); + } + + @Test + public void TestParametricAddQueryParam() { + assertEquals(this.expectedURI, HttpHelpers.addQueryParam(this.inputURI, this.inputKey, this.inputValue)); + } + + @Test(expected = IllegalArgumentException.class) + public void TestImproperURIThrowsException() { + URI uriUnderTest = URI.create("ImARidiculousURI/?existingparam=existingvalue"); + HttpHelpers.addQueryParam(uriUnderTest, "notImportant", "notImportant"); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/http/HttpHelpersConcatUriPathTest.java b/src/test/java/com/launchdarkly/sdk/internal/http/HttpHelpersConcatUriPathTest.java new file mode 100644 index 0000000..9cbc170 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/internal/http/HttpHelpersConcatUriPathTest.java @@ -0,0 +1,80 @@ +package com.launchdarkly.sdk.internal.http; + +import static org.junit.Assert.assertEquals; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collection; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; + +import com.launchdarkly.sdk.internal.BaseTest; + +@RunWith(Parameterized.class) +public class HttpHelpersConcatUriPathTest extends BaseTest { + + @Parameter(0) + public URI inputURI; + @Parameter(1) + public String inputPath; + @Parameter(2) + public URI expectedURI; + + @Parameterized.Parameters + public static Collection data() { + + // parameters and expected output + return Arrays.asList(new Object[][] { + { URI.create("http://1.1.1.1"), "/status", URI.create("http://1.1.1.1/status") }, + { URI.create("http://1.1.1.1/"), "/status", URI.create("http://1.1.1.1/status") }, + { URI.create("http://1.1.1.1/"), "//status", URI.create("http://1.1.1.1/status") }, + { URI.create("http://google.com"), "/status", URI.create("http://google.com/status") }, + { URI.create("http://google.com"), "//status", URI.create("http://google.com/status") }, + { URI.create("http://google.com"), "///status", URI.create("http://google.com/status") }, + { URI.create("http://google.com/"), "/status", URI.create("http://google.com/status") }, + { URI.create("http://google.com/"), "//status", URI.create("http://google.com/status") }, + { URI.create("http://google.com/"), "///status", URI.create("http://google.com/status") }, + { URI.create("http://google.com//"), "/status", URI.create("http://google.com/status") }, + { URI.create("http://google.com//"), "//status", URI.create("http://google.com/status") }, + { URI.create("http://google.com//"), "///status", URI.create("http://google.com/status") }, + { URI.create("http://google.com///"), "/status", URI.create("http://google.com/status") }, + { URI.create("http://google.com///"), "//status", URI.create("http://google.com/status") }, + { URI.create("http://google.com///"), "///status", URI.create("http://google.com/status") }, + { URI.create("https://google.com"), "/status", URI.create("https://google.com/status") }, + { URI.create("https://google.com"), "//status", URI.create("https://google.com/status") }, + { URI.create("https://google.com"), "///status", URI.create("https://google.com/status") }, + { URI.create("https://google.com/"), "/status", URI.create("https://google.com/status") }, + { URI.create("https://google.com/"), "//status", URI.create("https://google.com/status") }, + { URI.create("https://google.com/"), "///status", URI.create("https://google.com/status") }, + { URI.create("https://google.com//"), "/status", URI.create("https://google.com/status") }, + { URI.create("https://google.com//"), "//status", URI.create("https://google.com/status") }, + { URI.create("https://google.com//"), "///status", URI.create("https://google.com/status") }, + { URI.create("https://google.com///"), "/status", URI.create("https://google.com/status") }, + { URI.create("https://google.com///"), "//status", URI.create("https://google.com/status") }, + { URI.create("https://google.com///"), "///status", URI.create("https://google.com/status") }, + { URI.create("https://google.com:1234"), "/status", URI.create("https://google.com:1234/status") }, + { URI.create("https://google.com:1234"), "//status", URI.create("https://google.com:1234/status") }, + { URI.create("https://google.com:1234"), "///status", URI.create("https://google.com:1234/status") }, + { URI.create("https://google.com:1234/"), "/status", URI.create("https://google.com:1234/status") }, + { URI.create("https://google.com:1234/"), "//status", URI.create("https://google.com:1234/status") }, + { URI.create("https://google.com:1234/"), "///status", URI.create("https://google.com:1234/status") }, + { URI.create("https://google.com:1234//"), "/status", URI.create("https://google.com:1234/status") }, + { URI.create("https://google.com:1234//"), "//status", URI.create("https://google.com:1234/status") }, + { URI.create("https://google.com:1234//"), "///status", URI.create("https://google.com:1234/status") }, + { URI.create("https://google.com:1234///"), "/status", URI.create("https://google.com:1234/status") }, + { URI.create("https://google.com:1234///"), "//status", URI.create("https://google.com:1234/status") }, + { URI.create("https://google.com:1234///"), "///status", URI.create("https://google.com:1234/status") }, + + // test to make sure query params don't get removed by append + { URI.create("https://google.com:1234/some/root/path/?filter=myFilter"), "/toAppend", URI.create("https://google.com:1234/some/root/path/toAppend?filter=myFilter") }, + }); + } + + @Test + public void TestConcatenateUriPath() { + assertEquals(this.expectedURI, HttpHelpers.concatenateUriPath(this.inputURI, this.inputPath)); + } +} From 2e09091d78c4f0abf2b770984975afebfdc2d837 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 20 Mar 2023 13:14:58 -0500 Subject: [PATCH 50/57] Added additional AddQueryParamToUri case and other non-behavior changes --- .../java/com/launchdarkly/sdk/internal/http/HttpConsts.java | 2 +- .../java/com/launchdarkly/sdk/internal/http/HttpHelpers.java | 2 +- .../sdk/internal/http/HttpHelpersAddQueryParamToUriTest.java | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/internal/http/HttpConsts.java b/src/main/java/com/launchdarkly/sdk/internal/http/HttpConsts.java index 0067d31..6595216 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/http/HttpConsts.java +++ b/src/main/java/com/launchdarkly/sdk/internal/http/HttpConsts.java @@ -2,4 +2,4 @@ public final class HttpConsts { public static final String QUERY_PARAM_FILTER = "filter"; -} \ No newline at end of file +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/http/HttpHelpers.java b/src/main/java/com/launchdarkly/sdk/internal/http/HttpHelpers.java index 9d5d491..8efaf42 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/http/HttpHelpers.java +++ b/src/main/java/com/launchdarkly/sdk/internal/http/HttpHelpers.java @@ -46,7 +46,7 @@ public static URI concatenateUriPath(URI uri, String path) { */ public static URI addQueryParam(URI uri, String name, String value) { // it is important to use get(String) instead of get(URI) because get(String) will throw an exception - // that includes useful information for the user to diagnose their URI.j + // that includes useful information for the user to diagnose their URI. return HttpUrl.get(uri.toString()).newBuilder().addQueryParameter(name, value).build().uri(); } diff --git a/src/test/java/com/launchdarkly/sdk/internal/http/HttpHelpersAddQueryParamToUriTest.java b/src/test/java/com/launchdarkly/sdk/internal/http/HttpHelpersAddQueryParamToUriTest.java index 1671b61..b051297 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/http/HttpHelpersAddQueryParamToUriTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/http/HttpHelpersAddQueryParamToUriTest.java @@ -37,6 +37,8 @@ public static Collection data() { { URI.create("http://1.1.1.1/?withReasons=true"), "filter", "myFilter", URI.create("http://1.1.1.1/?withReasons=true&filter=myFilter") }, // order affects result (just including this for determinism, not a spec point) { URI.create("http://1.1.1.1/?filter=myFilter"), "withReasons", "true", URI.create("http://1.1.1.1/?filter=myFilter&withReasons=true") }, + // existing path + { URI.create("http://1.1.1.1/a/path"), "filter", "myFilter", URI.create("http://1.1.1.1/a/path?filter=myFilter") }, // below are weird cases that we aren't expecting to encounter, just including for documentation of behavior // adding param again From ae380f1e90c994a74e8782ba8f5ed70dca8d3db6 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 26 Jun 2023 11:16:14 -0500 Subject: [PATCH 51/57] Bumping Guava to fix CVE-2023-2976 --- buildSrc/src/main/kotlin/Dependencies.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 1492991..2eef4c3 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -28,7 +28,7 @@ object Libs { val javaTestImplementation = listOf( "junit:junit:4.12", "org.hamcrest:hamcrest-library:1.3", - "com.google.guava:guava:30.1-jre" + "com.google.guava:guava:32.0.1-jre" // "com.launchdarkly:test-helpers:${Versions.testHelpers}" // test-helpers is special-cased in build.gradle.kts and build-android.gradle From f27bd43b21061e3eea5c33b837a478fa3ecc7ae1 Mon Sep 17 00:00:00 2001 From: "ld-repository-standards[bot]" <113625520+ld-repository-standards[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 22:45:37 +0000 Subject: [PATCH 52/57] Add file CODEOWNERS --- CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..7d0dac3 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,2 @@ +# Repository Maintainers +* @launchdarkly/team-sdk From f5752c96e7e1a3238b3a9504bfcadacaa1635dc1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 6 Sep 2023 15:51:18 -0700 Subject: [PATCH 53/57] chore: Fix Windows CI. (#16) --- .circleci/config.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4f520b6..df7bf66 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -118,12 +118,14 @@ jobs: command: choco uninstall -y openjdk - run: name: install OpenJDK - command: choco install openjdk --version <> + command: choco install openjdk --version <> -y - attach_workspace: at: build - run: name: run tests command: | + Import-Module $env:ChocolateyInstall\helpers\chocolateyProfile.psm1 + refreshenv .\gradlew.bat --no-daemon test # must use --no-daemon because CircleCI in Windows will hang if there's a daemon running - run: name: save test results From 2ba514fb060bf670ff9cd7e0ecb7f6a8b0a57b14 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 11 Oct 2023 13:41:20 -0700 Subject: [PATCH 54/57] chore: Update CODEOWNERS (#21) --- CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 7d0dac3..f541913 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1,2 @@ # Repository Maintainers -* @launchdarkly/team-sdk +* @launchdarkly/team-sdk-java From e06d72689de71130fb2ae297c813af6a66e4bbbc Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 11 Oct 2023 14:58:17 -0700 Subject: [PATCH 55/57] feat: Add support for migrations. (#20) --- .../events/DefaultEventProcessor.java | 18 +- .../sdk/internal/events/Event.java | 491 ++++++++++++++++-- .../internal/events/EventOutputFormatter.java | 140 ++++- .../sdk/internal/events/Sampler.java | 28 + .../sdk/internal/events/BaseEventTest.java | 24 +- .../DefaultEventProcessorOutputTest.java | 76 +++ .../sdk/internal/events/EventOutputTest.java | 316 ++++++++++- 7 files changed, 1045 insertions(+), 48 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/Sampler.java diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java index 3bbd217..1ad7a02 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java @@ -518,6 +518,16 @@ private void processEvent(Event e, EventBuffer outbox) { return; } + // For migration events we process them and exit early. They cannot generate additional event types or be + // summarized. + if(e instanceof Event.MigrationOp) { + Event.MigrationOp me = (Event.MigrationOp)e; + if (Sampler.shouldSample(me.getSamplingRatio())) { + outbox.add(e); + } + return; + } + LDContext context = e.getContext(); if (context == null) { return; // LDClient should never give us an event with no context @@ -531,7 +541,9 @@ private void processEvent(Event e, EventBuffer outbox) { if (e instanceof Event.FeatureRequest) { Event.FeatureRequest fe = (Event.FeatureRequest)e; - outbox.addToSummary(fe); + if(!fe.isExcludeFromSummaries()) { + outbox.addToSummary(fe); + } addFullEvent = fe.isTrackEvents(); if (shouldDebugEvent(fe)) { debugEvent = fe.toDebugEvent(); @@ -562,10 +574,10 @@ private void processEvent(Event e, EventBuffer outbox) { Event.Index ie = new Event.Index(e.getCreationDate(), e.getContext()); outbox.add(ie); } - if (addFullEvent) { + if (addFullEvent && Sampler.shouldSample(e.getSamplingRatio())) { outbox.add(e); } - if (debugEvent != null) { + if (debugEvent != null && Sampler.shouldSample(e.getSamplingRatio())) { outbox.add(debugEvent); } } diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/Event.java b/src/main/java/com/launchdarkly/sdk/internal/events/Event.java index 0767509..16c5b66 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/Event.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/Event.java @@ -3,10 +3,12 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * Base class for all analytics events that are generated by the client. Also defines all of its own subclasses. - * + *

* These types are not visible to applications; they are an implementation detail of the default event * processor. */ @@ -16,30 +18,44 @@ public class Event { /** * Base event constructor. + * * @param creationDate the timestamp in milliseconds - * @param context the context associated with the event + * @param context the context associated with the event */ public Event(long creationDate, LDContext context) { this.creationDate = creationDate; this.context = context; } - + /** * The event timestamp. + * * @return the timestamp in milliseconds */ public long getCreationDate() { return creationDate; } - + /** * The context associated with the event. + * * @return the context object */ public LDContext getContext() { return context; } + /** + * Ratio used for sampling the event. The default sampling ratio is 1. + *

+ * Currently, sampling applies to feature, debug, and migration events. + * + * @return the sampling ratio + */ + public long getSamplingRatio() { + return 1; + } + /** * A custom event created with one of the SDK's {@code track} methods. */ @@ -50,10 +66,11 @@ public static final class Custom extends Event { /** * Constructs a custom event. - * @param timestamp the timestamp in milliseconds - * @param key the event key - * @param context the context associated with the event - * @param data custom data if any (null is the same as {@link LDValue#ofNull()}) + * + * @param timestamp the timestamp in milliseconds + * @param key the event key + * @param context the context associated with the event + * @param data custom data if any (null is the same as {@link LDValue#ofNull()}) * @param metricValue custom metric value if any */ public Custom(long timestamp, String key, LDContext context, LDValue data, Double metricValue) { @@ -65,22 +82,25 @@ public Custom(long timestamp, String key, LDContext context, LDValue data, Doubl /** * The custom event key. + * * @return the event key */ public String getKey() { return key; } - + /** * The custom data associated with the event, if any. + * * @return the event data (null is equivalent to {@link LDValue#ofNull()}) */ public LDValue getData() { return data; } - + /** * The numeric metric value associated with the event, if any. + * * @return the metric value or null */ public Double getMetricValue() { @@ -95,8 +115,9 @@ public Double getMetricValue() { public static final class Identify extends Event { /** * Constructs an identify event. + * * @param timestamp the timestamp in milliseconds - * @param context the context associated with the event + * @param context the context associated with the event */ public Identify(long timestamp, LDContext context) { super(timestamp, context); @@ -109,14 +130,15 @@ public Identify(long timestamp, LDContext context) { public static final class Index extends Event { /** * Constructs an index event. + * * @param timestamp the timestamp in milliseconds - * @param context the context associated with the event + * @param context the context associated with the event */ public Index(long timestamp, LDContext context) { super(timestamp, context); } } - + /** * An event generated by a feature flag evaluation. */ @@ -131,24 +153,30 @@ public static final class FeatureRequest extends Event { private final Long debugEventsUntilDate; private final EvaluationReason reason; private final boolean debug; + private final long samplingRatio; + private final boolean excludeFromSummaries; /** * Constructs a feature request event. - * @param timestamp the timestamp in milliseconds - * @param key the flag key - * @param context the context associated with the event - * @param version the flag version, or -1 if the flag was not found - * @param variation the result variation, or -1 if there was an error - * @param value the result value - * @param defaultVal the default value passed by the application - * @param reason the evaluation reason, if it is to be included in the event - * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it - * @param trackEvents true if full event tracking is turned on for this flag + * + * @param timestamp the timestamp in milliseconds + * @param key the flag key + * @param context the context associated with the event + * @param version the flag version, or -1 if the flag was not found + * @param variation the result variation, or -1 if there was an error + * @param value the result value + * @param defaultVal the default value passed by the application + * @param reason the evaluation reason, if it is to be included in the event + * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it + * @param trackEvents true if full event tracking is turned on for this flag * @param debugEventsUntilDate if non-null, the time until which event debugging should be enabled - * @param debug true if this is a debugging event + * @param debug true if this is a debugging event + * @param excludeFromSummaries true to exclude the event from summaries + * @param samplingRatio the sampling ratio for the event */ public FeatureRequest(long timestamp, String key, LDContext context, int version, int variation, LDValue value, - LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, Long debugEventsUntilDate, boolean debug) { + LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, + Long debugEventsUntilDate, boolean debug, long samplingRatio, boolean excludeFromSummaries) { super(timestamp, context); this.key = key; this.version = version; @@ -160,10 +188,38 @@ public FeatureRequest(long timestamp, String key, LDContext context, int version this.debugEventsUntilDate = debugEventsUntilDate; this.reason = reason; this.debug = debug; + this.excludeFromSummaries = excludeFromSummaries; + this.samplingRatio = samplingRatio; + } + + /** + * Constructs a feature request event. + *

+ * This version of the constructor uses default values for the samplingRatio (1) and excludeFromSummaries (false). + * + * @param timestamp the timestamp in milliseconds + * @param key the flag key + * @param context the context associated with the event + * @param version the flag version, or -1 if the flag was not found + * @param variation the result variation, or -1 if there was an error + * @param value the result value + * @param defaultVal the default value passed by the application + * @param reason the evaluation reason, if it is to be included in the event + * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it + * @param trackEvents true if full event tracking is turned on for this flag + * @param debugEventsUntilDate if non-null, the time until which event debugging should be enabled + * @param debug true if this is a debugging event + */ + public FeatureRequest(long timestamp, String key, LDContext context, int version, int variation, LDValue value, + LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, + Long debugEventsUntilDate, boolean debug) { + this(timestamp, key, context, version, variation, value, defaultVal, reason, prereqOf, trackEvents, + debugEventsUntilDate, debug, 1, false); } /** * The key of the feature flag that was evaluated. + * * @return the flag key */ public String getKey() { @@ -172,6 +228,7 @@ public String getKey() { /** * The index of the selected flag variation, or -1 if the application default value was used. + * * @return zero-based index of the variation, or -1 */ public int getVariation() { @@ -180,6 +237,7 @@ public int getVariation() { /** * The value of the selected flag variation. + * * @return the value */ public LDValue getValue() { @@ -188,6 +246,7 @@ public LDValue getValue() { /** * The application default value used in the evaluation. + * * @return the application default */ public LDValue getDefaultVal() { @@ -196,6 +255,7 @@ public LDValue getDefaultVal() { /** * The version of the feature flag that was evaluated, or -1 if the flag was not found. + * * @return the flag version or null */ public int getVersion() { @@ -204,6 +264,7 @@ public int getVersion() { /** * If this flag was evaluated as a prerequisite for another flag, the key of the other flag. + * * @return a flag key or null */ public String getPrereqOf() { @@ -212,6 +273,7 @@ public String getPrereqOf() { /** * True if full event tracking is enabled for this flag. + * * @return true if full event tracking is on */ public boolean isTrackEvents() { @@ -220,6 +282,7 @@ public boolean isTrackEvents() { /** * If debugging is enabled for this flag, the Unix millisecond time at which to stop debugging. + * * @return a timestamp or null */ public Long getDebugEventsUntilDate() { @@ -227,7 +290,8 @@ public Long getDebugEventsUntilDate() { } /** - * The {@link EvaluationReason} for this evaluation, or null if the reason was not requested for this evaluation. + * The {@link EvaluationReason} for this evaluation, or null if the reason was not requested for this evaluation. + * * @return a reason object or null */ public EvaluationReason getReason() { @@ -236,20 +300,389 @@ public EvaluationReason getReason() { /** * True if this event was generated due to debugging being enabled. + * * @return true if this is a debug event */ public boolean isDebug() { return debug; } - + + public boolean isExcludeFromSummaries() { + return excludeFromSummaries; + } + + @Override + public long getSamplingRatio() { + return samplingRatio; + } + /** * Creates a debug event with the same properties as this event. + * * @return a debug event */ public FeatureRequest toDebugEvent() { return new FeatureRequest(getCreationDate(), getKey(), getContext(), getVersion(), getVariation(), getValue(), getDefaultVal(), getReason(), getPrereqOf(), - false, null, true); + false, null, true, samplingRatio, excludeFromSummaries); + } + } + + /** + * An event generated by a migration operation. + */ + public static final class MigrationOp extends Event { + private final String featureKey; + private final int variation; + private final LDValue value; + private final LDValue defaultVal; + private final EvaluationReason reason; + + private final long samplingRatio; + + private final String operation; + + private final int flagVersion; + + private final ConsistencyMeasurement consistencyMeasurement; + private final LatencyMeasurement latencyMeasurement; + private final ErrorMeasurement errorMeasurement; + private final InvokedMeasurement invokedMeasurement; + + /** + * Measurement used to indicate if the values in a read operation were consistent. + */ + public static final class ConsistencyMeasurement { + private final boolean consistent; + private final long samplingRatio; + + /** + * Construct a new consistency measurement. + * + * @param consistent true if the result was consistent + * @param samplingRatio the sampling ratio for the consistency check + */ + public ConsistencyMeasurement(boolean consistent, long samplingRatio) { + this.consistent = consistent; + this.samplingRatio = samplingRatio; + } + + /** + * Check if the operation was consistent. + * + * @return true if the operation was consistent + */ + public boolean isConsistent() { + return consistent; + } + + /** + * Get the sampling ratio for the consistency check. + * + * @return the sampling ratio + */ + public long getSamplingRatio() { + return samplingRatio; + } + } + + /** + * Latency measurement for a migration operation. + */ + public static final class LatencyMeasurement { + private final Long oldLatencyMs; + private final Long newLatencyMs; + + /** + * Construct a latency measurement. + * + * @param oldLatency the old method latency, in milliseconds, or null if the old method was not executed + * @param newLatency the new method latency, in milliseconds, or null if the new method was not executed + */ + public LatencyMeasurement(@Nullable Long oldLatency, @Nullable Long newLatency) { + this.oldLatencyMs = oldLatency; + this.newLatencyMs = newLatency; + } + + /** + * Get the old method execution latency in milliseconds. + * + * @return The old latency or null if the method was not invoked. + */ + public Long getOldLatencyMs() { + return oldLatencyMs; + } + + /** + * Get the new method execution latency in milliseconds. + * + * @return The new latency or null if the method was not invoked. + */ + public Long getNewLatencyMs() { + return newLatencyMs; + } + + /** + * Returns true if either of the durations are set. + * + * @return true if either of the durations are set + */ + public boolean hasMeasurement() { + return oldLatencyMs != null || newLatencyMs != null; + } + } + + /** + * Error measurement for a migration operation. + */ + public static final class ErrorMeasurement { + private final boolean oldError; + private final boolean newError; + + /** + * Construct an error measurement. + * + * @param oldError true if there was an error executing the old method + * @param newError true if there was an error executing the new method + */ + public ErrorMeasurement(boolean oldError, boolean newError) { + this.oldError = oldError; + this.newError = newError; + } + + /** + * Check if there was an error executing the old method. + * + * @return true if there was an error executing the old method + */ + public boolean hasOldError() { + return oldError; + } + + /** + * Check if there was an error executing the new method. + * + * @return true if there was an error executing the new method + */ + public boolean hasNewError() { + return newError; + } + + /** + * Returns true if there are errors present for either of the origins. + * + * @return true if errors are present + */ + public boolean hasMeasurement() { + return oldError || newError; + } + } + + /** + * Invoked measurement for a migration op. + *

+ * Indicates which origins/sources were executed while doing a migration operation. + */ + public static final class InvokedMeasurement { + private final boolean oldInvoked; + private final boolean newInvoked; + + /** + * Construct a new invoked measurement. + * + * @param oldInvoked true if old was invoked + * @param newInvoked true if new was invoked + */ + public InvokedMeasurement(boolean oldInvoked, boolean newInvoked) { + this.oldInvoked = oldInvoked; + this.newInvoked = newInvoked; + } + + /** + * Check if the old method was invoked. + * + * @return true if the old method was invoked + */ + public boolean wasOldInvoked() { + return oldInvoked; + } + + /** + * Check if the new method was invoked. + * + * @return true if the new method was invoked + */ + public boolean wasNewInvoked() { + return newInvoked; + } + } + + /** + * Construct a new migration operation event. + * + * @param timestamp the timestamp in milliseconds + * @param context the context associated with the event + * @param featureKey the flag key + * @param variation the result variation, or -1 if there was an error + * @param flagVersion the flag version, or -1 if the flag was not found + * @param value the result value + * @param defaultVal the default value passed by the application + * @param reason the evaluation reason, if it is to be included in the event + * @param samplingRatio the sampling ratio for this event + * @param operation the operation for the event + * @param invokedMeasurement measurement containing which origins were invoked + * @param consistencyMeasurement measurement containing results of a consistency check, or null if no check was done + * @param latencyMeasurement measurement containing the execution latencies of invoked methods, or null if no check + * was done + * @param errorMeasurement measurement reporting any errors, or null if no errors were encountered + */ + public MigrationOp( + long timestamp, + @NotNull LDContext context, + @NotNull String featureKey, + int variation, + int flagVersion, + @NotNull LDValue value, + @NotNull LDValue defaultVal, + @Nullable EvaluationReason reason, // For a server SDK this will not be null, but if it is ever used client side + // then likely this would be null unless evaluation reasons were requested. + long samplingRatio, + @NotNull String operation, + @NotNull InvokedMeasurement invokedMeasurement, // An invoked measurement is required. + @Nullable ConsistencyMeasurement consistencyMeasurement, + @Nullable LatencyMeasurement latencyMeasurement, + @Nullable ErrorMeasurement errorMeasurement + ) { + super(timestamp, context); + this.featureKey = featureKey; + this.variation = variation; + this.flagVersion = flagVersion; + this.value = value; + this.defaultVal = defaultVal; + this.reason = reason; + this.samplingRatio = samplingRatio; + this.operation = operation; + this.consistencyMeasurement = consistencyMeasurement; + this.latencyMeasurement = latencyMeasurement; + this.errorMeasurement = errorMeasurement; + this.invokedMeasurement = invokedMeasurement; + } + + /** + * The key of the feature flag that was evaluated. + * + * @return the flag key + */ + @NotNull + public String getFeatureKey() { + return featureKey; + } + + /** + * The index of the selected flag variation, or -1 if the application default value was used. + * + * @return zero-based index of the variation, or -1 + */ + public int getVariation() { + return variation; + } + + /** + * The version of the feature flag that was evaluated, or -1 if the flag was not found. + * + * @return the flag version or -1 + */ + public int getFlagVersion() { + return flagVersion; + } + + /** + * The value of the selected flag variation. + * + * @return the value + */ + @NotNull + public LDValue getValue() { + return value; + } + + /** + * The application default value used in the evaluation. + * + * @return the application default + */ + @NotNull + public LDValue getDefaultVal() { + return defaultVal; + } + + /** + * The {@link EvaluationReason} for this evaluation, or null if the reason was not requested for this evaluation. + * + * @return a reason object or null + */ + @Nullable + public EvaluationReason getReason() { + return reason; + } + + /** + * The {@link InvokedMeasurement} for this operation. + * + * @return the invoked measurement + */ + @NotNull + public InvokedMeasurement getInvokedMeasurement() { + return invokedMeasurement; + } + + /** + * The {@link LatencyMeasurement} for this operation. + * + * @return the latency measurement or null + */ + @Nullable + public LatencyMeasurement getLatencyMeasurement() { + return latencyMeasurement; + } + + /** + * The {@link ErrorMeasurement} for this operation. + * + * @return the error measurement or null + */ + @Nullable + public ErrorMeasurement getErrorMeasurement() { + return errorMeasurement; + } + + /** + * Get the {@link ConsistencyMeasurement} for this operation. + * + * @return the consistency measurement or null + */ + @Nullable + public ConsistencyMeasurement getConsistencyMeasurement() { + return consistencyMeasurement; + } + + /** + * Get the sampling ratio for this event. + * + * @return the sampling ratio + */ + @Override + public long getSamplingRatio() { + return samplingRatio; + } + + /** + * Get the migration operation for this event. + * + * @return the migration operation + */ + public String getOperation() { + return operation; } } -} \ No newline at end of file +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java index 933648b..538e8b6 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java @@ -19,7 +19,7 @@ * Transforms analytics events and summary data into the JSON format that we send to LaunchDarkly. * Rather than creating intermediate objects to represent this schema, we use the Gson streaming * output API to construct JSON directly. - * + *

* Test coverage for this logic is in EventOutputTest and DefaultEventProcessorOutputTest. The * handling of context data and private attribute redaction is implemented in EventContextFormatter * and tested in more detail in EventContextFormatterTest. @@ -33,7 +33,7 @@ final class EventOutputFormatter { config.privateAttributes.toArray(new AttributeRef[config.privateAttributes.size()])); } - final int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writer writer) throws IOException { + int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writer writer) throws IOException { int count = 0; JsonWriter jsonWriter = new JsonWriter(writer); jsonWriter.beginArray(); @@ -51,7 +51,7 @@ final int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary return count; } - private final boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException { + private boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException { if (event.getContext() == null || !event.getContext().isValid()) { // The SDK should never send us an event without a valid context, but if we somehow get one, // just skip the event since there's no way to serialize it. @@ -81,7 +81,7 @@ private final boolean writeOutputEvent(Event event, JsonWriter jw) throws IOExce jw.name("prereqOf"); jw.value(fe.getPrereqOf()); } - writeEvaluationReason("reason", fe.getReason(), jw); + writeEvaluationReason(fe.getReason(), jw); jw.endObject(); } else if (event instanceof Event.Identify) { jw.beginObject(); @@ -104,14 +104,130 @@ private final boolean writeOutputEvent(Event event, JsonWriter jw) throws IOExce jw.beginObject(); writeKindAndCreationDate(jw, "index", event.getCreationDate()); writeContext(event.getContext(), jw); + jw.endObject(); + } else if (event instanceof Event.MigrationOp) { + jw.beginObject(); + writeKindAndCreationDate(jw, "migration_op", event.getCreationDate()); + writeContextKeys(event.getContext(), jw); + + Event.MigrationOp me = (Event.MigrationOp)event; + jw.name("operation").value(me.getOperation()); + + long samplingRatio = me.getSamplingRatio(); + if(samplingRatio != 1) { + jw.name("samplingRatio").value(samplingRatio); + } + + writeMigrationEvaluation(jw, me); + writeMeasurements(jw, me); + jw.endObject(); } else { return false; } return true; } - - private final void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw) throws IOException { + + private static void writeMeasurements(JsonWriter jw, Event.MigrationOp me) throws IOException { + jw.name("measurements"); + jw.beginArray(); + + writeInvokedMeasurement(jw, me); + writeConsistencyMeasurement(jw, me); + writeLatencyMeasurement(jw, me); + writeErrorMeasurement(jw, me); + + jw.endArray(); // end measurements + } + + private static void writeErrorMeasurement(JsonWriter jw, Event.MigrationOp me) throws IOException { + Event.MigrationOp.ErrorMeasurement errorMeasurement = me.getErrorMeasurement(); + if(errorMeasurement != null && errorMeasurement.hasMeasurement()) { + jw.beginObject(); + jw.name("key").value("error"); + jw.name("values"); + jw.beginObject(); + if(errorMeasurement.hasOldError()) { + jw.name("old").value(errorMeasurement.hasOldError()); + } + if(errorMeasurement.hasNewError()) { + jw.name("new").value(errorMeasurement.hasNewError()); + } + jw.endObject(); // end of values + jw.endObject(); // end of measurement + } + } + + private static void writeLatencyMeasurement(JsonWriter jw, Event.MigrationOp me) throws IOException { + Event.MigrationOp.LatencyMeasurement latencyMeasurement = me.getLatencyMeasurement(); + if(latencyMeasurement != null && latencyMeasurement.hasMeasurement()) { + jw.beginObject(); + + jw.name("key").value("latency_ms"); + + jw.name("values"); + jw.beginObject(); + if(latencyMeasurement.getOldLatencyMs() != null) { + jw.name("old").value(latencyMeasurement.getOldLatencyMs()); + } + if(latencyMeasurement.getNewLatencyMs() != null) { + jw.name("new").value(latencyMeasurement.getNewLatencyMs()); + } + + jw.endObject(); // end of values + jw.endObject(); // end of measurement + } + } + + private static void writeConsistencyMeasurement(JsonWriter jw, Event.MigrationOp me) throws IOException { + Event.MigrationOp.ConsistencyMeasurement consistencyMeasurement = me.getConsistencyMeasurement(); + if(consistencyMeasurement != null) { + jw.beginObject(); + jw.name("key").value("consistent"); + jw.name("value").value(consistencyMeasurement.isConsistent()); + if(consistencyMeasurement.getSamplingRatio() != 1) { + jw.name("samplingRatio").value(consistencyMeasurement.getSamplingRatio()); + } + jw.endObject(); // end measurement + } + } + + private static void writeInvokedMeasurement(JsonWriter jw, Event.MigrationOp me) throws IOException { + jw.beginObject(); + jw.name("key").value("invoked"); + Event.MigrationOp.InvokedMeasurement invokedMeasurement = me.getInvokedMeasurement(); + + jw.name("values"); + jw.beginObject(); + if(invokedMeasurement.wasOldInvoked()) { + jw.name("old").value(invokedMeasurement.wasOldInvoked()); + } + if(invokedMeasurement.wasNewInvoked()) { + jw.name("new").value(invokedMeasurement.wasNewInvoked()); + } + jw.endObject(); // end values + jw.endObject(); // end measurement + } + + private void writeMigrationEvaluation(JsonWriter jw, Event.MigrationOp me) throws IOException { + jw.name("evaluation"); + jw.beginObject(); + jw.name("key").value(me.getFeatureKey()); + if (me.getVariation() >= 0) { + jw.name("variation"); + jw.value(me.getVariation()); + } + if (me.getFlagVersion() >= 0) { + jw.name("version"); + jw.value(me.getFlagVersion()); + } + writeLDValue("value", me.getValue(), jw); + writeLDValue("default", me.getDefaultVal(), jw); + writeEvaluationReason(me.getReason(), jw); + jw.endObject(); + } + + private void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw) throws IOException { jw.beginObject(); jw.name("kind"); @@ -174,17 +290,17 @@ private final void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonW jw.endObject(); // end of summary event object } - private final void writeKindAndCreationDate(JsonWriter jw, String kind, long creationDate) throws IOException { + private void writeKindAndCreationDate(JsonWriter jw, String kind, long creationDate) throws IOException { jw.name("kind").value(kind); jw.name("creationDate").value(creationDate); } - private final void writeContext(LDContext context, JsonWriter jw) throws IOException { + private void writeContext(LDContext context, JsonWriter jw) throws IOException { jw.name("context"); contextFormatter.write(context, jw); } - private final void writeContextKeys(LDContext context, JsonWriter jw) throws IOException { + private void writeContextKeys(LDContext context, JsonWriter jw) throws IOException { jw.name("contextKeys").beginObject(); for (int i = 0; i < context.getIndividualContextCount(); i++) { LDContext c = context.getIndividualContext(i); @@ -195,7 +311,7 @@ private final void writeContextKeys(LDContext context, JsonWriter jw) throws IOE jw.endObject(); } - private final void writeLDValue(String key, LDValue value, JsonWriter jw) throws IOException { + private void writeLDValue(String key, LDValue value, JsonWriter jw) throws IOException { if (value == null || value.isNull()) { return; } @@ -203,11 +319,11 @@ private final void writeLDValue(String key, LDValue value, JsonWriter jw) throws gsonInstance().toJson(value, LDValue.class, jw); // LDValue defines its own custom serializer } - private final void writeEvaluationReason(String key, EvaluationReason er, JsonWriter jw) throws IOException { + private void writeEvaluationReason(EvaluationReason er, JsonWriter jw) throws IOException { if (er == null) { return; } - jw.name(key); + jw.name("reason"); gsonInstance().toJson(er, EvaluationReason.class, jw); // EvaluationReason defines its own custom serializer } } diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/Sampler.java b/src/main/java/com/launchdarkly/sdk/internal/events/Sampler.java new file mode 100644 index 0000000..f86e02b --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/Sampler.java @@ -0,0 +1,28 @@ +package com.launchdarkly.sdk.internal.events; + +import java.util.concurrent.ThreadLocalRandom; + +/** + * Class used for event sampling. + */ +public final class Sampler { + /** + * Given a ratio determine if an event should be sampled. + * + * @param ratio the sampling ratio + * @return true if it should be sampled + */ + public static boolean shouldSample(long ratio) { + if(ratio == 1) { + return true; + } + if(ratio == 0) { + return false; + } + + // Checking for any number in the range will have approximately a 1 in X + // chance. So we check for 0 as it is part of any range. + // This random number is not used for cryptographic purposes. + return ThreadLocalRandom.current().nextLong(ratio) == 0; + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java index 0f894ef..d59bd98 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java @@ -203,6 +203,15 @@ public static Matcher isIdentifyEvent(Event sourceEvent, LDValue ); } + public static Matcher isMigrationEvent(Event sourceEvent, LDValue context) { + // Doesn't fully test an event, but makes sure it is a specific event. + return allOf( + jsonProperty("kind", "migration_op"), + jsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + hasContextKeys(sourceEvent) + ); + } + public static Matcher isIndexEvent() { return jsonProperty("kind", "index"); } @@ -462,6 +471,8 @@ public static final class FeatureRequestEventBuilder { private String prereqOf = null; private boolean trackEvents = false; private Long debugEventsUntilDate = null; + private long samplingRatio = 1; + private boolean excludeFromSummaries = false; public FeatureRequestEventBuilder(LDContext context, String flagKey) { this.context = context; @@ -470,7 +481,8 @@ public FeatureRequestEventBuilder(LDContext context, String flagKey) { public Event.FeatureRequest build() { return new Event.FeatureRequest(timestamp, flagKey, context, flagVersion, variation, value, - defaultValue, reason, prereqOf, trackEvents, debugEventsUntilDate, false); + defaultValue, reason, prereqOf, trackEvents, debugEventsUntilDate, false, samplingRatio, + excludeFromSummaries); } public FeatureRequestEventBuilder flagVersion(int flagVersion) { @@ -512,6 +524,16 @@ public FeatureRequestEventBuilder debugEventsUntilDate(Long debugEventsUntilDate this.debugEventsUntilDate = debugEventsUntilDate; return this; } + + public FeatureRequestEventBuilder excludeFromSummaries(boolean excludeFromSummaries) { + this.excludeFromSummaries = excludeFromSummaries; + return this; + } + + public FeatureRequestEventBuilder samplingRatio(long samplingRatio) { + this.samplingRatio = samplingRatio; + return this; + } } public static final class CustomEventBuilder { diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java index 8bbebba..55a505b 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java @@ -4,10 +4,13 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.testhelpers.JsonTestValue; import org.hamcrest.Matchers; +import org.junit.Assert; import org.junit.Test; import java.util.Date; +import java.util.List; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.allOf; @@ -93,6 +96,46 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { )); } + @Test + public void featureEventWith0SamplingRatioIsNotSampled() throws Exception { + MockEventSender es = new MockEventSender(); + Event.FeatureRequest fe = featureEvent(user, FLAG_KEY).trackEvents(true).samplingRatio(0).build(); + + EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatAlwaysSaysKeysAreNew(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).contextDeduplicator(contextDeduplicator))) { + ep.sendEvent(fe); + } + + List events = es.getEventsFromLastRequest(); + assertThat(events, contains( + isIndexEvent(fe, userJson), + isSummaryEvent() + )); + // No feature event. + Assert.assertEquals(2, events.size()); + } + + @Test + public void featureEventCanBeExcludedFromSummaries() throws Exception { + MockEventSender es = new MockEventSender(); + Event.FeatureRequest fe = featureEvent(user, FLAG_KEY).trackEvents(true).excludeFromSummaries(true).build(); + + EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatAlwaysSaysKeysAreNew(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).contextDeduplicator(contextDeduplicator))) { + ep.sendEvent(fe); + } + + List events = es.getEventsFromLastRequest(); + assertThat(events, contains( + isIndexEvent(fe, userJson), + isFeatureEvent(fe) + )); + // No feature event. + Assert.assertEquals(2, events.size()); + } + @SuppressWarnings("unchecked") @Test public void userIsFilteredInIndexEvent() throws Exception { @@ -379,4 +422,37 @@ public void customEventWithNullContextOrInvalidContextDoesNotCauseError() throws isCustomEvent(event3) )); } + + @Test + public void migrationEventIsQueued() throws Exception { + MockEventSender es = new MockEventSender(); + Event.MigrationOp event = new Event.MigrationOp( + 0, + user, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 1, + "read", + new Event.MigrationOp.InvokedMeasurement(true, false), + null, + null, + null + ); + + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(event); + } + + List events = es.getEventsFromLastRequest(); + assertThat(events, contains( + isMigrationEvent(event, userJson) + )); + // Migration events should not trigger any other events (index, debug, etc.) + Assert.assertEquals(1, events.size()); + } } diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java index 2168de4..f523510 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java @@ -24,10 +24,16 @@ import static org.junit.Assert.assertEquals; @SuppressWarnings("javadoc") -public class EventOutputTest extends BaseEventTest { +public class + + + + + +EventOutputTest extends BaseEventTest { private static final Gson gson = new Gson(); - private ContextBuilder contextBuilderWithAllAttributes = LDContext.builder("userkey") + private final ContextBuilder contextBuilderWithAllAttributes = LDContext.builder("userkey") .anonymous(true) .name("me") .set("custom1", "value1") @@ -312,7 +318,311 @@ public void summaryEventIsSerialized() throws Exception { parseValue("{\"unknown\":true,\"value\":\"default3\",\"count\":1}") )); } - + + @Test + public void migrationOpEventIsSerialized() throws IOException { + LDContext context = LDContext.builder("user-key").name("me").build(); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + + Event.MigrationOp event = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + new Event.MigrationOp.ConsistencyMeasurement(true, 1), + new Event.MigrationOp.LatencyMeasurement(100l, 50l), + new Event.MigrationOp.ErrorMeasurement(false, true) + ); + + LDValue received = getSingleOutputEvent(f, event); + LDValue expected = LDValue.buildObject() + .put("operation", "read") + .put("kind", "migration_op") + .put("creationDate", 0) + .put("evaluation", LDValue.buildObject() + .put("key", "migration-key") + .put("variation", 1) + .put("version", 2) + .put("value", "live") + .put("default", "off") + .put("reason", LDValue.buildObject() + .put("kind", "FALLTHROUGH") + .build()).build()) + .put("contextKeys", LDValue.buildObject() + .put("user", "user-key") + .build()) + .put("samplingRatio", 2) + .put("measurements", LDValue.buildArray() + .add(LDValue.buildObject() + .put("key", "invoked") + .put("values", LDValue.buildObject() + .put("new", true) + .build()) + .build()) + .add(LDValue.buildObject() + .put("key", "consistent") + .put("value", true) + .build()) + .add(LDValue.buildObject() + .put("key", "latency_ms") + .put("values", LDValue.buildObject() + .put("old", 100) + .put("new", 50) + .build()) + .build()) + .add(LDValue.buildObject() + .put("key", "error") + .put("values", LDValue.buildObject() + .put("new", true) + .build()) + .build()) + .build()) + .build(); + + assertJsonEquals(expected, received); + } + + @Test + public void migrationOpEventSerializationCanExcludeOptionalItems() throws IOException { + LDContext context = LDContext.builder("user-key").name("me").build(); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + + Event.MigrationOp event = new Event.MigrationOp( + 0, + context, + "migration-key", + -1, + -1, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 1, + "read", + new Event.MigrationOp.InvokedMeasurement(true, false), + null, + null, + null + ); + + LDValue received1 = getSingleOutputEvent(f, event); + Event.MigrationOp event2 = new Event.MigrationOp( + 0, + context, + "migration-key", + -1, + -1, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 1, + "read", + new Event.MigrationOp.InvokedMeasurement(true, false), + null, + // Null measurement, versus a measurement containing no values, should behave the same. + new Event.MigrationOp.LatencyMeasurement(null, null), + new Event.MigrationOp.ErrorMeasurement(false, false) + ); + LDValue received2 = getSingleOutputEvent(f, event2); + + LDValue expected = LDValue.buildObject() + .put("operation", "read") + .put("kind", "migration_op") + .put("creationDate", 0) + .put("evaluation", LDValue.buildObject() + .put("key", "migration-key") + .put("value", "live") + .put("default", "off") + .put("reason", LDValue.buildObject() + .put("kind", "FALLTHROUGH") + .build()).build()) + .put("contextKeys", LDValue.buildObject() + .put("user", "user-key") + .build()) + .put("measurements", LDValue.buildArray() + .add(LDValue.buildObject() + .put("key", "invoked") + .put("values", LDValue.buildObject() + .put("old", true) + .build()) + .build()) + .build()) + .build(); + + assertJsonEquals(expected, received1); + assertJsonEquals(expected, received2); + } + + @Test + public void migrationOpEventCanSerializeDifferentLatencyPermutations() throws IOException { + LDContext context = LDContext.builder("user-key").name("me").build(); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + + Event.MigrationOp event1 = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + null, + new Event.MigrationOp.LatencyMeasurement(null, 50l), + null + ); + + LDValue received1 = getSingleOutputEvent(f, event1); + assertJsonEquals(LDValue.buildObject() + .put("key", "latency_ms") + .put("values", LDValue.buildObject() + .put("new", 50) + .build()) + .build(), received1.get("measurements").get(1)); + + Event.MigrationOp event2 = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + null, + new Event.MigrationOp.LatencyMeasurement(50l, null), + null + ); + + LDValue received2 = getSingleOutputEvent(f, event2); + assertJsonEquals(LDValue.buildObject() + .put("key", "latency_ms") + .put("values", LDValue.buildObject() + .put("old", 50) + .build()) + .build(), received2.get("measurements").get(1)); + + Event.MigrationOp event3 = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + null, + new Event.MigrationOp.LatencyMeasurement(50l, 150l), + null + ); + + LDValue received3 = getSingleOutputEvent(f, event3); + assertJsonEquals(LDValue.buildObject() + .put("key", "latency_ms") + .put("values", LDValue.buildObject() + .put("old", 50) + .put("new", 150) + .build()) + .build(), received3.get("measurements").get(1)); + } + + @Test + public void migrationOpEventCanSerializeDifferentErrorPermutations() throws IOException { + LDContext context = LDContext.builder("user-key").name("me").build(); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + + Event.MigrationOp event1 = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + null, + null, + new Event.MigrationOp.ErrorMeasurement(true, false) + ); + + LDValue received1 = getSingleOutputEvent(f, event1); + assertJsonEquals(LDValue.buildObject() + .put("key", "error") + .put("values", LDValue.buildObject() + .put("old", true) + .build()) + .build(), received1.get("measurements").get(1)); + + Event.MigrationOp event2 = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + null, + null, + new Event.MigrationOp.ErrorMeasurement(false, true) + ); + + LDValue received2 = getSingleOutputEvent(f, event2); + assertJsonEquals(LDValue.buildObject() + .put("key", "error") + .put("values", LDValue.buildObject() + .put("new", true) + .build()) + .build(), received2.get("measurements").get(1)); + + Event.MigrationOp event3 = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + null, + null, + new Event.MigrationOp.ErrorMeasurement(true, true) + ); + + LDValue received3 = getSingleOutputEvent(f, event3); + assertJsonEquals(LDValue.buildObject() + .put("key", "error") + .put("values", LDValue.buildObject() + .put("old", true) + .put("new", true) + .build()) + .build(), received3.get("measurements").get(1)); + } + @Test public void unknownEventClassIsNotSerialized() throws Exception { // This shouldn't be able to happen in reality. From 7c165c8c3f9fc45cc72a076d65b57851fb028a0e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 11 Oct 2023 15:18:09 -0700 Subject: [PATCH 56/57] chore: Remove extra blank lines. (#22) --- .../launchdarkly/sdk/internal/events/EventOutputTest.java | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java index f523510..c38add5 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java @@ -24,13 +24,7 @@ import static org.junit.Assert.assertEquals; @SuppressWarnings("javadoc") -public class - - - - - -EventOutputTest extends BaseEventTest { +public class EventOutputTest extends BaseEventTest { private static final Gson gson = new Gson(); private final ContextBuilder contextBuilderWithAllAttributes = LDContext.builder("userkey") From f122c4085b47df32a2033e2ee1447c31a7f15982 Mon Sep 17 00:00:00 2001 From: Todd Anderson <127344469+tanderson-ld@users.noreply.github.com> Date: Mon, 13 Nov 2023 16:35:50 -0600 Subject: [PATCH 57/57] Updating SDKCommon dependency to get NPE fix --- buildSrc/src/main/kotlin/Dependencies.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 2eef4c3..2b71ae6 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -4,7 +4,7 @@ object Versions { const val gson = "2.8.9" - const val launchdarklyJavaSdkCommon = "2.0.0" + const val launchdarklyJavaSdkCommon = "2.1.1" const val launchdarklyLogging = "1.1.1" const val okhttp = "4.9.1" const val testHelpers = "1.2.0"