diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fc63176 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# AGENTS.md + +## Project Conventions + +- The project uses the `speq` skill, see https://github.com/marconae/speq-skill +- The project uses OpenFastTrace (OFT). + - Reference skill: https://github.com/itsallcode/openfasttrace/blob/main/skills/openfasttrace-skill/SKILL.md + +## OpenFastTrace Artifact Types + +- `feat`: high level features in the mission +- `req`: requirements in the speq spec files +- `impl`: implementation in code +- `utest`: unit tests +- `itest`: integration tests + +## Tag Maintenance + +- Always add or update OpenFastTrace tags for features, requirements, and code tags when updating the mission or speq spec files. + +## Validation + +- Ensure that all requirements are covered by running `mvn generate-sources org.itsallcode:openfasttrace-maven-plugin:trace`. diff --git a/dependencies.md b/dependencies.md index d59e5c6..f89ccbc 100644 --- a/dependencies.md +++ b/dependencies.md @@ -40,8 +40,9 @@ | [error-code-crawler-maven-plugin][37] | [MIT License][38] | | [Git Commit Id Maven Plugin][39] | [GNU Lesser General Public License 3.0][40] | | [Project Keeper Maven plugin][41] | [The MIT License][42] | -| [OpenFastTrace Maven Plugin][43] | [GNU General Public License v3.0][44] | -| [Apache Maven JAR Plugin][45] | [Apache-2.0][9] | +| [Build Helper Maven Plugin][43] | [The MIT License][44] | +| [OpenFastTrace Maven Plugin][45] | [GNU General Public License v3.0][46] | +| [Apache Maven JAR Plugin][47] | [Apache-2.0][9] | [0]: https://github.com/eclipse-ee4j/jsonp [1]: https://projects.eclipse.org/license/epl-2.0 @@ -86,6 +87,8 @@ [40]: http://www.gnu.org/licenses/lgpl-3.0.txt [41]: https://github.com/exasol/project-keeper/ [42]: https://github.com/exasol/project-keeper/blob/main/LICENSE -[43]: https://github.com/itsallcode/openfasttrace-maven-plugin -[44]: https://www.gnu.org/licenses/gpl-3.0.html -[45]: https://maven.apache.org/plugins/maven-jar-plugin/ +[43]: https://www.mojohaus.org/build-helper-maven-plugin/ +[44]: https://spdx.org/licenses/MIT.txt +[45]: https://github.com/itsallcode/openfasttrace-maven-plugin +[46]: https://www.gnu.org/licenses/gpl-3.0.html +[47]: https://maven.apache.org/plugins/maven-jar-plugin/ diff --git a/doc/app-user-guide.md b/doc/app-user-guide.md index 3f498bd..4f15c64 100644 --- a/doc/app-user-guide.md +++ b/doc/app-user-guide.md @@ -4,17 +4,17 @@ This guide is for end users of applications that use `telemetry-java`. It explains: -- which data is collected -- how to see whether telemetry is enabled +- what data is collected +- how to tell whether telemetry is enabled - how to disable telemetry ## What Is Collected? -The library collects only the data needed for feature-usage telemetry: +The library collects only the data required for feature-usage telemetry: -- product name, sent as the telemetry category +- the product name, sent as the telemetry category - product version -- which application features are used +- which features are used - when those features are used It does not collect: @@ -31,15 +31,29 @@ For Exasol's general privacy information, see the [Exasol Privacy Policy](https: ## How To See Whether Telemetry Is Enabled -Applications can use lifecycle log messages from the library to show whether telemetry is enabled or disabled. +Applications can use lifecycle log messages from the library to indicate whether telemetry is enabled or disabled. -When telemetry is disabled, the library does not enqueue or send usage events. +When telemetry is disabled, the library does not queue or send usage events. ## How To Disable Telemetry -Host applications can disable telemetry globally by setting environment variable `EXASOL_TELEMETRY_DISABLE` to any non-empty value. +### In Java-Based Virtual Schemas -In Exasol UDFs, set the environment variable in the script definition with `%env`. Example: +When you create or update a Java-based virtual schema, disable telemetry by setting the adapter property `TELEMETRY=false`. Example: + +```sql +CREATE VIRTUAL SCHEMA hive USING adapter.jdbc_adapter +WITH + CONNECTION_STRING = 'jdbc:hive2://localhost:10000/default' + // ... + TELEMETRY = 'false'; +``` + +See the [documentation](https://docs.exasol.com/db/latest/sql/create_schema.htm) for details. + +### In General Java-Based Exasol UDFs + +Set the environment variable `EXASOL_TELEMETRY_DISABLE` to any non-empty value in the script definition with `%env`. Example: ```sql CREATE OR REPLACE JAVA SCALAR SCRIPT MY_UDF(...) RETURNS VARCHAR(100) AS @@ -47,6 +61,12 @@ CREATE OR REPLACE JAVA SCALAR SCRIPT MY_UDF(...) RETURNS VARCHAR(100) AS / ``` -In Exasol UDF script options, each environment variable declaration must end with a semicolon and the value must not be quoted. +In Exasol UDF script options, each environment variable declaration must end with a semicolon, and the value must not be quoted. See the [UDF documentation](https://docs.exasol.com/db/latest/database_concepts/udf_scripts/udf_overview.htm#Environmentvariables) for details. + +### In Other Applications + +Disable telemetry by setting the environment variable `EXASOL_TELEMETRY_DISABLE` to any non-empty value. + +### In Continuous Integration -Telemetry is also disabled automatically when environment variable `CI` is set to any non-empty value, so CI and test environments do not emit usage data by default. +Telemetry is also disabled automatically when the environment variable `CI` is set to any non-empty value. This ensures that CI and test environments do not emit usage data by default. diff --git a/doc/changes/changes_0.1.0.md b/doc/changes/changes_0.1.0.md index f95114a..38df3b9 100644 --- a/doc/changes/changes_0.1.0.md +++ b/doc/changes/changes_0.1.0.md @@ -1,4 +1,4 @@ -# Telemetry Java 0.1.0, released 2026-??-?? +# Telemetry Java 0.1.0, released 2026-04-16 Code name: Initial Release @@ -9,6 +9,11 @@ Initial release of the telemetry-java library. ## Features * #2: Create initial version +* #6: Add category (project tag) and product version to message + +## Documentation + +* #3: Add requirements tracing using OFT ## Dependency Updates @@ -42,6 +47,7 @@ Initial release of the telemetry-java library. * Added `org.apache.maven.plugins:maven-surefire-plugin:3.5.4` * Added `org.apache.maven.plugins:maven-toolchains-plugin:3.2.0` * Added `org.basepom.maven:duplicate-finder-maven-plugin:2.0.1` +* Added `org.codehaus.mojo:build-helper-maven-plugin:3.6.1` * Added `org.codehaus.mojo:flatten-maven-plugin:1.7.3` * Added `org.codehaus.mojo:versions-maven-plugin:2.21.0` * Added `org.itsallcode:openfasttrace-maven-plugin:2.3.0` diff --git a/doc/developer-guide.md b/doc/developer-guide.md index 04cb3d0..4b4e924 100644 --- a/doc/developer-guide.md +++ b/doc/developer-guide.md @@ -16,3 +16,13 @@ Standard Maven commands are used: ## Project Workflow This project uses `speq-skill` and recorded specs for mission definition, planning, implementation, verification, and recording into the permanent spec library. See [speq-skill documentation](https://github.com/marconae/speq-skill?tab=readme-ov-file) for details. + +## OpenFastTrace Requirement Tracing + +OpenFastTrace tags are included in the speq-skill spec to avoid duplication. + +Tracing runs in the Maven `verify` phase. You can specifically run tracing using this command: + +```sh +mvn generate-sources org.itsallcode:openfasttrace-maven-plugin:trace +``` diff --git a/doc/integration-guide.md b/doc/integration-guide.md index a625e52..64e21f5 100644 --- a/doc/integration-guide.md +++ b/doc/integration-guide.md @@ -30,7 +30,7 @@ Example changelog entry: ```markdown ## Added -- Added anonymous feature-usage telemetry via `telemetry-java`. See the [documentation](app-user-guide.md) for details on collected data and opt-out behavior. +- Added anonymous feature-usage telemetry via `telemetry-java`. See the [documentation](https://github.com/exasol/telemetry-java/blob/main/doc/app-user-guide.md) for details on collected data and opt-out behavior. ``` Example end-user documentation entry: @@ -40,7 +40,7 @@ Example end-user documentation entry: This application uses `telemetry-java` to send anonymous feature-usage events. -For details on what is collected and how to disable telemetry, see the [documentation](app-user-guide.md). +For details on what is collected and how to disable telemetry, see the [documentation](https://github.com/exasol/telemetry-java/blob/main/doc/app-user-guide.md). ``` ## Environment Variables diff --git a/pom.xml b/pom.xml index 458538c..7ca6712 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,26 @@ + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.1 + + + add-spec-to-source + generate-sources + + add-source + + + + + specs/ + + + + + org.itsallcode openfasttrace-maven-plugin diff --git a/specs/config/client-identity/spec.md b/specs/config/client-identity/spec.md index 6f1be62..8b0c0b8 100644 --- a/specs/config/client-identity/spec.md +++ b/specs/config/client-identity/spec.md @@ -2,6 +2,16 @@ Defines the required product identity values that the host application configures once and the library attaches to every emitted telemetry message. +## Requirement: Client Identity +`req~client-identity~1` + +The library shall require caller-configured identity values and attach them to emitted telemetry messages as described by the scenarios below. + +Covers: +* `feat~client-identity~1` + +Needs: impl, utest, itest + ## Background Telemetry messages carry three stable identity fields: `category`, which is the configured project tag; `version`, which is the telemetry protocol version; and `productVersion`, which is the integrating product or library version. Feature names remain arbitrary caller-provided strings and MUST NOT duplicate project identity. @@ -14,3 +24,11 @@ Telemetry messages carry three stable identity fields: `category`, which is the * *WHEN* the host application provides a blank project tag or a blank `productVersion` * *THEN* the library SHALL reject configuration creation * *AND* the library MUST require both values before a telemetry client can be created + +### Scenario: Attaches configured identity values to emitted telemetry messages + +* *GIVEN* the library is configured with a project tag and `productVersion` +* *WHEN* the library emits a telemetry message +* *THEN* the library SHALL emit the configured project tag as `category` +* *AND* the library SHALL emit the configured `productVersion` as `productVersion` +* *AND* the library SHALL keep `version` reserved for telemetry protocol version `0.2.0` diff --git a/specs/config/tracking-controls/spec.md b/specs/config/tracking-controls/spec.md index 3113ec8..aed3fbb 100644 --- a/specs/config/tracking-controls/spec.md +++ b/specs/config/tracking-controls/spec.md @@ -2,6 +2,16 @@ Allows host applications and deployment environments to disable tracking or redirect telemetry delivery without code changes. +## Requirement: Tracking Controls +`req~tracking-controls~1` + +The library shall resolve tracking controls from configuration and environment variables so telemetry can be disabled automatically or redirected to an overridden endpoint as described by the scenarios below. + +Covers: +* `feat~tracking-controls~1` + +Needs: impl, utest, itest + ## Background Tracking can be deactivated by `EXASOL_TELEMETRY_DISABLE` or automatically by `CI` when either environment variable is set to any non-empty value. If the host application does not configure an endpoint, the library uses `https://metrics.exasol.com`. The configured endpoint can be overridden by `EXASOL_TELEMETRY_ENDPOINT`. diff --git a/specs/delivery/async-delivery/spec.md b/specs/delivery/async-delivery/spec.md index 22073d3..a23d40a 100644 --- a/specs/delivery/async-delivery/spec.md +++ b/specs/delivery/async-delivery/spec.md @@ -2,6 +2,16 @@ Delivers accepted usage events to an HTTP endpoint without blocking the host application's main execution path. +## Requirement: Async Delivery +`req~async-delivery~1` + +The library shall batch accepted telemetry events into protocol messages and deliver them asynchronously over HTTP with retry handling and bounded in-memory buffering as described by the scenarios below. + +Covers: +* `feat~async-delivery~1` + +Needs: impl, utest, itest + ## Background Accepted telemetry events are serialized to JSON and delivered via HTTP `POST` to a configured endpoint or the default endpoint `https://metrics.exasol.com`. The JSON payload contains `category`, protocol `version`, `productVersion`, `timestamp`, and `features` fields, and the library uses bounded in-memory buffering with no persistent local storage. diff --git a/specs/lifecycle/shutdown-flush/spec.md b/specs/lifecycle/shutdown-flush/spec.md index 89e55bd..6468dae 100644 --- a/specs/lifecycle/shutdown-flush/spec.md +++ b/specs/lifecycle/shutdown-flush/spec.md @@ -2,6 +2,16 @@ Ensures host applications can shut down cleanly while still giving queued telemetry an opportunity to be delivered. +## Requirement: Shutdown Flush +`req~shutdown-flush~1` + +The library shall flush queued telemetry work during close and stop background delivery threads before shutdown completes as described by the scenarios below. + +Covers: +* `feat~shutdown-flush~1` + +Needs: impl, utest, itest + ## Background The telemetry client participates in application shutdown through `AutoCloseable`. diff --git a/specs/mission.md b/specs/mission.md index 85e517a..9e91558 100644 --- a/specs/mission.md +++ b/specs/mission.md @@ -27,6 +27,43 @@ Application end users use the host application and, when they allow it, the appl - Provide clean shutdown behavior and ensure queued usage data is sent during shutdown. - Allow tracking to be deactivated via environment variables. +## Traceable Features + +### Tracking API +`feat~tracking-api~1` + +Provides the host-facing API for recording feature-usage events with minimal integration effort. + +Needs: req + +### Tracking Controls +`feat~tracking-controls~1` + +Allows host applications and deployment environments to disable tracking or override telemetry delivery settings. + +Needs: req + +### Client Identity +`feat~client-identity~1` + +Defines the required product identity values that the host application configures once and the library attaches to emitted telemetry messages. + +Needs: req + +### Async Delivery +`feat~async-delivery~1` + +Delivers accepted telemetry events over HTTP without blocking the host application's calling thread. + +Needs: req + +### Shutdown Flush +`feat~shutdown-flush~1` + +Ensures queued telemetry is flushed during shutdown and background work stops when the client closes. + +Needs: req + ## Out of Scope The library does not collect: diff --git a/specs/tracking/tracking-api/spec.md b/specs/tracking/tracking-api/spec.md index fcc16d5..4ccd1aa 100644 --- a/specs/tracking/tracking-api/spec.md +++ b/specs/tracking/tracking-api/spec.md @@ -2,13 +2,23 @@ Enables host applications to record allowed feature-usage events with minimal integration effort. +## Requirement: Tracking API +`req~tracking-api~1` + +The library shall provide a tracking API that accepts feature-usage events from the host application, queues accepted tracking work for asynchronous delivery, and keeps the caller thread free of delivery work as described by the scenarios below. + +Covers: +* `feat~tracking-api~1` + +Needs: impl, utest, itest + ## Background The host application still configures project identity once at startup, but accepted feature names are transmitted as caller-provided strings. Project tag and `productVersion` are emitted separately as message metadata, while the JSON `version` field remains reserved for the telemetry protocol version. The library does not validate which feature names applications choose apart from ignoring `null` values. ## Scenarios -### Scenario: Records a tagged feature usage event +### Scenario: Records a feature usage event * *GIVEN* the library is configured with a project short tag and `productVersion` * *AND* tracking is enabled @@ -19,25 +29,17 @@ The host application still configures project identity once at startup, but acce * *AND* the library SHALL keep `version` reserved for protocol version `0.2.0` * *AND* the library MUST NOT validate or reject the feature name based on application-defined naming choices -### Scenario: Rejects unsupported usage payloads - -* *GIVEN* the library is configured and tracking is enabled -* *WHEN* the host application records usage data that contains unsupported fields -* *THEN* the library SHALL reject the event -* *AND* the library MUST NOT enqueue the rejected payload for delivery -* *AND* the library MUST NOT emit logs, stack traces, or PII in the protocol payload - -### Scenario: Rejects tracking after the client is closed +### Scenario: Ignores tracking after the client is closed * *GIVEN* the host application has closed the telemetry client * *WHEN* the host application records a feature-usage event -* *THEN* the library SHALL report that the client is closed +* *THEN* the library SHALL ignore that call * *AND* the library MUST NOT enqueue the event for delivery ### Scenario: Keeps caller-thread overhead low for accepted tracking * *GIVEN* tracking is enabled -* *AND* the host application records a valid feature-usage event +* *AND* the host application records a feature-usage event * *WHEN* the library accepts the event for delivery * *THEN* the library SHALL keep caller-thread work limited to receiving the feature name, timestamp capture, and queue admission * *AND* the library SHALL defer JSON serialization and HTTP delivery to background processing diff --git a/src/main/java/com/exasol/telemetry/HttpTransport.java b/src/main/java/com/exasol/telemetry/HttpTransport.java index 83bb29f..a315aa7 100644 --- a/src/main/java/com/exasol/telemetry/HttpTransport.java +++ b/src/main/java/com/exasol/telemetry/HttpTransport.java @@ -32,6 +32,7 @@ private static RequestSender defaultSender(final TelemetryConfig config) { }; } + // [impl~http-transport-send~1->req~async-delivery~1] void send(final Message message) throws IOException { final HttpRequest request = HttpRequest.newBuilder(config.getEndpoint()) .header("Content-Type", "application/json") diff --git a/src/main/java/com/exasol/telemetry/Message.java b/src/main/java/com/exasol/telemetry/Message.java index 49b12dd..e39fd0e 100644 --- a/src/main/java/com/exasol/telemetry/Message.java +++ b/src/main/java/com/exasol/telemetry/Message.java @@ -20,6 +20,8 @@ private Message(final String category, final String productVersion, final Instan this.features = requireNonNull(features, "features"); } + // [impl~message-from-events~1->req~async-delivery~1] + // [impl~message-from-events-preserves-client-identity~1->req~client-identity~1] static Message fromEvents(final String category, final String productVersion, final Instant timestamp, final List events) { final Map> features = new LinkedHashMap<>(); for (final TelemetryEvent event : events) { @@ -28,6 +30,8 @@ static Message fromEvents(final String category, final String productVersion, fi return new Message(category, productVersion, timestamp, features); } + // [impl~message-to-json~1->req~async-delivery~1] + // [impl~message-to-json-client-identity-fields~1->req~client-identity~1] String toJson() { final StringBuilder builder = new StringBuilder(); builder.append('{'); diff --git a/src/main/java/com/exasol/telemetry/TelemetryClient.java b/src/main/java/com/exasol/telemetry/TelemetryClient.java index a5fcf00..692e0f6 100644 --- a/src/main/java/com/exasol/telemetry/TelemetryClient.java +++ b/src/main/java/com/exasol/telemetry/TelemetryClient.java @@ -52,6 +52,7 @@ private TelemetryClient(final TelemetryConfig config) { * @param config telemetry runtime configuration * @return telemetry client */ + // [impl~telemetry-client-create~1->req~tracking-api~1] public static TelemetryClient create(final TelemetryConfig config) { return new TelemetryClient(config); } @@ -61,6 +62,7 @@ public static TelemetryClient create(final TelemetryConfig config) { * * @param feature feature name provided by the caller */ + // [impl~telemetry-client-track~1->req~tracking-api~1] public void track(final String feature) { if (!trackingEnabled || closed) { return; @@ -101,6 +103,7 @@ private List drainBatch(final TelemetryEvent firstEvent) { return batch; } + // [impl~telemetry-client-send-with-retry~1->req~async-delivery~1] private void sendWithRetry(final List events) { final Instant start = clock.instant(); final Message message = Message.fromEvents(config.getProjectTag(), config.getProductVersion(), start, events); @@ -108,6 +111,9 @@ private void sendWithRetry(final List events) { Duration delay = config.getInitialRetryDelay(); while (true) { + if (Thread.currentThread().isInterrupted()) { + return; + } try { transport.send(message); LOGGER.fine(() -> "Telemetry sent to the server with " + events.size() + " event(s)."); @@ -115,6 +121,9 @@ private void sendWithRetry(final List events) { } catch (final Exception exception) { LOGGER.fine(() -> "Telemetry sending failed for " + events.size() + " event(s): " + rootCauseMessage(exception)); + if (Thread.currentThread().isInterrupted()) { + return; + } final Instant now = clock.instant(); if (!now.isBefore(deadline)) { return; @@ -168,21 +177,36 @@ boolean isRunning() { * Stop the sender thread and wait for any queued events to be flushed before returning. */ @Override + // [impl~telemetry-client-close~1->req~shutdown-flush~1] public void close() { if (closed) { return; } closed = true; if (trackingEnabled) { - try { - senderThread.join(); - } catch (final InterruptedException ignored) { - Thread.currentThread().interrupt(); - } + awaitSenderStop(); } LOGGER.fine("Telemetry is stopped."); } + private void awaitSenderStop() { + final long timeoutNanos = config.getRetryTimeout().toNanos(); + final long deadlineNanos = System.nanoTime() + timeoutNanos; + try { + while (senderThread.isAlive()) { + final long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + senderThread.interrupt(); + senderThread.join(); + return; + } + TimeUnit.NANOSECONDS.timedJoin(senderThread, remainingNanos); + } + } catch (final InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + private void logEnabled() { LOGGER.info(() -> "Telemetry is enabled. Set " + TelemetryConfig.DISABLED_ENV + " to any non-empty value to disable telemetry. " + TelemetryConfig.DISABLED_ENV + "=" + formatEnvValue(config.getDisabledEnvValue()) + ", " diff --git a/src/main/java/com/exasol/telemetry/TelemetryConfig.java b/src/main/java/com/exasol/telemetry/TelemetryConfig.java index 6177d64..fb1a848 100644 --- a/src/main/java/com/exasol/telemetry/TelemetryConfig.java +++ b/src/main/java/com/exasol/telemetry/TelemetryConfig.java @@ -136,10 +136,12 @@ String getDisableMechanismValue() { return null; } + // [impl~telemetry-config-disable-detection~1->req~tracking-controls~1] static boolean isDisabled(final String value) { return value != null && !value.trim().isEmpty(); } + // [impl~telemetry-config-resolve-endpoint~1->req~tracking-controls~1] private static URI resolveEndpoint(final URI configuredEndpoint, final Environment environment) { final String override = environment.getenv(ENDPOINT_ENV); if (override != null && !override.trim().isEmpty()) { @@ -148,6 +150,7 @@ private static URI resolveEndpoint(final URI configuredEndpoint, final Environme return configuredEndpoint != null ? configuredEndpoint : DEFAULT_ENDPOINT; } + // [impl~telemetry-config-require-client-identity~1->req~client-identity~1] private static String requireText(final String value, final String field) { if (value == null || value.trim().isEmpty()) { throw new IllegalArgumentException(field + " must not be blank"); diff --git a/src/test/java/com/exasol/telemetry/AsyncDeliveryIT.java b/src/test/java/com/exasol/telemetry/AsyncDeliveryIT.java index c71095c..b81e90e 100644 --- a/src/test/java/com/exasol/telemetry/AsyncDeliveryIT.java +++ b/src/test/java/com/exasol/telemetry/AsyncDeliveryIT.java @@ -15,6 +15,7 @@ class AsyncDeliveryIT { private static final String VERSION = "1.2.3"; private static final String FEATURE = "myFeature"; + // [itest~async-delivery-over-http~1->req~async-delivery~1] @Test void sendsQueuedEventsAsynchronouslyOverHttp() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createDelayedSuccessServer(300); @@ -31,6 +32,7 @@ void sendsQueuedEventsAsynchronouslyOverHttp() throws Exception { } } + // [itest~async-delivery-retry-with-backoff~1->req~async-delivery~1] @Test void retriesFailedDeliveryWithExponentialBackoffUntilTimeout() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createFlakyServer(2); diff --git a/src/test/java/com/exasol/telemetry/HttpTransportTest.java b/src/test/java/com/exasol/telemetry/HttpTransportTest.java index 06a079e..9e24d67 100644 --- a/src/test/java/com/exasol/telemetry/HttpTransportTest.java +++ b/src/test/java/com/exasol/telemetry/HttpTransportTest.java @@ -23,6 +23,7 @@ class HttpTransportTest { private static final String VERSION = "1.2.3"; private static final String FEATURE = "projectTag.feature"; + // [utest~http-transport-sends-json-payload~1->req~async-delivery~1] @Test void sendsJsonPayloadToConfiguredClient() throws IOException { final CapturingRequestSender requestSender = new CapturingRequestSender(202); @@ -43,6 +44,7 @@ void sendsJsonPayloadToConfiguredClient() throws IOException { assertThat(body, containsString("\"features\":{\"projectTag.feature\":[10]}")); } + // [utest~http-transport-rejects-non-success~1->req~async-delivery~1] @Test void rejectsNonSuccessStatusCodes() { final HttpTransport transport = new HttpTransport( @@ -56,6 +58,7 @@ void rejectsNonSuccessStatusCodes() { assertThat(exception.getMessage(), is("server says no")); } + // [utest~http-transport-handles-interruption~1->req~async-delivery~1] @Test void convertsInterruptedExceptionToIoException() { final HttpTransport transport = new HttpTransport( diff --git a/src/test/java/com/exasol/telemetry/MessageTest.java b/src/test/java/com/exasol/telemetry/MessageTest.java index 13dd2d6..641a424 100644 --- a/src/test/java/com/exasol/telemetry/MessageTest.java +++ b/src/test/java/com/exasol/telemetry/MessageTest.java @@ -17,6 +17,8 @@ void verifiesEqualsAndHashCode() { EqualsVerifier.forClass(Message.class).verify(); } + // [utest~message-groups-events~1->req~async-delivery~1] + // [utest~message-emits-client-identity~1->req~client-identity~1] @Test void groupsEventsByFeatureAndSerializesProtocolShape() { final Message message = Message.fromEvents("shop-ui", "1.2.3", Instant.ofEpochSecond(30), List.of( @@ -33,6 +35,7 @@ void groupsEventsByFeatureAndSerializesProtocolShape() { assertThat(json, containsString("\"features\":{\"project.a\":[10,20],\"project.b\":[30]}")); } + // [utest~message-valid-json~1->req~async-delivery~1] @Test void serializesValidJson() { final Message message = Message.fromEvents("shop-ui", "1.2.3", Instant.ofEpochSecond(30), List.of( @@ -47,6 +50,7 @@ void serializesValidJson() { assertThat(payload.containsKey("features"), is(true)); } + // [utest~message-escapes-feature-names~1->req~async-delivery~1] @Test void escapesFeatureNamesInJson() { final Message message = Message.fromEvents("shop-ui", "1.2.3", Instant.ofEpochSecond(30), List.of( @@ -57,6 +61,7 @@ void escapesFeatureNamesInJson() { assertThat(json, containsString("proj.\\\"x\\\"\\n\\t\\\\")); } + // [utest~message-escapes-client-identity~1->req~client-identity~1] @Test void escapesCategoryAndProductVersionInJson() { final Message message = Message.fromEvents("shop-\"ui\"\n\t\\", "1.2.3-\"beta\"\n\t\\", Instant.ofEpochSecond(30), List.of( diff --git a/src/test/java/com/exasol/telemetry/RecordingHttpServer.java b/src/test/java/com/exasol/telemetry/RecordingHttpServer.java index f601798..4c2a974 100644 --- a/src/test/java/com/exasol/telemetry/RecordingHttpServer.java +++ b/src/test/java/com/exasol/telemetry/RecordingHttpServer.java @@ -43,6 +43,10 @@ static RecordingHttpServer createFlakyServer(final int failuresBeforeSuccess) { return new RecordingHttpServer(failuresBeforeSuccess, 0); } + static RecordingHttpServer createDelayedFlakyServer(final int failuresBeforeSuccess, final long responseDelayMillis) { + return new RecordingHttpServer(failuresBeforeSuccess, responseDelayMillis); + } + TelemetryConfig.Builder configBuilder(final String projectTag, final String version) { return TelemetryConfig.builder(projectTag, version) .endpoint(endpoint()) diff --git a/src/test/java/com/exasol/telemetry/ShutdownFlushIT.java b/src/test/java/com/exasol/telemetry/ShutdownFlushIT.java index b0859a0..f5b14d7 100644 --- a/src/test/java/com/exasol/telemetry/ShutdownFlushIT.java +++ b/src/test/java/com/exasol/telemetry/ShutdownFlushIT.java @@ -4,6 +4,7 @@ import static org.hamcrest.Matchers.*; import java.time.Duration; +import java.time.Instant; import java.util.List; import org.junit.jupiter.api.Test; @@ -11,6 +12,7 @@ class ShutdownFlushIT { private static final String PRODUCT_VERSION = "1.2.3"; + // [itest~shutdown-flush-pending-events~1->req~shutdown-flush~1] @Test void flushesPendingEventsOnClose() throws Exception { final List requests; @@ -31,6 +33,7 @@ void flushesPendingEventsOnClose() throws Exception { assertThat(requests.get(0).body(), containsString("\"features\":{\"checkout-started\":[")); } + // [itest~shutdown-flush-stops-background-thread~1->req~shutdown-flush~1] @Test void stopsBackgroundThreadsAfterClose() throws Exception { final TelemetryClient client; @@ -43,4 +46,27 @@ void stopsBackgroundThreadsAfterClose() throws Exception { assertThat(client.awaitStopped(Duration.ofSeconds(1)), is(true)); assertThat(client.isRunning(), is(false)); } + + // [itest~shutdown-flush-respects-retry-timeout~1->req~shutdown-flush~1] + @Test + void respectsRetryTimeoutWhileFlushingOnClose() throws Exception { + try (RecordingHttpServer server = RecordingHttpServer.createDelayedFlakyServer(Integer.MAX_VALUE, 1_000)) { + final TelemetryClient client = TelemetryClient.create(server.configBuilder("shop-ui", PRODUCT_VERSION) + .retryTimeout(Duration.ofMillis(220)) + .initialRetryDelay(Duration.ofMillis(20)) + .maxRetryDelay(Duration.ofMillis(80)) + .requestTimeout(Duration.ofSeconds(5)) + .build()); + client.track("checkout-started"); + + final Instant start = Instant.now(); + client.close(); + final long elapsedMillis = Duration.between(start, Instant.now()).toMillis(); + final int attempts = server.awaitRequests(1, Duration.ofSeconds(1)).size(); + + assertThat("close should wait until the configured retry timeout is reached", elapsedMillis, greaterThanOrEqualTo(180L)); + assertThat("close should stop background flushing shortly after the retry timeout", elapsedMillis, lessThan(600L)); + assertThat("the sender should have started flushing before the timeout is reached", attempts, is(1)); + } + } } diff --git a/src/test/java/com/exasol/telemetry/TelemetryClientTest.java b/src/test/java/com/exasol/telemetry/TelemetryClientTest.java index fe1df45..358d21f 100644 --- a/src/test/java/com/exasol/telemetry/TelemetryClientTest.java +++ b/src/test/java/com/exasol/telemetry/TelemetryClientTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; class TelemetryClientTest { + // [utest~telemetry-client-disabled-tracking~1->req~tracking-controls~1] @Test void doesNotRunSenderWhenTrackingIsDisabled() throws Exception { final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3").endpoint(URI.create("https://example.com")) @@ -27,6 +28,7 @@ void doesNotRunSenderWhenTrackingIsDisabled() throws Exception { } } + // [utest~telemetry-client-invalid-feature~1->req~tracking-api~1] @Test void ignoresNullFeatureName() { final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3").endpoint(URI.create("https://example.com")).build(); @@ -38,6 +40,7 @@ void ignoresNullFeatureName() { } } + // [utest~telemetry-client-after-close~1->req~tracking-api~1] @Test void ignoresTrackingAfterClose() { final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3").endpoint(URI.create("https://example.com")) @@ -50,6 +53,7 @@ void ignoresTrackingAfterClose() { assertDoesNotThrow(() -> client.track("feature")); } + // [utest~telemetry-client-close-idempotent~1->req~shutdown-flush~1] @Test void makesCloseIdempotent() { final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3").endpoint(URI.create("https://example.com")) diff --git a/src/test/java/com/exasol/telemetry/TelemetryConfigTest.java b/src/test/java/com/exasol/telemetry/TelemetryConfigTest.java index f5ead1c..c543786 100644 --- a/src/test/java/com/exasol/telemetry/TelemetryConfigTest.java +++ b/src/test/java/com/exasol/telemetry/TelemetryConfigTest.java @@ -14,6 +14,7 @@ import com.exasol.telemetry.TelemetryConfig.Builder; class TelemetryConfigTest { + // [utest~telemetry-config-defaults~1->req~tracking-controls~1] @Test void usesDefaultsAndConfiguredValues() { final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3") @@ -28,6 +29,7 @@ void usesDefaultsAndConfiguredValues() { assertThat(config.isTrackingDisabled(), is(false)); } + // [utest~telemetry-config-client-identity-defaults~1->req~client-identity~1] @Test void usesDefaultsAndConfiguredValuesWithRealEnvironment() { final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3") @@ -41,6 +43,7 @@ void usesDefaultsAndConfiguredValuesWithRealEnvironment() { // Can't verify isTrackingDisabled() because in CI the CI env variable is set } + // [utest~telemetry-config-endpoint-override~1->req~tracking-controls~1] @Test void usesEndpointOverrideAndDisableEnvironmentValues() { final TelemetryConfig config = defaultBuilder() @@ -53,6 +56,7 @@ void usesEndpointOverrideAndDisableEnvironmentValues() { assertThat(config.isTrackingDisabled(), is(true)); } + // [utest~telemetry-config-disable-in-ci~1->req~tracking-controls~1] @Test void disablesTrackingAutomaticallyInCi() { final TelemetryConfig config = defaultBuilder() @@ -62,6 +66,7 @@ void disablesTrackingAutomaticallyInCi() { assertThat(config.isTrackingDisabled(), is(true)); } + // [utest~telemetry-config-disabled-value-detection~1->req~tracking-controls~1] @Test void treatsAnyNonEmptyEnvironmentValueAsDisabled() { assertThat(TelemetryConfig.isDisabled(null), is(false)); @@ -73,6 +78,7 @@ void treatsAnyNonEmptyEnvironmentValueAsDisabled() { assertThat(TelemetryConfig.isDisabled("github-actions"), is(true)); } + // [utest~telemetry-config-rejects-blank-project-tag~1->req~client-identity~1] @Test void rejectsBlankProjectTag() { final Builder builder = TelemetryConfig.builder(" ", "1.2.3"); @@ -80,6 +86,7 @@ void rejectsBlankProjectTag() { assertThat(exception.getMessage(), containsString("projectTag")); } + // [utest~telemetry-config-rejects-blank-product-version~1->req~client-identity~1] @Test void rejectsBlankProductVersion() { final Builder builder = TelemetryConfig.builder("project", " "); @@ -87,6 +94,7 @@ void rejectsBlankProductVersion() { assertThat(exception.getMessage(), containsString("productVersion")); } + // [utest~telemetry-config-default-endpoint~1->req~tracking-controls~1] @Test void usesDefaultEndpointWhenNoEndpointIsConfigured() { final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3").build(); diff --git a/src/test/java/com/exasol/telemetry/TrackingApiIT.java b/src/test/java/com/exasol/telemetry/TrackingApiIT.java index d3e8fbe..f8bca83 100644 --- a/src/test/java/com/exasol/telemetry/TrackingApiIT.java +++ b/src/test/java/com/exasol/telemetry/TrackingApiIT.java @@ -14,6 +14,8 @@ class TrackingApiIT { private static final String PRODUCT_VERSION = "1.2.3"; private static final String FEATURE = "checkout-started"; + // [itest~tracking-api-records-tagged-feature~1->req~tracking-api~1] + // [itest~tracking-api-emits-client-identity~1->req~client-identity~1] @Test void recordsFeatureUsageEventWithCategoryProtocolVersionAndProductVersion() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); @@ -33,6 +35,7 @@ void recordsFeatureUsageEventWithCategoryProtocolVersionAndProductVersion() thro } } + // [itest~tracking-api-valid-json-payload~1->req~tracking-api~1] @Test void emitsPayloadAsValidJson() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); @@ -47,6 +50,7 @@ void emitsPayloadAsValidJson() throws Exception { } } + // [itest~tracking-api-low-caller-thread-overhead~1->req~tracking-api~1] @Test void keepsCallerThreadOverheadLowForAcceptedTracking() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createDelayedSuccessServer(300); @@ -62,6 +66,7 @@ void keepsCallerThreadOverheadLowForAcceptedTracking() throws Exception { } } + // [itest~tracking-api-disabled-no-op~1->req~tracking-api~1] @Test void makesDisabledTrackingNoOpWithoutTelemetryOverhead() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer()) { @@ -79,6 +84,7 @@ void makesDisabledTrackingNoOpWithoutTelemetryOverhead() throws Exception { } } + // [itest~tracking-api-invalid-feature-name~1->req~tracking-api~1] @Test void recordsFeatureUsageEventWithoutPrefixingOrValidation() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); @@ -92,6 +98,7 @@ void recordsFeatureUsageEventWithoutPrefixingOrValidation() throws Exception { } } + // [itest~tracking-api-ignores-null-feature-name~1->req~tracking-api~1] @Test void ignoresNullFeatureNames() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); diff --git a/src/test/java/com/exasol/telemetry/TrackingControlsIT.java b/src/test/java/com/exasol/telemetry/TrackingControlsIT.java index a76cdd6..e9d650d 100644 --- a/src/test/java/com/exasol/telemetry/TrackingControlsIT.java +++ b/src/test/java/com/exasol/telemetry/TrackingControlsIT.java @@ -4,6 +4,7 @@ import static org.hamcrest.Matchers.*; import java.time.Duration; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -13,6 +14,7 @@ class TrackingControlsIT { private static final String VERSION = "1.2.3"; private static final String FEATURE = "myFeature"; + // [itest~tracking-controls-disable-env~1->req~tracking-controls~1] @Test void disablesTrackingViaEnvironmentVariables() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); @@ -26,6 +28,7 @@ void disablesTrackingViaEnvironmentVariables() throws Exception { } } + // [itest~tracking-controls-disable-ci~1->req~tracking-controls~1] @Test void disablesTrackingAutomaticallyWhenCiIsNonEmpty() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); @@ -39,8 +42,10 @@ void disablesTrackingAutomaticallyWhenCiIsNonEmpty() throws Exception { } } + // [itest~tracking-controls-endpoint-override~1->req~tracking-controls~1] @Test void overridesConfiguredEndpointViaEnvironmentVariable() throws Exception { + final List requests; try (RecordingHttpServer configuredServer = RecordingHttpServer.createSuccessServer(); RecordingHttpServer overrideServer = RecordingHttpServer.createSuccessServer(); TelemetryClient client = TelemetryClient.create(configuredServer.configBuilder(PROJECT_TAG, VERSION) @@ -48,8 +53,13 @@ void overridesConfiguredEndpointViaEnvironmentVariable() throws Exception { .build())) { client.track(FEATURE); - assertThat(overrideServer.awaitRequests(1, Duration.ofSeconds(2)), hasSize(1)); + requests = overrideServer.awaitRequests(1, Duration.ofSeconds(2)); + assertThat(requests, hasSize(1)); assertThat(configuredServer.awaitRequests(1, Duration.ofMillis(150)), empty()); } + + assertThat(requests.get(0).body(), containsString("\"category\":\"" + PROJECT_TAG + "\"")); + assertThat(requests.get(0).body(), containsString("\"productVersion\":\"" + VERSION + "\"")); + assertThat(requests.get(0).body(), containsString("\"version\":\"0.2.0\"")); } }