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.itsallcodeopenfasttrace-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\""));
}
}