From 1622c2fd059d0a07408a71f26629d6aba17777a1 Mon Sep 17 00:00:00 2001 From: Christoph Pirkl Date: Wed, 15 Apr 2026 15:18:40 +0200 Subject: [PATCH 1/7] #6: Create plan for adding new fields --- .../config/client-identity/spec.md | 16 +++ .../config/tracking-controls/spec.md | 20 +++ .../delivery/async-delivery/spec.md | 30 +++++ .../change-tracking-message-metadata/plan.md | 117 ++++++++++++++++++ .../tracking/tracking-api/spec.md | 40 ++++++ 5 files changed, 223 insertions(+) create mode 100644 specs/_plans/change-tracking-message-metadata/config/client-identity/spec.md create mode 100644 specs/_plans/change-tracking-message-metadata/config/tracking-controls/spec.md create mode 100644 specs/_plans/change-tracking-message-metadata/delivery/async-delivery/spec.md create mode 100644 specs/_plans/change-tracking-message-metadata/plan.md create mode 100644 specs/_plans/change-tracking-message-metadata/tracking/tracking-api/spec.md diff --git a/specs/_plans/change-tracking-message-metadata/config/client-identity/spec.md b/specs/_plans/change-tracking-message-metadata/config/client-identity/spec.md new file mode 100644 index 0000000..d7c3afc --- /dev/null +++ b/specs/_plans/change-tracking-message-metadata/config/client-identity/spec.md @@ -0,0 +1,16 @@ +# Feature: client-identity + +Defines the required product identity values that the host application configures once and the library attaches to every emitted telemetry message. + +## Background + +Telemetry messages carry two stable identity fields: `category`, which is the configured project tag, and `version`, which is the integrating product or library version. Feature names remain arbitrary caller-provided strings and MUST NOT duplicate project identity. + +## Scenarios + +### Scenario: Requires project tag and version when creating telemetry configuration + +* *GIVEN* the host application creates telemetry configuration +* *WHEN* the host application provides a blank project tag or a blank product/library version +* *THEN* the library SHALL reject configuration creation +* *AND* the library MUST require both values before a telemetry client can be created diff --git a/specs/_plans/change-tracking-message-metadata/config/tracking-controls/spec.md b/specs/_plans/change-tracking-message-metadata/config/tracking-controls/spec.md new file mode 100644 index 0000000..eba291d --- /dev/null +++ b/specs/_plans/change-tracking-message-metadata/config/tracking-controls/spec.md @@ -0,0 +1,20 @@ +# Feature: tracking-controls + +Preserves environment-based tracking control behavior while keeping message identity metadata stable. + +## Background + +Environment variables may disable telemetry or override the delivery endpoint, but they do not redefine the configured project identity attached to emitted messages. + +## Scenarios + + +### Scenario: Overrides the configured endpoint via environment variable + +* *GIVEN* the host application configures an endpoint, project tag, and product/library version in code +* *AND* the host environment defines an endpoint override +* *WHEN* the library initializes +* *THEN* the library SHALL use the environment-provided endpoint for delivery +* *AND* the library SHALL continue to emit the configured project tag as the `category` field +* *AND* the library SHALL continue to emit the configured product/library version as the `version` field + diff --git a/specs/_plans/change-tracking-message-metadata/delivery/async-delivery/spec.md b/specs/_plans/change-tracking-message-metadata/delivery/async-delivery/spec.md new file mode 100644 index 0000000..cbe7d89 --- /dev/null +++ b/specs/_plans/change-tracking-message-metadata/delivery/async-delivery/spec.md @@ -0,0 +1,30 @@ +# Feature: async-delivery + +Delivers accepted usage events asynchronously while serializing explicit client identity metadata in each payload. + +## Background + +The telemetry protocol now carries project identity in top-level message fields instead of encoding it into feature names. Async delivery remains responsible for emitting valid JSON over HTTP without blocking the caller thread, including correct escaping of caller-provided feature names in JSON object keys. + +## Scenarios + + +### Scenario: Sends queued events asynchronously over HTTP + +* *GIVEN* the library is configured with an endpoint, project tag, and product/library version +* *AND* an accepted usage event is queued for delivery +* *WHEN* the background sender processes the queue +* *THEN* the library SHALL submit a protocol message as JSON using HTTP `POST` +* *AND* the library SHALL include `category`, `version`, `timestamp`, and `features` fields in that JSON payload +* *AND* the library SHALL perform network delivery without blocking the calling thread + + + +### Scenario: Batches multiple drained events into a single protocol message + +* *GIVEN* multiple accepted telemetry events are present when the background sender drains the queue +* *WHEN* the background sender emits the next protocol message +* *THEN* the library SHALL include the queued events in a single JSON payload +* *AND* the library SHALL group timestamps by caller-provided feature name in the `features` map +* *AND* the library SHALL correctly JSON-escape caller-provided feature names when serializing the `features` map + diff --git a/specs/_plans/change-tracking-message-metadata/plan.md b/specs/_plans/change-tracking-message-metadata/plan.md new file mode 100644 index 0000000..3d6c1d3 --- /dev/null +++ b/specs/_plans/change-tracking-message-metadata/plan.md @@ -0,0 +1,117 @@ +# Plan: change-tracking-message-metadata + +## Summary + +This plan replaces project-tag-prefixed feature names with explicit top-level message metadata. It adds a permanent config feature for required client identity inputs and updates the tracking, delivery, and tracking-controls specs so they consistently require `category=projectTag`, `version=`, and caller-provided feature names in the payload without feature-name validation beyond ignoring `null` values. + +## Design + +### Context + +The current contract mixes two identities into one field by encoding the configured project tag into every feature name, while the issue requires a top-level `category` field and a required host-product version. The permanent spec library also lacks a feature that owns required client identity inputs, so the new runtime contract would remain underspecified unless the plan adds one. + +- **Goals** — Move project identity out of feature names, require host applications to configure both project tag and product version, preserve application-chosen feature names without validation apart from ignoring `null` values, and make emitted payloads carry `category` and `version` explicitly. +- **Non-Goals** — Change retry behavior, add extra payload fields, impose feature-name rules, or redesign endpoint/environment override behavior. + +### Decision + +Add a new permanent `config/client-identity` feature that defines required project tag and version inputs. Update the tracking API so accepted feature names are sent exactly as provided rather than being qualified with the project tag or validated by the library, except that `null` feature names are ignored and not queued. Update async delivery so every JSON payload includes `category`, `version`, `timestamp`, and `features` with correct JSON escaping for feature keys. + +#### Architecture + +``` +┌──────────────────┐ +│ TelemetryConfig │ +│ projectTag │ +│ version │ +└────────┬─────────┘ + │ + ▼ +┌──────────────────┐ +│ TelemetryClient │ +│ track(feature) │ +│ no tag prefixing │ +└────────┬─────────┘ + │ + ▼ +┌──────────────────┐ +│ Message │ +│ category │ +│ version │ +│ timestamp │ +│ features │ +└──────────────────┘ +``` + +### Consequences + +| Decision | Alternatives Considered | Rationale | +|----------|------------------------|-----------| +| Add `config/client-identity` as a new permanent feature | Fold version requirements into `tracking-api` only | Required configuration inputs are part of the public setup contract and need a clear permanent owner | +| Send raw feature names and add top-level `category` | Keep prefixing features and also add `category` | Prefixing would duplicate project identity and violate the issue acceptance criteria | +| Define `version` as the configured host product/library version | Keep the existing hard-coded protocol version field and add another field | The issue explicitly wants emitted version information about the integrating product/library | + +## Features + +| Feature | Status | Spec | +|---------|--------|------| +| client-identity | NEW | `config/client-identity/spec.md` | +| tracking-controls | CHANGED | `config/tracking-controls/spec.md` | +| async-delivery | CHANGED | `delivery/async-delivery/spec.md` | +| tracking-api | CHANGED | `tracking/tracking-api/spec.md` | + +## Requirements + +| Requirement | Details | +|-------------|---------| +| Required identity inputs | Host applications SHALL provide a non-blank project tag and a non-blank product/library version when creating telemetry configuration | +| Payload identity | Emitted telemetry messages SHALL include `category` equal to the configured project tag | +| Payload version | Emitted telemetry messages SHALL include `version` equal to the configured host product/library version | +| Feature semantics | Emitted `features` keys SHALL use the caller-provided feature names without adding the project tag as a prefix or validating the chosen names, except that `null` feature names SHALL be ignored | +| JSON encoding | The library SHALL correctly JSON-escape caller-provided feature names when serializing the `features` map | + +## Implementation Tasks + +1. Extend `TelemetryConfig` to require and expose both project tag and product/library version through the public builder entry point. +2. Remove feature-name qualification and feature-name validation from `TelemetryClient` so it queues caller-provided feature names without project-tag prefixing or library-side filtering, while ignoring `null` feature names. +3. Update `Message` serialization to emit `category`, configured `version`, `timestamp`, and caller-provided feature keys with correct JSON escaping. +4. Adjust helper/test fixtures such as `RecordingHttpServer` and any hard-coded payload assertions to use the new builder contract and payload shape. +5. Update docs and Javadocs where they still describe project-tag-prefixed feature names or a fixed payload version. + +## Dead Code Removal + +| Type | Location | Reason | +|------|----------|--------| +| Field | `src/main/java/com/exasol/telemetry/TelemetryClient.java` | The cached feature-prefix field becomes obsolete once feature qualification is removed | +| Methods | `src/main/java/com/exasol/telemetry/TelemetryClient.java` | Feature qualification and most feature-name validation helpers become obsolete once raw feature names are emitted without validation apart from ignoring `null` | +| Test assertions | `src/test/java/com/exasol/telemetry/*` | Assertions expecting prefixed feature keys or fixed payload version strings become obsolete under the new payload contract | + +## Verification + +### Scenario Coverage + +| Scenario | Test Type | Test Location | Test Name | +|----------|-----------|---------------|-----------| +| Requires project tag and version when creating telemetry configuration | Unit | `src/test/java/com/exasol/telemetry/TelemetryConfigTest.java` | `requiresProjectTagAndVersion` | +| Overrides the configured endpoint via environment variable | Integration | `src/test/java/com/exasol/telemetry/TrackingControlsIT.java` | `overridesConfiguredEndpointWithoutChangingPayloadIdentity` | +| Sends queued events asynchronously over HTTP with configured identity metadata | Integration | `src/test/java/com/exasol/telemetry/TrackingApiIT.java` | `recordsFeatureUsageEventWithCategoryAndVersion` | +| Batches multiple drained events into a single protocol message | Integration | `src/test/java/com/exasol/telemetry/AsyncDeliveryIT.java` | `batchesMultipleDrainedEventsIntoSingleProtocolMessage` | +| Records a feature usage event without project-tag prefixing or feature-name validation | Integration | `src/test/java/com/exasol/telemetry/TrackingApiIT.java` | `recordsFeatureUsageEventWithoutPrefixingOrValidation` | +| Ignores null feature names | Integration | `src/test/java/com/exasol/telemetry/TrackingApiIT.java` | `ignoresNullFeatureNames` | +| Escapes feature names correctly in JSON | Unit | `src/test/java/com/exasol/telemetry/MessageTest.java` | `escapesFeatureNamesInJson` | +| Keeps caller-thread overhead low for accepted tracking | Integration | `src/test/java/com/exasol/telemetry/TrackingApiIT.java` | `keepsCallerThreadOverheadLowForAcceptedTracking` | + +### Manual Testing + +| Feature | Command | Expected Output | +|---------|---------|-----------------| +| client-identity, tracking-controls, async-delivery, tracking-api | `mvn verify` | Tests confirm required config identity, unvalidated caller-provided feature names except ignored `null` values, correct JSON escaping, top-level `category`, and configured `version` across payload-producing paths | + +### Checklist + +| Step | Command | Expected | +|------|---------|----------| +| Build | `mvn package` | Exit 0 | +| Test | `mvn test` | 0 failures | +| Lint | `mvn verify` | Exit 0 | +| Format | `mvn verify` | Exit 0; no dedicated formatter command is defined in the mission | diff --git a/specs/_plans/change-tracking-message-metadata/tracking/tracking-api/spec.md b/specs/_plans/change-tracking-message-metadata/tracking/tracking-api/spec.md new file mode 100644 index 0000000..932d830 --- /dev/null +++ b/specs/_plans/change-tracking-message-metadata/tracking/tracking-api/spec.md @@ -0,0 +1,40 @@ +# Feature: tracking-api + +Enables host applications to record feature-usage events without coupling feature names to the configured project tag. + +## Background + +The host application still configures project identity once at startup, but accepted feature names are transmitted as caller-provided strings. Project tag and product version are emitted separately as message metadata, and the library does not validate which feature names applications choose apart from ignoring `null` values. + +## Scenarios + + +### Scenario: Records a tagged feature usage event + +* *GIVEN* the library is configured with a project short tag and product/library version +* *AND* tracking is enabled +* *WHEN* the host application records a feature-usage event +* *THEN* the library SHALL accept the event for delivery +* *AND* the library SHALL preserve the caller-provided feature name in the emitted protocol payload without adding the configured project short tag as a prefix +* *AND* the library MUST NOT validate or reject the feature name based on application-defined naming choices + + + +### Scenario: Ignores null feature names + +* *GIVEN* tracking is enabled +* *WHEN* the host application records a `null` feature name +* *THEN* the library SHALL ignore that call +* *AND* the library MUST NOT enqueue or emit a telemetry event for the `null` feature name + + + +### Scenario: Keeps caller-thread overhead low for accepted tracking + +* *GIVEN* tracking is enabled +* *AND* the host application records a valid 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 +* *AND* the library SHOULD avoid avoidable heap allocations on the caller thread + From 84de4b22d219e0dab61b4f39672c6b46eca3f459 Mon Sep 17 00:00:00 2001 From: Christoph Pirkl Date: Wed, 15 Apr 2026 15:29:19 +0200 Subject: [PATCH 2/7] Update plan to keep protocol version --- .../config/client-identity/spec.md | 6 +-- .../config/tracking-controls/spec.md | 5 ++- .../delivery/async-delivery/spec.md | 7 ++-- .../change-tracking-message-metadata/plan.md | 38 ++++++++++--------- .../tracking/tracking-api/spec.md | 6 ++- 5 files changed, 34 insertions(+), 28 deletions(-) diff --git a/specs/_plans/change-tracking-message-metadata/config/client-identity/spec.md b/specs/_plans/change-tracking-message-metadata/config/client-identity/spec.md index d7c3afc..6f1be62 100644 --- a/specs/_plans/change-tracking-message-metadata/config/client-identity/spec.md +++ b/specs/_plans/change-tracking-message-metadata/config/client-identity/spec.md @@ -4,13 +4,13 @@ Defines the required product identity values that the host application configure ## Background -Telemetry messages carry two stable identity fields: `category`, which is the configured project tag, and `version`, which is the integrating product or library version. Feature names remain arbitrary caller-provided strings and MUST NOT duplicate project identity. +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. ## Scenarios -### Scenario: Requires project tag and version when creating telemetry configuration +### Scenario: Requires project tag and productVersion when creating telemetry configuration * *GIVEN* the host application creates telemetry configuration -* *WHEN* the host application provides a blank project tag or a blank product/library version +* *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 diff --git a/specs/_plans/change-tracking-message-metadata/config/tracking-controls/spec.md b/specs/_plans/change-tracking-message-metadata/config/tracking-controls/spec.md index eba291d..1c58218 100644 --- a/specs/_plans/change-tracking-message-metadata/config/tracking-controls/spec.md +++ b/specs/_plans/change-tracking-message-metadata/config/tracking-controls/spec.md @@ -11,10 +11,11 @@ Environment variables may disable telemetry or override the delivery endpoint, b ### Scenario: Overrides the configured endpoint via environment variable -* *GIVEN* the host application configures an endpoint, project tag, and product/library version in code +* *GIVEN* the host application configures an endpoint, project tag, and `productVersion` in code * *AND* the host environment defines an endpoint override * *WHEN* the library initializes * *THEN* the library SHALL use the environment-provided endpoint for delivery * *AND* the library SHALL continue to emit the configured project tag as the `category` field -* *AND* the library SHALL continue to emit the configured product/library version as the `version` field +* *AND* the library SHALL continue to emit the configured `productVersion` as the `productVersion` field +* *AND* the library SHALL continue to emit protocol `version`=`0.2.0` diff --git a/specs/_plans/change-tracking-message-metadata/delivery/async-delivery/spec.md b/specs/_plans/change-tracking-message-metadata/delivery/async-delivery/spec.md index cbe7d89..8569a9b 100644 --- a/specs/_plans/change-tracking-message-metadata/delivery/async-delivery/spec.md +++ b/specs/_plans/change-tracking-message-metadata/delivery/async-delivery/spec.md @@ -4,18 +4,19 @@ Delivers accepted usage events asynchronously while serializing explicit client ## Background -The telemetry protocol now carries project identity in top-level message fields instead of encoding it into feature names. Async delivery remains responsible for emitting valid JSON over HTTP without blocking the caller thread, including correct escaping of caller-provided feature names in JSON object keys. +The telemetry protocol now carries project identity in top-level message fields instead of encoding it into feature names. The JSON `version` field remains the telemetry protocol version and is incremented to `0.2.0`, while `productVersion` carries the integrating product or library version. Async delivery remains responsible for emitting valid JSON over HTTP without blocking the caller thread, including correct escaping of caller-provided feature names in JSON object keys. ## Scenarios ### Scenario: Sends queued events asynchronously over HTTP -* *GIVEN* the library is configured with an endpoint, project tag, and product/library version +* *GIVEN* the library is configured with an endpoint, project tag, and `productVersion` * *AND* an accepted usage event is queued for delivery * *WHEN* the background sender processes the queue * *THEN* the library SHALL submit a protocol message as JSON using HTTP `POST` -* *AND* the library SHALL include `category`, `version`, `timestamp`, and `features` fields in that JSON payload +* *AND* the library SHALL include `category`, `version`, `productVersion`, `timestamp`, and `features` fields in that JSON payload +* *AND* the library SHALL emit protocol `version`=`0.2.0` * *AND* the library SHALL perform network delivery without blocking the calling thread diff --git a/specs/_plans/change-tracking-message-metadata/plan.md b/specs/_plans/change-tracking-message-metadata/plan.md index 3d6c1d3..c568142 100644 --- a/specs/_plans/change-tracking-message-metadata/plan.md +++ b/specs/_plans/change-tracking-message-metadata/plan.md @@ -2,20 +2,20 @@ ## Summary -This plan replaces project-tag-prefixed feature names with explicit top-level message metadata. It adds a permanent config feature for required client identity inputs and updates the tracking, delivery, and tracking-controls specs so they consistently require `category=projectTag`, `version=`, and caller-provided feature names in the payload without feature-name validation beyond ignoring `null` values. +This plan replaces project-tag-prefixed feature names with explicit top-level message metadata. It keeps the JSON `version` field as the telemetry protocol version, increments that protocol version to `0.2.0`, and adds a separate `productVersion` field for the host product/library version. It also updates the tracking, delivery, and tracking-controls specs so they consistently require caller-provided feature names in the payload without feature-name validation beyond ignoring `null` values. ## Design ### Context -The current contract mixes two identities into one field by encoding the configured project tag into every feature name, while the issue requires a top-level `category` field and a required host-product version. The permanent spec library also lacks a feature that owns required client identity inputs, so the new runtime contract would remain underspecified unless the plan adds one. +The current contract mixes two identities into one field by encoding the configured project tag into every feature name, while the issue requires a top-level `category` field and a required host product/library version. The JSON payload already has a `version` field, so changing its meaning would break the protocol contract unless the host-side version is given a distinct name. The permanent spec library also lacks a feature that owns required client identity inputs, so the new runtime contract would remain underspecified unless the plan adds one. -- **Goals** — Move project identity out of feature names, require host applications to configure both project tag and product version, preserve application-chosen feature names without validation apart from ignoring `null` values, and make emitted payloads carry `category` and `version` explicitly. +- **Goals** — Move project identity out of feature names, require host applications to configure both project tag and product/library version, preserve application-chosen feature names without validation apart from ignoring `null` values, keep the protocol `version` field, and make emitted payloads carry `category` and `productVersion` explicitly. - **Non-Goals** — Change retry behavior, add extra payload fields, impose feature-name rules, or redesign endpoint/environment override behavior. ### Decision -Add a new permanent `config/client-identity` feature that defines required project tag and version inputs. Update the tracking API so accepted feature names are sent exactly as provided rather than being qualified with the project tag or validated by the library, except that `null` feature names are ignored and not queued. Update async delivery so every JSON payload includes `category`, `version`, `timestamp`, and `features` with correct JSON escaping for feature keys. +Add a new permanent `config/client-identity` feature that defines required project tag and `productVersion` inputs. Keep the protocol `version` field in the JSON message and increment it to `0.2.0`. Update the tracking API so accepted feature names are sent exactly as provided rather than being qualified with the project tag or validated by the library, except that `null` feature names are ignored and not queued. Update async delivery so every JSON payload includes `category`, `version`, `productVersion`, `timestamp`, and `features` with correct JSON escaping for feature keys. #### Architecture @@ -23,7 +23,7 @@ Add a new permanent `config/client-identity` feature that defines required proje ┌──────────────────┐ │ TelemetryConfig │ │ projectTag │ -│ version │ +│ productVersion │ └────────┬─────────┘ │ ▼ @@ -37,7 +37,8 @@ Add a new permanent `config/client-identity` feature that defines required proje ┌──────────────────┐ │ Message │ │ category │ -│ version │ +│ version=0.2.0 │ +│ productVersion │ │ timestamp │ │ features │ └──────────────────┘ @@ -47,9 +48,9 @@ Add a new permanent `config/client-identity` feature that defines required proje | Decision | Alternatives Considered | Rationale | |----------|------------------------|-----------| -| Add `config/client-identity` as a new permanent feature | Fold version requirements into `tracking-api` only | Required configuration inputs are part of the public setup contract and need a clear permanent owner | +| Add `config/client-identity` as a new permanent feature | Fold `productVersion` requirements into `tracking-api` only | Required configuration inputs are part of the public setup contract and need a clear permanent owner | | Send raw feature names and add top-level `category` | Keep prefixing features and also add `category` | Prefixing would duplicate project identity and violate the issue acceptance criteria | -| Define `version` as the configured host product/library version | Keep the existing hard-coded protocol version field and add another field | The issue explicitly wants emitted version information about the integrating product/library | +| Keep `version` as the protocol version and add `productVersion` for the host product/library version | Reinterpret `version` as the host version | Preserves the existing protocol field semantics and avoids overloading one field with two meanings | ## Features @@ -64,19 +65,20 @@ Add a new permanent `config/client-identity` feature that defines required proje | Requirement | Details | |-------------|---------| -| Required identity inputs | Host applications SHALL provide a non-blank project tag and a non-blank product/library version when creating telemetry configuration | +| Required identity inputs | Host applications SHALL provide a non-blank project tag and a non-blank `productVersion` when creating telemetry configuration | | Payload identity | Emitted telemetry messages SHALL include `category` equal to the configured project tag | -| Payload version | Emitted telemetry messages SHALL include `version` equal to the configured host product/library version | +| Protocol version | Emitted telemetry messages SHALL include `version` equal to the protocol version `0.2.0` | +| Product version | Emitted telemetry messages SHALL include `productVersion` equal to the configured host product/library version | | Feature semantics | Emitted `features` keys SHALL use the caller-provided feature names without adding the project tag as a prefix or validating the chosen names, except that `null` feature names SHALL be ignored | | JSON encoding | The library SHALL correctly JSON-escape caller-provided feature names when serializing the `features` map | ## Implementation Tasks -1. Extend `TelemetryConfig` to require and expose both project tag and product/library version through the public builder entry point. +1. Extend `TelemetryConfig` to require and expose both project tag and `productVersion` through the public builder entry point. 2. Remove feature-name qualification and feature-name validation from `TelemetryClient` so it queues caller-provided feature names without project-tag prefixing or library-side filtering, while ignoring `null` feature names. -3. Update `Message` serialization to emit `category`, configured `version`, `timestamp`, and caller-provided feature keys with correct JSON escaping. +3. Update `Message` serialization to emit `category`, protocol `version`=`0.2.0`, configured `productVersion`, `timestamp`, and caller-provided feature keys with correct JSON escaping. 4. Adjust helper/test fixtures such as `RecordingHttpServer` and any hard-coded payload assertions to use the new builder contract and payload shape. -5. Update docs and Javadocs where they still describe project-tag-prefixed feature names or a fixed payload version. +5. Update docs and Javadocs where they still describe project-tag-prefixed feature names or outdated payload field semantics. ## Dead Code Removal @@ -84,7 +86,7 @@ Add a new permanent `config/client-identity` feature that defines required proje |------|----------|--------| | Field | `src/main/java/com/exasol/telemetry/TelemetryClient.java` | The cached feature-prefix field becomes obsolete once feature qualification is removed | | Methods | `src/main/java/com/exasol/telemetry/TelemetryClient.java` | Feature qualification and most feature-name validation helpers become obsolete once raw feature names are emitted without validation apart from ignoring `null` | -| Test assertions | `src/test/java/com/exasol/telemetry/*` | Assertions expecting prefixed feature keys or fixed payload version strings become obsolete under the new payload contract | +| Test assertions | `src/test/java/com/exasol/telemetry/*` | Assertions expecting prefixed feature keys or the old protocol version string become obsolete under the new payload contract | ## Verification @@ -92,20 +94,20 @@ Add a new permanent `config/client-identity` feature that defines required proje | Scenario | Test Type | Test Location | Test Name | |----------|-----------|---------------|-----------| -| Requires project tag and version when creating telemetry configuration | Unit | `src/test/java/com/exasol/telemetry/TelemetryConfigTest.java` | `requiresProjectTagAndVersion` | +| Requires project tag and productVersion when creating telemetry configuration | Unit | `src/test/java/com/exasol/telemetry/TelemetryConfigTest.java` | `requiresProjectTagAndProductVersion` | | Overrides the configured endpoint via environment variable | Integration | `src/test/java/com/exasol/telemetry/TrackingControlsIT.java` | `overridesConfiguredEndpointWithoutChangingPayloadIdentity` | -| Sends queued events asynchronously over HTTP with configured identity metadata | Integration | `src/test/java/com/exasol/telemetry/TrackingApiIT.java` | `recordsFeatureUsageEventWithCategoryAndVersion` | +| Sends queued events asynchronously over HTTP with configured identity metadata | Integration | `src/test/java/com/exasol/telemetry/TrackingApiIT.java` | `recordsFeatureUsageEventWithCategoryProtocolVersionAndProductVersion` | | Batches multiple drained events into a single protocol message | Integration | `src/test/java/com/exasol/telemetry/AsyncDeliveryIT.java` | `batchesMultipleDrainedEventsIntoSingleProtocolMessage` | | Records a feature usage event without project-tag prefixing or feature-name validation | Integration | `src/test/java/com/exasol/telemetry/TrackingApiIT.java` | `recordsFeatureUsageEventWithoutPrefixingOrValidation` | | Ignores null feature names | Integration | `src/test/java/com/exasol/telemetry/TrackingApiIT.java` | `ignoresNullFeatureNames` | -| Escapes feature names correctly in JSON | Unit | `src/test/java/com/exasol/telemetry/MessageTest.java` | `escapesFeatureNamesInJson` | +| Emits protocol version `0.2.0`, configured `productVersion`, and escaped feature names in JSON | Unit | `src/test/java/com/exasol/telemetry/MessageTest.java` | `serializesProtocolVersionProductVersionAndEscapedFeatureNames` | | Keeps caller-thread overhead low for accepted tracking | Integration | `src/test/java/com/exasol/telemetry/TrackingApiIT.java` | `keepsCallerThreadOverheadLowForAcceptedTracking` | ### Manual Testing | Feature | Command | Expected Output | |---------|---------|-----------------| -| client-identity, tracking-controls, async-delivery, tracking-api | `mvn verify` | Tests confirm required config identity, unvalidated caller-provided feature names except ignored `null` values, correct JSON escaping, top-level `category`, and configured `version` across payload-producing paths | +| client-identity, tracking-controls, async-delivery, tracking-api | `mvn verify` | Tests confirm required config identity, protocol `version=0.2.0`, configured `productVersion`, unvalidated caller-provided feature names except ignored `null` values, correct JSON escaping, and top-level `category` across payload-producing paths | ### Checklist diff --git a/specs/_plans/change-tracking-message-metadata/tracking/tracking-api/spec.md b/specs/_plans/change-tracking-message-metadata/tracking/tracking-api/spec.md index 932d830..550bfa3 100644 --- a/specs/_plans/change-tracking-message-metadata/tracking/tracking-api/spec.md +++ b/specs/_plans/change-tracking-message-metadata/tracking/tracking-api/spec.md @@ -4,18 +4,20 @@ Enables host applications to record feature-usage events without coupling featur ## Background -The host application still configures project identity once at startup, but accepted feature names are transmitted as caller-provided strings. Project tag and product version are emitted separately as message metadata, and the library does not validate which feature names applications choose apart from ignoring `null` values. +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 -* *GIVEN* the library is configured with a project short tag and product/library version +* *GIVEN* the library is configured with a project short tag and `productVersion` * *AND* tracking is enabled * *WHEN* the host application records a feature-usage event * *THEN* the library SHALL accept the event for delivery * *AND* the library SHALL preserve the caller-provided feature name in the emitted protocol payload without adding the configured project short tag as a prefix +* *AND* the library SHALL emit the configured `productVersion` as the `productVersion` field +* *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 From 2ad34cb61270e8d3a5f10e4bb97be115eee10b69 Mon Sep 17 00:00:00 2001 From: Christoph Pirkl Date: Wed, 15 Apr 2026 15:44:46 +0200 Subject: [PATCH 3/7] #6: Implement plan --- README.md | 4 +- doc/integration-guide.md | 10 +-- .../change-tracking-message-metadata/tasks.md | 13 ++++ .../verification-report.md | 71 +++++++++++++++++++ .../java/com/exasol/telemetry/Message.java | 19 +++-- .../com/exasol/telemetry/TelemetryClient.java | 24 ++----- .../com/exasol/telemetry/TelemetryConfig.java | 19 +++-- .../com/exasol/telemetry/AsyncDeliveryIT.java | 7 +- .../exasol/telemetry/HttpTransportTest.java | 19 +++-- .../com/exasol/telemetry/MessageTest.java | 25 +++++-- .../exasol/telemetry/RecordingHttpServer.java | 4 +- .../com/exasol/telemetry/ShutdownFlushIT.java | 11 ++- .../com/exasol/telemetry/StatusLoggingIT.java | 13 ++-- .../exasol/telemetry/TelemetryClientTest.java | 12 ++-- .../exasol/telemetry/TelemetryConfigTest.java | 19 +++-- .../com/exasol/telemetry/TrackingApiIT.java | 37 +++++++--- .../exasol/telemetry/TrackingControlsIT.java | 7 +- 17 files changed, 227 insertions(+), 87 deletions(-) create mode 100644 specs/_plans/change-tracking-message-metadata/tasks.md create mode 100644 specs/_plans/change-tracking-message-metadata/verification-report.md diff --git a/README.md b/README.md index bc2e13f..ee405d5 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ The project uses `speq` skills and recorded specs to drive planning, implementat - Non-blocking telemetry recording through an in-memory queue and background sender - JSON-over-HTTP `POST` delivery -- Protocol-compatible message format with `version`, `timestamp`, and `features` +- Protocol-compatible message format with `category`, protocol `version`, `productVersion`, `timestamp`, and `features` - Exponential backoff with retry timeout - Clean shutdown via `AutoCloseable` - Environment-variable control for disabling telemetry and overriding the endpoint @@ -41,7 +41,7 @@ The project uses `speq` skills and recorded specs to drive planning, implementat ```java import com.exasol.telemetry.TelemetryClient; import com.exasol.telemetry.TelemetryConfig; -TelemetryConfig config = TelemetryConfig.builder("my-app").build(); +TelemetryConfig config = TelemetryConfig.builder("my-app", "1.2.3").build(); try (TelemetryClient client = TelemetryClient.create(config)) { client.track("startup"); diff --git a/doc/integration-guide.md b/doc/integration-guide.md index adf8776..a625e52 100644 --- a/doc/integration-guide.md +++ b/doc/integration-guide.md @@ -6,7 +6,7 @@ ```java String projectShortTag = "MyApp"; -TelemetryConfig config = TelemetryConfig.builder(projectShortTag).build(); +TelemetryConfig config = TelemetryConfig.builder(projectShortTag, "1.2.3").build(); try (TelemetryClient client = TelemetryClient.create(config)) { client.track("checkout-started"); @@ -15,7 +15,7 @@ try (TelemetryClient client = TelemetryClient.create(config)) { ## Required Configuration -- A project short tag at startup. The library adds it to every accepted telemetry event. +- A project short tag and a product/library version at startup. The library adds the project tag as the telemetry category and includes the configured productVersion in every accepted telemetry event. - An optional HTTP endpoint for JSON `POST` delivery. If omitted, the default endpoint is `https://metrics.exasol.com`. ## Required Documentation @@ -60,9 +60,9 @@ For Exasol UDF integration tests, disable telemetry explicitly so test execution - Tracking calls are non-blocking and enqueue events into a bounded in-memory queue. - Delivery happens on a background sender thread. -- The JSON payload format matches the Python protocol shape: `version`, `timestamp`, and `features`. -- Multiple queued events may be batched into a single payload, with timestamps grouped by fully qualified feature name. -- The configured project short tag prefixes feature names in the payload, for example `MyApp.checkout-started`. +- The JSON payload format includes `category`, protocol `version`, `productVersion`, `timestamp`, and `features`. +- Multiple queued events may be batched into a single payload, with timestamps grouped by caller-provided feature name. +- The configured project short tag is emitted as top-level `category`; feature names are preserved as provided, for example `checkout-started`. - Failed delivery uses exponential backoff and stops when the configured retry timeout is reached. - Closing `TelemetryClient` flushes pending work before returning and stops background threads. - Calling `track(...)` after `TelemetryClient` is closed is a no-op. diff --git a/specs/_plans/change-tracking-message-metadata/tasks.md b/specs/_plans/change-tracking-message-metadata/tasks.md new file mode 100644 index 0000000..ee57c82 --- /dev/null +++ b/specs/_plans/change-tracking-message-metadata/tasks.md @@ -0,0 +1,13 @@ +# Tasks: change-tracking-message-metadata + +## Phase 2: Implementation +- [x] 2.1 Update telemetry configuration and payload model to require project tag and `productVersion`, keep protocol `version`, and emit `category`, `version=0.2.0`, and `productVersion` +- [x] 2.2 Remove feature-name prefixing, ignore `null` feature names, and preserve caller-provided feature names for delivery +- [x] 2.3 Update unit and integration tests plus helper fixtures for the new builder contract and payload shape +- [x] 2.4 Update docs and Javadocs that still describe prefixed feature names or outdated payload field semantics + +## Phase 3: Verification +- [x] 3.1 Run targeted tests for configuration, payload serialization, transport, tracking API, delivery, and tracking controls +- [x] 3.2 Run `mvn test` +- [x] 3.3 Run `mvn verify` +- [x] 3.4 Generate verification report diff --git a/specs/_plans/change-tracking-message-metadata/verification-report.md b/specs/_plans/change-tracking-message-metadata/verification-report.md new file mode 100644 index 0000000..cc68407 --- /dev/null +++ b/specs/_plans/change-tracking-message-metadata/verification-report.md @@ -0,0 +1,71 @@ +# Verification Report: change-tracking-message-metadata + +**Generated:** 2026-04-15 + +## Verdict + +| Result | Details | +|--------|---------| +| **PASS** | The plan is implemented and verified. Telemetry config now requires `projectTag` plus `productVersion`, payloads emit `category`, protocol `version=0.2.0`, and `productVersion`, caller-provided feature names are preserved, `null` feature names are ignored, and feature keys are JSON-escaped correctly. | + +| Check | Status | +|-------|--------| +| Build | ✓ | +| Tests | ✓ | +| Lint | ✓ | +| Format | ✓ | +| Scenario Coverage | ✓ | +| Manual Tests | ✓ | + +## Test Evidence + +### Coverage + +| Type | Coverage % | +|------|------------| +| Unit | 24/24 tests passed in `mvn test` | +| Integration | 18/18 tests passed in `mvn verify` | + +### Test Results + +| Type | Run | Passed | Ignored | +|------|-----|--------|---------| +| Unit | 24 | 24 | 0 | +| Integration | 18 | 18 | 0 | + +### Manual Tests + +| Test | Result | +|------|--------| +| `mvn verify` | ✓ | + +## Tool Evidence + +### Linter + +``` +`mvn verify` completed successfully with project-keeper, duplicate-finder, build-plan, gpg, failsafe, jacoco, quality-summarizer, error-code-crawler, and openfasttrace checks passing. +``` + +### Formatter + +``` +No dedicated formatter command is defined for this project. `mvn verify` completed with no format violations. +``` + +## Scenario Coverage + +| Domain | Feature | Scenario | Test Location | Test Name | Passes | +|--------|---------|----------|---------------|-----------|--------| +| config | client-identity | Requires project tag and productVersion when creating telemetry configuration | `src/test/java/com/exasol/telemetry/TelemetryConfigTest.java` | `rejectsBlankProjectTag` / `rejectsBlankProductVersion` | Pass | +| tracking | tracking-api | Records a tagged feature usage event | `src/test/java/com/exasol/telemetry/TrackingApiIT.java` | `recordsFeatureUsageEventWithCategoryProtocolVersionAndProductVersion` | Pass | +| tracking | tracking-api | Ignores null feature names | `src/test/java/com/exasol/telemetry/TrackingApiIT.java` | `ignoresNullFeatureNames` | Pass | +| tracking | tracking-api | Keeps caller-thread overhead low for accepted tracking | `src/test/java/com/exasol/telemetry/TrackingApiIT.java` | `keepsCallerThreadOverheadLowForAcceptedTracking` | Pass | +| delivery | async-delivery | Sends queued events asynchronously over HTTP | `src/test/java/com/exasol/telemetry/AsyncDeliveryIT.java` | `sendsQueuedEventsAsynchronouslyOverHttp` | Pass | +| delivery | async-delivery | Batches multiple drained events into a single protocol message | `src/test/java/com/exasol/telemetry/AsyncDeliveryIT.java` | `retriesFailedDeliveryWithExponentialBackoffUntilTimeout` | Pass | +| tracking-controls | tracking-controls | Overrides the configured endpoint via environment variable | `src/test/java/com/exasol/telemetry/TrackingControlsIT.java` | `overridesConfiguredEndpointViaEnvironmentVariable` | Pass | +| message | async-delivery | Emits protocol version `0.2.0`, configured `productVersion`, and escaped feature names in JSON | `src/test/java/com/exasol/telemetry/MessageTest.java` | `groupsEventsByFeatureAndSerializesProtocolShape` / `escapesFeatureNamesInJson` | Pass | + +## Notes + +`mvn test` and `mvn verify` both passed cleanly. `mvn verify` also completed the packaging, Javadoc, source/javadoc jar, project-keeper, duplicate-finder, and openfasttrace checks. diff --git a/src/main/java/com/exasol/telemetry/Message.java b/src/main/java/com/exasol/telemetry/Message.java index 7080a32..49b12dd 100644 --- a/src/main/java/com/exasol/telemetry/Message.java +++ b/src/main/java/com/exasol/telemetry/Message.java @@ -6,28 +6,34 @@ import java.util.*; final class Message { - static final String VERSION = "0.1"; + static final String VERSION = "0.2.0"; + private final String category; + private final String productVersion; private final Instant timestamp; private final Map> features; - private Message(final Instant timestamp, final Map> features) { + private Message(final String category, final String productVersion, final Instant timestamp, final Map> features) { + this.category = requireNonNull(category, "category"); + this.productVersion = requireNonNull(productVersion, "productVersion"); this.timestamp = requireNonNull(timestamp, "timestamp"); this.features = requireNonNull(features, "features"); } - static Message fromEvents(final Instant timestamp, final List events) { + 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) { features.computeIfAbsent(event.getFeature(), ignored -> new ArrayList<>()).add(event.getTimestamp()); } - return new Message(timestamp, features); + return new Message(category, productVersion, timestamp, features); } String toJson() { final StringBuilder builder = new StringBuilder(); builder.append('{'); + builder.append("\"category\":\"").append(escape(category)).append("\","); builder.append("\"version\":\"").append(VERSION).append("\","); + builder.append("\"productVersion\":\"").append(escape(productVersion)).append("\","); builder.append("\"timestamp\":").append(timestamp.getEpochSecond()).append(','); builder.append("\"features\":{"); @@ -90,11 +96,12 @@ public boolean equals(final Object other) { return false; } final Message that = (Message) other; - return Objects.equals(timestamp, that.timestamp) && Objects.equals(features, that.features); + return Objects.equals(category, that.category) && Objects.equals(productVersion, that.productVersion) + && Objects.equals(timestamp, that.timestamp) && Objects.equals(features, that.features); } @Override public int hashCode() { - return Objects.hash(timestamp, features); + return Objects.hash(category, productVersion, timestamp, features); } } diff --git a/src/main/java/com/exasol/telemetry/TelemetryClient.java b/src/main/java/com/exasol/telemetry/TelemetryClient.java index 0c413fd..a5fcf00 100644 --- a/src/main/java/com/exasol/telemetry/TelemetryClient.java +++ b/src/main/java/com/exasol/telemetry/TelemetryClient.java @@ -10,7 +10,7 @@ /** * Tracks feature usage events and delivers them asynchronously to the configured telemetry endpoint. - * Create a client by building a {@link TelemetryConfig} with {@link TelemetryConfig#builder(String)} and passing it + * Create a client by building a {@link TelemetryConfig} with {@link TelemetryConfig#builder(String, String)} and passing it * to {@link #create(TelemetryConfig)}. */ public final class TelemetryClient implements AutoCloseable { @@ -22,7 +22,6 @@ public final class TelemetryClient implements AutoCloseable { private final Thread senderThread; private final Clock clock; private final boolean trackingEnabled; - private final String featurePrefix; private final CountDownLatch terminated = new CountDownLatch(1); private volatile boolean closed; @@ -36,7 +35,6 @@ private TelemetryClient(final TelemetryConfig config) { this.queue = new ArrayBlockingQueue<>(config.getQueueCapacity()); this.transport = new HttpTransport(config); this.trackingEnabled = !config.isTrackingDisabled(); - this.featurePrefix = config.getProjectTag() + "~"; this.senderThread = new Thread(this::runSender, "telemetry-java-sender"); this.senderThread.setDaemon(true); if (trackingEnabled) { @@ -61,19 +59,18 @@ public static TelemetryClient create(final TelemetryConfig config) { /** * Queue a feature usage event for asynchronous delivery. * - * @param feature feature name without the project tag prefix + * @param feature feature name provided by the caller */ public void track(final String feature) { if (!trackingEnabled || closed) { return; } - final String sanitizedFeature = sanitizeText(feature); - if (sanitizedFeature == null) { + if (feature == null) { return; } - final TelemetryEvent event = new TelemetryEvent(namespacedFeature(sanitizedFeature), clock.instant()); + final TelemetryEvent event = new TelemetryEvent(feature, clock.instant()); enqueue(event); } @@ -82,17 +79,6 @@ private void enqueue(final TelemetryEvent event) { queue.offer(event); } - private String namespacedFeature(final String feature) { - return featurePrefix + feature; - } - - private String sanitizeText(final String value) { - if (value == null || value.trim().isEmpty()) { - return null; - } - return value.trim(); - } - private void runSender() { try { while (!closed || !queue.isEmpty()) { @@ -117,7 +103,7 @@ private List drainBatch(final TelemetryEvent firstEvent) { private void sendWithRetry(final List events) { final Instant start = clock.instant(); - final Message message = Message.fromEvents(start, events); + final Message message = Message.fromEvents(config.getProjectTag(), config.getProductVersion(), start, events); final Instant deadline = start.plus(config.getRetryTimeout()); Duration delay = config.getInitialRetryDelay(); diff --git a/src/main/java/com/exasol/telemetry/TelemetryConfig.java b/src/main/java/com/exasol/telemetry/TelemetryConfig.java index a4a014d..6177d64 100644 --- a/src/main/java/com/exasol/telemetry/TelemetryConfig.java +++ b/src/main/java/com/exasol/telemetry/TelemetryConfig.java @@ -28,6 +28,7 @@ public final class TelemetryConfig { static final URI DEFAULT_ENDPOINT = URI.create("https://metrics.exasol.com"); private final String projectTag; + private final String productVersion; private final URI endpoint; private final String disabledEnvValue; private final String ciEnvValue; @@ -42,6 +43,7 @@ public final class TelemetryConfig { private TelemetryConfig(final Builder builder) { this.projectTag = requireText(builder.projectTag, "projectTag"); + this.productVersion = requireText(builder.productVersion, "productVersion"); this.environment = requireNonNull(builder.environment, "environment"); this.disabledEnvValue = environment.getenv(DISABLED_ENV); this.ciEnvValue = environment.getenv(CI_ENV); @@ -56,19 +58,24 @@ private TelemetryConfig(final Builder builder) { } /** - * Creates a builder for a telemetry configuration bound to the given project tag. + * Creates a builder for a telemetry configuration bound to the given project tag and product version. * - * @param projectTag project identifier used as the prefix for emitted feature names + * @param projectTag project identifier attached to emitted telemetry messages as {@code category} + * @param productVersion host product or library version attached to emitted telemetry messages as {@code productVersion} * @return configuration builder */ - public static Builder builder(final String projectTag) { - return new Builder(projectTag, null); + public static Builder builder(final String projectTag, final String productVersion) { + return new Builder(projectTag, productVersion, null); } String getProjectTag() { return projectTag; } + String getProductVersion() { + return productVersion; + } + URI getEndpoint() { return endpoint; } @@ -168,6 +175,7 @@ private static Duration positive(final Duration value, final String field) { */ public static final class Builder { private final String projectTag; + private final String productVersion; private URI endpoint; private int queueCapacity = 256; private Duration retryTimeout = Duration.ofSeconds(5); @@ -177,8 +185,9 @@ public static final class Builder { private Duration requestTimeout = Duration.ofSeconds(2); private Environment environment = Environment.SystemEnvironment.INSTANCE; - private Builder(final String projectTag, final URI endpoint) { + private Builder(final String projectTag, final String productVersion, final URI endpoint) { this.projectTag = projectTag; + this.productVersion = productVersion; this.endpoint = endpoint; } diff --git a/src/test/java/com/exasol/telemetry/AsyncDeliveryIT.java b/src/test/java/com/exasol/telemetry/AsyncDeliveryIT.java index 517181a..c71095c 100644 --- a/src/test/java/com/exasol/telemetry/AsyncDeliveryIT.java +++ b/src/test/java/com/exasol/telemetry/AsyncDeliveryIT.java @@ -12,12 +12,13 @@ class AsyncDeliveryIT { private static final String PROJECT_TAG = "projectTag"; + private static final String VERSION = "1.2.3"; private static final String FEATURE = "myFeature"; @Test void sendsQueuedEventsAsynchronouslyOverHttp() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createDelayedSuccessServer(300); - TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG) + TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, VERSION) .retryTimeout(Duration.ofMillis(500)) .build())) { final long start = System.nanoTime(); @@ -33,7 +34,7 @@ void sendsQueuedEventsAsynchronouslyOverHttp() throws Exception { @Test void retriesFailedDeliveryWithExponentialBackoffUntilTimeout() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createFlakyServer(2); - TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG) + TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, VERSION) .retryTimeout(Duration.ofSeconds(1)) .initialRetryDelay(Duration.ofMillis(50)) .maxRetryDelay(Duration.ofMillis(200)) @@ -47,7 +48,7 @@ void retriesFailedDeliveryWithExponentialBackoffUntilTimeout() throws Exception } try (RecordingHttpServer server = RecordingHttpServer.createFlakyServer(Integer.MAX_VALUE)) { - final TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG) + final TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, VERSION) .retryTimeout(Duration.ofMillis(220)) .initialRetryDelay(Duration.ofMillis(20)) .maxRetryDelay(Duration.ofMillis(80)) diff --git a/src/test/java/com/exasol/telemetry/HttpTransportTest.java b/src/test/java/com/exasol/telemetry/HttpTransportTest.java index bcf6550..06a079e 100644 --- a/src/test/java/com/exasol/telemetry/HttpTransportTest.java +++ b/src/test/java/com/exasol/telemetry/HttpTransportTest.java @@ -20,32 +20,37 @@ class HttpTransportTest { private static final String DUMMY_ENDPOINT = "https://example.com"; private static final String PROJECT_TAG = "projectTag"; + private static final String VERSION = "1.2.3"; private static final String FEATURE = "projectTag.feature"; @Test void sendsJsonPayloadToConfiguredClient() throws IOException { final CapturingRequestSender requestSender = new CapturingRequestSender(202); final HttpTransport transport = new HttpTransport( - TelemetryConfig.builder(PROJECT_TAG).endpoint(URI.create(DUMMY_ENDPOINT)).build(), + TelemetryConfig.builder(PROJECT_TAG, VERSION).endpoint(URI.create(DUMMY_ENDPOINT)).build(), requestSender); - transport.send(Message.fromEvents(Instant.ofEpochMilli(30), List.of(new TelemetryEvent(FEATURE, Instant.ofEpochSecond(10))))); + transport.send(Message.fromEvents(PROJECT_TAG, VERSION, Instant.ofEpochMilli(30), List.of(new TelemetryEvent(FEATURE, Instant.ofEpochSecond(10))))); final HttpRequest request = requestSender.request; + final String body = bodyToString(request); assertThat(request.method(), is("POST")); assertThat(request.uri(), is(URI.create(DUMMY_ENDPOINT))); assertThat(request.headers().firstValue("Content-Type").orElseThrow(), is("application/json")); - assertThat(bodyToString(request), containsString("\"features\":{\"projectTag.feature\":[10]}")); + assertThat(body, containsString("\"category\":\"projectTag\"")); + assertThat(body, containsString("\"version\":\"0.2.0\"")); + assertThat(body, containsString("\"productVersion\":\"1.2.3\"")); + assertThat(body, containsString("\"features\":{\"projectTag.feature\":[10]}")); } @Test void rejectsNonSuccessStatusCodes() { final HttpTransport transport = new HttpTransport( - TelemetryConfig.builder(PROJECT_TAG).endpoint(URI.create(DUMMY_ENDPOINT)).build(), + TelemetryConfig.builder(PROJECT_TAG, VERSION).endpoint(URI.create(DUMMY_ENDPOINT)).build(), request -> new HttpTransport.Response(500, "server says no")); final HttpException exception = assertThrows(HttpException.class, - () -> transport.send(Message.fromEvents(Instant.ofEpochSecond(30), List.of(new TelemetryEvent(FEATURE, Instant.ofEpochSecond(10)))))); + () -> transport.send(Message.fromEvents(PROJECT_TAG, VERSION, Instant.ofEpochSecond(30), List.of(new TelemetryEvent(FEATURE, Instant.ofEpochSecond(10)))))); assertThat(exception.getStatusCode(), is(500)); assertThat(exception.getServerStatus(), is("server says no")); assertThat(exception.getMessage(), is("server says no")); @@ -54,13 +59,13 @@ void rejectsNonSuccessStatusCodes() { @Test void convertsInterruptedExceptionToIoException() { final HttpTransport transport = new HttpTransport( - TelemetryConfig.builder(PROJECT_TAG).endpoint(URI.create(DUMMY_ENDPOINT)).build(), + TelemetryConfig.builder(PROJECT_TAG, VERSION).endpoint(URI.create(DUMMY_ENDPOINT)).build(), request -> { throw new InterruptedException("interrupted"); }); final IOException exception = assertThrows(IOException.class, - () -> transport.send(Message.fromEvents(Instant.ofEpochSecond(30), List.of(new TelemetryEvent(FEATURE, Instant.ofEpochSecond(10)))))); + () -> transport.send(Message.fromEvents(PROJECT_TAG, VERSION, Instant.ofEpochSecond(30), List.of(new TelemetryEvent(FEATURE, Instant.ofEpochSecond(10)))))); assertThat(exception.getMessage(), containsString("Interrupted while sending telemetry")); assertThat(Thread.currentThread().isInterrupted(), is(true)); Thread.interrupted(); diff --git a/src/test/java/com/exasol/telemetry/MessageTest.java b/src/test/java/com/exasol/telemetry/MessageTest.java index a48e3e0..13dd2d6 100644 --- a/src/test/java/com/exasol/telemetry/MessageTest.java +++ b/src/test/java/com/exasol/telemetry/MessageTest.java @@ -19,33 +19,37 @@ void verifiesEqualsAndHashCode() { @Test void groupsEventsByFeatureAndSerializesProtocolShape() { - final Message message = Message.fromEvents(Instant.ofEpochSecond(30), List.of( + final Message message = Message.fromEvents("shop-ui", "1.2.3", Instant.ofEpochSecond(30), List.of( new TelemetryEvent("project.a", Instant.ofEpochSecond(10)), new TelemetryEvent("project.a", Instant.ofEpochSecond(20)), new TelemetryEvent("project.b", Instant.ofEpochSecond(30)))); final String json = message.toJson(); - assertThat(json, containsString("\"version\":\"0.1\"")); + assertThat(json, containsString("\"category\":\"shop-ui\"")); + assertThat(json, containsString("\"version\":\"0.2.0\"")); + assertThat(json, containsString("\"productVersion\":\"1.2.3\"")); assertThat(json, containsString("\"timestamp\":")); assertThat(json, containsString("\"features\":{\"project.a\":[10,20],\"project.b\":[30]}")); } @Test void serializesValidJson() { - final Message message = Message.fromEvents(Instant.ofEpochSecond(30), List.of( + final Message message = Message.fromEvents("shop-ui", "1.2.3", Instant.ofEpochSecond(30), List.of( new TelemetryEvent("project.a", Instant.ofEpochSecond(10)))); final var payload = JsonTestHelper.parseJson(message.toJson()); + assertThat(payload.containsKey("category"), is(true)); assertThat(payload.containsKey("version"), is(true)); + assertThat(payload.containsKey("productVersion"), is(true)); assertThat(payload.containsKey("timestamp"), is(true)); assertThat(payload.containsKey("features"), is(true)); } @Test void escapesFeatureNamesInJson() { - final Message message = Message.fromEvents(Instant.ofEpochSecond(30), List.of( + final Message message = Message.fromEvents("shop-ui", "1.2.3", Instant.ofEpochSecond(30), List.of( new TelemetryEvent("proj.\"x\"\n\t\\", Instant.ofEpochSecond(10)))); final String json = message.toJson(); @@ -53,9 +57,20 @@ void escapesFeatureNamesInJson() { assertThat(json, containsString("proj.\\\"x\\\"\\n\\t\\\\")); } + @Test + void escapesCategoryAndProductVersionInJson() { + final Message message = Message.fromEvents("shop-\"ui\"\n\t\\", "1.2.3-\"beta\"\n\t\\", Instant.ofEpochSecond(30), List.of( + new TelemetryEvent("feature", Instant.ofEpochSecond(10)))); + + final String json = message.toJson(); + + assertThat(json, containsString("\"category\":\"shop-\\\"ui\\\"\\n\\t\\\\\"")); + assertThat(json, containsString("\"productVersion\":\"1.2.3-\\\"beta\\\"\\n\\t\\\\\"")); + } + @Test void serializesEmptyFeatureCollection() { - final Message message = Message.fromEvents(Instant.ofEpochSecond(30), List.of()); + final Message message = Message.fromEvents("shop-ui", "1.2.3", Instant.ofEpochSecond(30), List.of()); assertThat(message.toJson(), containsString("\"features\":{}")); } diff --git a/src/test/java/com/exasol/telemetry/RecordingHttpServer.java b/src/test/java/com/exasol/telemetry/RecordingHttpServer.java index e64fe24..f601798 100644 --- a/src/test/java/com/exasol/telemetry/RecordingHttpServer.java +++ b/src/test/java/com/exasol/telemetry/RecordingHttpServer.java @@ -43,8 +43,8 @@ static RecordingHttpServer createFlakyServer(final int failuresBeforeSuccess) { return new RecordingHttpServer(failuresBeforeSuccess, 0); } - TelemetryConfig.Builder configBuilder(final String projectTag) { - return TelemetryConfig.builder(projectTag) + TelemetryConfig.Builder configBuilder(final String projectTag, final String version) { + return TelemetryConfig.builder(projectTag, version) .endpoint(endpoint()) .environment(MapEnvironment.empty()); } diff --git a/src/test/java/com/exasol/telemetry/ShutdownFlushIT.java b/src/test/java/com/exasol/telemetry/ShutdownFlushIT.java index 4734ff3..b0859a0 100644 --- a/src/test/java/com/exasol/telemetry/ShutdownFlushIT.java +++ b/src/test/java/com/exasol/telemetry/ShutdownFlushIT.java @@ -9,11 +9,13 @@ import org.junit.jupiter.api.Test; class ShutdownFlushIT { + private static final String PRODUCT_VERSION = "1.2.3"; + @Test void flushesPendingEventsOnClose() throws Exception { final List requests; try (RecordingHttpServer server = RecordingHttpServer.createDelayedSuccessServer(150)) { - final TelemetryClient client = TelemetryClient.create(server.configBuilder("shop-ui") + final TelemetryClient client = TelemetryClient.create(server.configBuilder("shop-ui", PRODUCT_VERSION) .retryTimeout(Duration.ofSeconds(1)) .build()); client.track("checkout-started"); @@ -23,14 +25,17 @@ void flushesPendingEventsOnClose() throws Exception { } assertThat(requests, hasSize(1)); - assertThat(requests.get(0).body(), containsString("\"features\":{\"shop-ui~checkout-started\":[")); + assertThat(requests.get(0).body(), containsString("\"category\":\"shop-ui\"")); + assertThat(requests.get(0).body(), containsString("\"version\":\"0.2.0\"")); + assertThat(requests.get(0).body(), containsString("\"productVersion\":\"1.2.3\"")); + assertThat(requests.get(0).body(), containsString("\"features\":{\"checkout-started\":[")); } @Test void stopsBackgroundThreadsAfterClose() throws Exception { final TelemetryClient client; try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer()) { - client = TelemetryClient.create(server.configBuilder("shop-ui").build()); + client = TelemetryClient.create(server.configBuilder("shop-ui", PRODUCT_VERSION).build()); client.track("checkout-started"); client.close(); } diff --git a/src/test/java/com/exasol/telemetry/StatusLoggingIT.java b/src/test/java/com/exasol/telemetry/StatusLoggingIT.java index 2c42938..4d65b36 100644 --- a/src/test/java/com/exasol/telemetry/StatusLoggingIT.java +++ b/src/test/java/com/exasol/telemetry/StatusLoggingIT.java @@ -14,6 +14,7 @@ class StatusLoggingIT { private static final String PROJECT_TAG = "projectTag"; + private static final String VERSION = "1.2.3"; private static final String FEATURE = "myFeature"; @SuppressWarnings("java:S3416") // Using captured logger name by intention @@ -23,7 +24,7 @@ class StatusLoggingIT { void logsWhenTelemetryIsEnabled() throws Exception { try (LogCapture capture = new LogCapture(CAPTURED_LOGGER); RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); - TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG).build())) { + TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, VERSION).build())) { final LogRecord enabledRecord = capture.await(logRecord -> logRecord.getLevel() == Level.INFO && logRecord.getMessage().contains("Telemetry is enabled"), Duration.ofSeconds(1)); @@ -38,7 +39,7 @@ void logsWhenTelemetryIsEnabled() throws Exception { void logsWhenTelemetryIsDisabledWithMechanism() throws Exception { try (LogCapture capture = new LogCapture(CAPTURED_LOGGER); RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); - TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG) + TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, VERSION) .environment(new MapEnvironment(Map.of(TelemetryConfig.DISABLED_ENV, "disabled"))) .build())) { client.track(FEATURE); @@ -50,7 +51,7 @@ void logsWhenTelemetryIsDisabledWithMechanism() throws Exception { try (LogCapture capture = new LogCapture(CAPTURED_LOGGER); RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); - TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG) + TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, VERSION) .environment(new MapEnvironment(Map.of(TelemetryConfig.CI_ENV, "github-actions"))) .build())) { final LogRecord ciRecord = capture.await(logRecord -> logRecord.getLevel() == Level.INFO @@ -65,7 +66,7 @@ void logsWhenTelemetryIsDisabledWithMechanism() throws Exception { void logsSentMessageCount() throws Exception { try (LogCapture capture = new LogCapture(CAPTURED_LOGGER); RecordingHttpServer server = RecordingHttpServer.createSuccessServer()) { - final TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG) + final TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, VERSION) .build()); try { client.track(FEATURE); @@ -84,7 +85,7 @@ void logsSentMessageCount() throws Exception { void logsWhenTelemetrySendingFails() throws Exception { try (LogCapture capture = new LogCapture(CAPTURED_LOGGER); RecordingHttpServer server = RecordingHttpServer.createFlakyServer(1)) { - final TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG) + final TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, VERSION) .retryTimeout(Duration.ofMillis(500)) .initialRetryDelay(Duration.ofMillis(25)) .maxRetryDelay(Duration.ofMillis(25)) @@ -108,7 +109,7 @@ void logsWhenTelemetrySendingFails() throws Exception { void logsWhenTelemetryStops() throws Exception { try (LogCapture capture = new LogCapture(CAPTURED_LOGGER); RecordingHttpServer server = RecordingHttpServer.createSuccessServer()) { - final TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG).build()); + final TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, VERSION).build()); try { client.close(); final LogRecord logRecord = capture.await(r -> r.getLevel() == Level.FINE diff --git a/src/test/java/com/exasol/telemetry/TelemetryClientTest.java b/src/test/java/com/exasol/telemetry/TelemetryClientTest.java index 199a8cb..fe1df45 100644 --- a/src/test/java/com/exasol/telemetry/TelemetryClientTest.java +++ b/src/test/java/com/exasol/telemetry/TelemetryClientTest.java @@ -13,7 +13,7 @@ class TelemetryClientTest { @Test void doesNotRunSenderWhenTrackingIsDisabled() throws Exception { - final TelemetryConfig config = TelemetryConfig.builder("project").endpoint(URI.create("https://example.com")) + final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3").endpoint(URI.create("https://example.com")) .environment(new MapEnvironment(Map.of(TelemetryConfig.DISABLED_ENV, "true"))) .build(); @@ -28,11 +28,11 @@ void doesNotRunSenderWhenTrackingIsDisabled() throws Exception { } @Test - void ignoresBlankFeatureName() { - final TelemetryConfig config = TelemetryConfig.builder("project").endpoint(URI.create("https://example.com")).build(); + void ignoresNullFeatureName() { + final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3").endpoint(URI.create("https://example.com")).build(); final TelemetryClient client = TelemetryClient.create(config); try { - client.track(" "); + client.track(null); } finally { client.close(); } @@ -40,7 +40,7 @@ void ignoresBlankFeatureName() { @Test void ignoresTrackingAfterClose() { - final TelemetryConfig config = TelemetryConfig.builder("project").endpoint(URI.create("https://example.com")) + final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3").endpoint(URI.create("https://example.com")) .environment(new MapEnvironment(Map.of(TelemetryConfig.DISABLED_ENV, "true"))) .build(); final TelemetryClient client = TelemetryClient.create(config); @@ -52,7 +52,7 @@ void ignoresTrackingAfterClose() { @Test void makesCloseIdempotent() { - final TelemetryConfig config = TelemetryConfig.builder("project").endpoint(URI.create("https://example.com")) + final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3").endpoint(URI.create("https://example.com")) .environment(new MapEnvironment(Map.of(TelemetryConfig.DISABLED_ENV, "true"))) .build(); final TelemetryClient client = TelemetryClient.create(config); diff --git a/src/test/java/com/exasol/telemetry/TelemetryConfigTest.java b/src/test/java/com/exasol/telemetry/TelemetryConfigTest.java index fc92b78..1327d50 100644 --- a/src/test/java/com/exasol/telemetry/TelemetryConfigTest.java +++ b/src/test/java/com/exasol/telemetry/TelemetryConfigTest.java @@ -15,11 +15,12 @@ class TelemetryConfigTest { @Test void usesDefaultsAndConfiguredValues() { - final TelemetryConfig config = TelemetryConfig.builder("project") + final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3") .environment(MapEnvironment.empty()) .build(); assertThat(config.getProjectTag(), is("project")); + assertThat(config.getProductVersion(), is("1.2.3")); assertThat(config.getEndpoint(), is(TelemetryConfig.DEFAULT_ENDPOINT)); assertThat(config.getQueueCapacity(), is(256)); assertThat(config.getRetryTimeout(), is(Duration.ofSeconds(5))); @@ -28,10 +29,11 @@ void usesDefaultsAndConfiguredValues() { @Test void usesDefaultsAndConfiguredValuesWithRealEnvironment() { - final TelemetryConfig config = TelemetryConfig.builder("project") + final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3") .build(); assertThat(config.getProjectTag(), is("project")); + assertThat(config.getProductVersion(), is("1.2.3")); assertThat(config.getEndpoint(), is(TelemetryConfig.DEFAULT_ENDPOINT)); assertThat(config.getQueueCapacity(), is(256)); assertThat(config.getRetryTimeout(), is(Duration.ofSeconds(5))); @@ -73,13 +75,20 @@ void treatsAnyNonEmptyEnvironmentValueAsDisabled() { @Test void rejectsBlankProjectTag() { final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> TelemetryConfig.builder(" ").build()); + () -> TelemetryConfig.builder(" ", "1.2.3").build()); assertThat(exception.getMessage(), containsString("projectTag")); } + @Test + void rejectsBlankProductVersion() { + final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> TelemetryConfig.builder("project", " ").build()); + assertThat(exception.getMessage(), containsString("productVersion")); + } + @Test void usesDefaultEndpointWhenNoEndpointIsConfigured() { - final TelemetryConfig config = TelemetryConfig.builder("project").build(); + final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3").build(); assertThat(config.getEndpoint(), is(TelemetryConfig.DEFAULT_ENDPOINT)); } @@ -107,6 +116,6 @@ void rejectsNonPositiveNumbersAndDurations() { } private Builder defaultBuilder() { - return TelemetryConfig.builder("project").endpoint(URI.create("https://example.com")); + return TelemetryConfig.builder("project", "1.2.3").endpoint(URI.create("https://example.com")); } } diff --git a/src/test/java/com/exasol/telemetry/TrackingApiIT.java b/src/test/java/com/exasol/telemetry/TrackingApiIT.java index c6014be..d3e8fbe 100644 --- a/src/test/java/com/exasol/telemetry/TrackingApiIT.java +++ b/src/test/java/com/exasol/telemetry/TrackingApiIT.java @@ -11,12 +11,13 @@ class TrackingApiIT { private static final String PROJECT_TAG = "shop-ui"; + private static final String PRODUCT_VERSION = "1.2.3"; private static final String FEATURE = "checkout-started"; @Test - void recordsTaggedFeatureUsageEvent() throws Exception { + void recordsFeatureUsageEventWithCategoryProtocolVersionAndProductVersion() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); - TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG) + TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, PRODUCT_VERSION) .retryTimeout(Duration.ofMillis(500)) .build())) { client.track(FEATURE); @@ -24,16 +25,18 @@ void recordsTaggedFeatureUsageEvent() throws Exception { final List requests = server.awaitRequests(1, Duration.ofSeconds(2)); assertThat(requests, hasSize(1)); assertThat(requests.get(0).method(), is("POST")); - assertThat(requests.get(0).body(), containsString("\"version\":\"0.1\"")); + assertThat(requests.get(0).body(), containsString("\"category\":\"shop-ui\"")); + assertThat(requests.get(0).body(), containsString("\"version\":\"0.2.0\"")); + assertThat(requests.get(0).body(), containsString("\"productVersion\":\"1.2.3\"")); assertThat(requests.get(0).body(), containsString("\"timestamp\":")); - assertThat(requests.get(0).body(), containsString("\"features\":{\"shop-ui~checkout-started\":[")); + assertThat(requests.get(0).body(), containsString("\"features\":{\"checkout-started\":[")); } } @Test void emitsPayloadAsValidJson() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); - TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG) + TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, PRODUCT_VERSION) .retryTimeout(Duration.ofMillis(500)) .build())) { client.track(FEATURE); @@ -47,7 +50,7 @@ void emitsPayloadAsValidJson() throws Exception { @Test void keepsCallerThreadOverheadLowForAcceptedTracking() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createDelayedSuccessServer(300); - TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG) + TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, PRODUCT_VERSION) .retryTimeout(Duration.ofMillis(500)) .build())) { final long start = System.nanoTime(); @@ -62,7 +65,7 @@ void keepsCallerThreadOverheadLowForAcceptedTracking() throws Exception { @Test void makesDisabledTrackingNoOpWithoutTelemetryOverhead() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer()) { - final TelemetryConfig config = server.configBuilder(PROJECT_TAG) + final TelemetryConfig config = server.configBuilder(PROJECT_TAG, PRODUCT_VERSION) .environment(new MapEnvironment(Map.of(TelemetryConfig.DISABLED_ENV, "disabled"))) .build(); @@ -77,10 +80,24 @@ void makesDisabledTrackingNoOpWithoutTelemetryOverhead() throws Exception { } @Test - void ignoresInvalidFeatureNames() throws Exception { + void recordsFeatureUsageEventWithoutPrefixingOrValidation() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); - TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG).build())) { - client.track(" "); + TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, PRODUCT_VERSION) + .build())) { + client.track(" feature "); + + final List requests = server.awaitRequests(1, Duration.ofSeconds(2)); + assertThat(requests, hasSize(1)); + assertThat(requests.get(0).body(), containsString("\"features\":{\" feature \":")); + } + } + + @Test + void ignoresNullFeatureNames() throws Exception { + try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); + TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, PRODUCT_VERSION) + .build())) { + client.track(null); Thread.sleep(150); assertThat(server.awaitRequests(1, Duration.ofMillis(150)), empty()); diff --git a/src/test/java/com/exasol/telemetry/TrackingControlsIT.java b/src/test/java/com/exasol/telemetry/TrackingControlsIT.java index b4c8fe1..a76cdd6 100644 --- a/src/test/java/com/exasol/telemetry/TrackingControlsIT.java +++ b/src/test/java/com/exasol/telemetry/TrackingControlsIT.java @@ -10,12 +10,13 @@ class TrackingControlsIT { private static final String PROJECT_TAG = "projectTag"; + private static final String VERSION = "1.2.3"; private static final String FEATURE = "myFeature"; @Test void disablesTrackingViaEnvironmentVariables() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); - TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG) + TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, VERSION) .environment(new MapEnvironment(Map.of(TelemetryConfig.DISABLED_ENV, "disabled"))) .build())) { client.track(FEATURE); @@ -28,7 +29,7 @@ void disablesTrackingViaEnvironmentVariables() throws Exception { @Test void disablesTrackingAutomaticallyWhenCiIsNonEmpty() throws Exception { try (RecordingHttpServer server = RecordingHttpServer.createSuccessServer(); - TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG) + TelemetryClient client = TelemetryClient.create(server.configBuilder(PROJECT_TAG, VERSION) .environment(new MapEnvironment(Map.of(TelemetryConfig.CI_ENV, "github-actions"))) .build())) { client.track(FEATURE); @@ -42,7 +43,7 @@ void disablesTrackingAutomaticallyWhenCiIsNonEmpty() throws Exception { void overridesConfiguredEndpointViaEnvironmentVariable() throws Exception { try (RecordingHttpServer configuredServer = RecordingHttpServer.createSuccessServer(); RecordingHttpServer overrideServer = RecordingHttpServer.createSuccessServer(); - TelemetryClient client = TelemetryClient.create(configuredServer.configBuilder(PROJECT_TAG) + TelemetryClient client = TelemetryClient.create(configuredServer.configBuilder(PROJECT_TAG, VERSION) .environment(new MapEnvironment(Map.of(TelemetryConfig.ENDPOINT_ENV, overrideServer.endpoint().toString()))) .build())) { client.track(FEATURE); From 058e5cd9700dd7cb1a6abdec3d0f5399aa27ab0e Mon Sep 17 00:00:00 2001 From: Christoph Pirkl Date: Wed, 15 Apr 2026 15:47:14 +0200 Subject: [PATCH 4/7] #6: Speq record --- .../config/client-identity/spec.md | 0 .../config/tracking-controls/spec.md | 0 .../delivery/async-delivery/spec.md | 0 .../plan.md | 0 .../tasks.md | 0 .../tracking/tracking-api/spec.md | 0 .../verification-report.md | 0 specs/config/client-identity/spec.md | 16 ++++++++++++++++ specs/config/tracking-controls/spec.md | 6 ++++-- specs/delivery/async-delivery/spec.md | 9 ++++++--- specs/tracking/tracking-api/spec.md | 18 ++++++++++++++---- 11 files changed, 40 insertions(+), 9 deletions(-) rename specs/{_plans/change-tracking-message-metadata => _recorded/2026-04-15-change-tracking-message-metadata}/config/client-identity/spec.md (100%) rename specs/{_plans/change-tracking-message-metadata => _recorded/2026-04-15-change-tracking-message-metadata}/config/tracking-controls/spec.md (100%) rename specs/{_plans/change-tracking-message-metadata => _recorded/2026-04-15-change-tracking-message-metadata}/delivery/async-delivery/spec.md (100%) rename specs/{_plans/change-tracking-message-metadata => _recorded/2026-04-15-change-tracking-message-metadata}/plan.md (100%) rename specs/{_plans/change-tracking-message-metadata => _recorded/2026-04-15-change-tracking-message-metadata}/tasks.md (100%) rename specs/{_plans/change-tracking-message-metadata => _recorded/2026-04-15-change-tracking-message-metadata}/tracking/tracking-api/spec.md (100%) rename specs/{_plans/change-tracking-message-metadata => _recorded/2026-04-15-change-tracking-message-metadata}/verification-report.md (100%) create mode 100644 specs/config/client-identity/spec.md diff --git a/specs/_plans/change-tracking-message-metadata/config/client-identity/spec.md b/specs/_recorded/2026-04-15-change-tracking-message-metadata/config/client-identity/spec.md similarity index 100% rename from specs/_plans/change-tracking-message-metadata/config/client-identity/spec.md rename to specs/_recorded/2026-04-15-change-tracking-message-metadata/config/client-identity/spec.md diff --git a/specs/_plans/change-tracking-message-metadata/config/tracking-controls/spec.md b/specs/_recorded/2026-04-15-change-tracking-message-metadata/config/tracking-controls/spec.md similarity index 100% rename from specs/_plans/change-tracking-message-metadata/config/tracking-controls/spec.md rename to specs/_recorded/2026-04-15-change-tracking-message-metadata/config/tracking-controls/spec.md diff --git a/specs/_plans/change-tracking-message-metadata/delivery/async-delivery/spec.md b/specs/_recorded/2026-04-15-change-tracking-message-metadata/delivery/async-delivery/spec.md similarity index 100% rename from specs/_plans/change-tracking-message-metadata/delivery/async-delivery/spec.md rename to specs/_recorded/2026-04-15-change-tracking-message-metadata/delivery/async-delivery/spec.md diff --git a/specs/_plans/change-tracking-message-metadata/plan.md b/specs/_recorded/2026-04-15-change-tracking-message-metadata/plan.md similarity index 100% rename from specs/_plans/change-tracking-message-metadata/plan.md rename to specs/_recorded/2026-04-15-change-tracking-message-metadata/plan.md diff --git a/specs/_plans/change-tracking-message-metadata/tasks.md b/specs/_recorded/2026-04-15-change-tracking-message-metadata/tasks.md similarity index 100% rename from specs/_plans/change-tracking-message-metadata/tasks.md rename to specs/_recorded/2026-04-15-change-tracking-message-metadata/tasks.md diff --git a/specs/_plans/change-tracking-message-metadata/tracking/tracking-api/spec.md b/specs/_recorded/2026-04-15-change-tracking-message-metadata/tracking/tracking-api/spec.md similarity index 100% rename from specs/_plans/change-tracking-message-metadata/tracking/tracking-api/spec.md rename to specs/_recorded/2026-04-15-change-tracking-message-metadata/tracking/tracking-api/spec.md diff --git a/specs/_plans/change-tracking-message-metadata/verification-report.md b/specs/_recorded/2026-04-15-change-tracking-message-metadata/verification-report.md similarity index 100% rename from specs/_plans/change-tracking-message-metadata/verification-report.md rename to specs/_recorded/2026-04-15-change-tracking-message-metadata/verification-report.md diff --git a/specs/config/client-identity/spec.md b/specs/config/client-identity/spec.md new file mode 100644 index 0000000..6f1be62 --- /dev/null +++ b/specs/config/client-identity/spec.md @@ -0,0 +1,16 @@ +# Feature: client-identity + +Defines the required product identity values that the host application configures once and the library attaches to every emitted telemetry message. + +## 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. + +## Scenarios + +### Scenario: Requires project tag and productVersion when creating telemetry configuration + +* *GIVEN* the host application creates telemetry configuration +* *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 diff --git a/specs/config/tracking-controls/spec.md b/specs/config/tracking-controls/spec.md index 3230d75..3113ec8 100644 --- a/specs/config/tracking-controls/spec.md +++ b/specs/config/tracking-controls/spec.md @@ -24,8 +24,10 @@ Tracking can be deactivated by `EXASOL_TELEMETRY_DISABLE` or automatically by `C ### Scenario: Overrides the configured endpoint via environment variable -* *GIVEN* the host application configures an endpoint in code +* *GIVEN* the host application configures an endpoint, project tag, and `productVersion` in code * *AND* the host environment defines an endpoint override * *WHEN* the library initializes * *THEN* the library SHALL use the environment-provided endpoint for delivery -* *AND* the library SHALL continue to add the configured project short tag to all accepted events +* *AND* the library SHALL continue to emit the configured project tag as the `category` field +* *AND* the library SHALL continue to emit the configured `productVersion` as the `productVersion` field +* *AND* the library SHALL continue to emit protocol `version`=`0.2.0` diff --git a/specs/delivery/async-delivery/spec.md b/specs/delivery/async-delivery/spec.md index f43dad9..22073d3 100644 --- a/specs/delivery/async-delivery/spec.md +++ b/specs/delivery/async-delivery/spec.md @@ -4,16 +4,18 @@ Delivers accepted usage events to an HTTP endpoint without blocking the host app ## 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 `version`, `timestamp`, and `features` fields, and the library uses bounded in-memory buffering with no persistent local storage. +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. ## Scenarios ### Scenario: Sends queued events asynchronously over HTTP -* *GIVEN* the library is configured with an endpoint +* *GIVEN* the library is configured with an endpoint, project tag, and `productVersion` * *AND* an accepted usage event is queued for delivery * *WHEN* the background sender processes the queue * *THEN* the library SHALL submit a protocol message as JSON using HTTP `POST` +* *AND* the library SHALL include `category`, `version`, `productVersion`, `timestamp`, and `features` fields in that JSON payload +* *AND* the library SHALL emit protocol `version`=`0.2.0` * *AND* the library SHALL perform network delivery without blocking the calling thread ### Scenario: Retries failed delivery with exponential backoff until timeout @@ -30,4 +32,5 @@ Accepted telemetry events are serialized to JSON and delivered via HTTP `POST` t * *GIVEN* multiple accepted telemetry events are present when the background sender drains the queue * *WHEN* the background sender emits the next protocol message * *THEN* the library SHALL include the queued events in a single JSON payload -* *AND* the library SHALL group timestamps by fully qualified feature name in the `features` map +* *AND* the library SHALL group timestamps by caller-provided feature name in the `features` map +* *AND* the library SHALL correctly JSON-escape caller-provided feature names when serializing the `features` map diff --git a/specs/tracking/tracking-api/spec.md b/specs/tracking/tracking-api/spec.md index 5c99521..fcc16d5 100644 --- a/specs/tracking/tracking-api/spec.md +++ b/specs/tracking/tracking-api/spec.md @@ -4,17 +4,20 @@ Enables host applications to record allowed feature-usage events with minimal in ## Background -The host application configures the library at startup with a project short tag. Every accepted usage event uses that project short tag to namespace the feature name in the telemetry protocol. +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 -* *GIVEN* the library is configured with a project short tag +* *GIVEN* the library is configured with a project short tag and `productVersion` * *AND* tracking is enabled * *WHEN* the host application records a feature-usage event * *THEN* the library SHALL accept the event for delivery -* *AND* the library SHALL prefix the feature name with the configured project short tag in the emitted protocol payload +* *AND* the library SHALL preserve the caller-provided feature name in the emitted protocol payload without adding the configured project short tag as a prefix +* *AND* the library SHALL emit the configured `productVersion` as the `productVersion` field +* *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 @@ -36,10 +39,17 @@ The host application configures the library at startup with a project short tag. * *GIVEN* tracking is enabled * *AND* the host application records a valid feature-usage event * *WHEN* the library accepts the event for delivery -* *THEN* the library SHALL keep caller-thread work limited to feature validation, feature qualification, timestamp capture, and queue admission +* *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 * *AND* the library SHOULD avoid avoidable heap allocations on the caller thread +### Scenario: Ignores null feature names + +* *GIVEN* tracking is enabled +* *WHEN* the host application records a `null` feature name +* *THEN* the library SHALL ignore that call +* *AND* the library MUST NOT enqueue or emit a telemetry event for the `null` feature name + ### Scenario: Makes disabled tracking a no-op without telemetry overhead * *GIVEN* tracking is disabled From 97ba96b353e06886985acd5a725f8c788ebdabb9 Mon Sep 17 00:00:00 2001 From: Christoph Pirkl Date: Thu, 16 Apr 2026 09:10:53 +0200 Subject: [PATCH 5/7] #6: Update collected data in end user guide --- doc/app-user-guide.md | 32 ++++++++++++++++++++++++-------- specs/mission.md | 6 +++++- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/doc/app-user-guide.md b/doc/app-user-guide.md index f2bb540..1e1f0a3 100644 --- a/doc/app-user-guide.md +++ b/doc/app-user-guide.md @@ -1,16 +1,36 @@ # App User Guide -Applications using `telemetry-java` are expected to inform users when usage tracking is active. +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 +- how to disable telemetry ## What Is Collected? -The library is designed to send feature-usage events only. It does not collect logs, stack traces, high-frequency data, numeric data, or personally identifiable information. +The library sends feature-usage telemetry only. This means it records which application features are used and when they are used. + +It does not collect: -Messages sent to the server contain the protocol version, the message timestamp, and a `features` map from feature name to a list of usage timestamps. +- personally identifiable information +- general-purpose diagnostic logs +- stack traces +- high-frequency event streams +- numeric measurements The library sends telemetry to `https://metrics.exasol.com`. -## Opt-Out +For Exasol's general privacy information, see the [Exasol Privacy Policy](https://www.exasol.com/privacy-policy/). + +## How To See Whether Telemetry Is Enabled + +Applications can use lifecycle log messages from the library to show whether telemetry is enabled or disabled. + +When telemetry is disabled, the library does not enqueue 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. @@ -25,7 +45,3 @@ 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. 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. - -When telemetry is disabled, the library does not enqueue or send usage events. - -The library also emits lifecycle log messages so users can see whether telemetry is enabled or disabled, when data is sent, and when telemetry stops. diff --git a/specs/mission.md b/specs/mission.md index 5ebcef6..85e517a 100644 --- a/specs/mission.md +++ b/specs/mission.md @@ -12,6 +12,8 @@ Primary users are developers of other applications who integrate the library int Application end users are also part of the system context. They must be informed about usage tracking and be able to opt out. +The app user guide is written for end users, not application developers. It should avoid technical implementation details and focus on what data is collected, how users can tell whether telemetry is enabled, and how they can disable it. + ## Typical Workflow Developers integrate the library into their application with minimal effort. @@ -50,7 +52,7 @@ The project uses standard Maven commands. The repository follows the default Maven project structure. -It also includes a `docs/` directory containing: +It also includes a `doc/` directory containing: - an app user guide - a developer guide @@ -76,3 +78,5 @@ Intended data flow: - Only send strings and feature names - No PII - No background threads after close +- Any change to collected telemetry data must be documented in the app user guide +- The app user guide must stay end-user focused and avoid unnecessary technical details From 9e4d0cb9c7398e11e2631b21733187869ecae8ce Mon Sep 17 00:00:00 2001 From: Christoph Pirkl Date: Thu, 16 Apr 2026 09:13:34 +0200 Subject: [PATCH 6/7] #6: document collected data --- doc/app-user-guide.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/doc/app-user-guide.md b/doc/app-user-guide.md index 1e1f0a3..3f498bd 100644 --- a/doc/app-user-guide.md +++ b/doc/app-user-guide.md @@ -10,7 +10,12 @@ It explains: ## What Is Collected? -The library sends feature-usage telemetry only. This means it records which application features are used and when they are used. +The library collects only the data needed for feature-usage telemetry: + +- product name, sent as the telemetry category +- product version +- which application features are used +- when those features are used It does not collect: From 5265d430ab2afcf590bba7e38a540d9d47b079fd Mon Sep 17 00:00:00 2001 From: Christoph Pirkl Date: Thu, 16 Apr 2026 09:16:18 +0200 Subject: [PATCH 7/7] Fix sonar warning --- .../com/exasol/telemetry/TelemetryConfigTest.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/exasol/telemetry/TelemetryConfigTest.java b/src/test/java/com/exasol/telemetry/TelemetryConfigTest.java index 1327d50..f5ead1c 100644 --- a/src/test/java/com/exasol/telemetry/TelemetryConfigTest.java +++ b/src/test/java/com/exasol/telemetry/TelemetryConfigTest.java @@ -1,7 +1,8 @@ package com.exasol.telemetry; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertThrows; import java.net.URI; @@ -74,22 +75,21 @@ void treatsAnyNonEmptyEnvironmentValueAsDisabled() { @Test void rejectsBlankProjectTag() { - final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> TelemetryConfig.builder(" ", "1.2.3").build()); + final Builder builder = TelemetryConfig.builder(" ", "1.2.3"); + final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, builder::build); assertThat(exception.getMessage(), containsString("projectTag")); } @Test void rejectsBlankProductVersion() { - final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> TelemetryConfig.builder("project", " ").build()); + final Builder builder = TelemetryConfig.builder("project", " "); + final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, builder::build); assertThat(exception.getMessage(), containsString("productVersion")); } @Test void usesDefaultEndpointWhenNoEndpointIsConfigured() { final TelemetryConfig config = TelemetryConfig.builder("project", "1.2.3").build(); - assertThat(config.getEndpoint(), is(TelemetryConfig.DEFAULT_ENDPOINT)); }