diff --git a/itests/pom.xml b/itests/pom.xml
index 8f6f55b65..d28e1d2a6 100644
--- a/itests/pom.xml
+++ b/itests/pom.xml
@@ -178,6 +178,11 @@
unomi-rest
test
+
+ org.apache.unomi
+ unomi-tracing-api
+ test
+
org.apache.unomi
unomi-api
diff --git a/itests/src/test/java/org/apache/unomi/itests/EventServiceIT.java b/itests/src/test/java/org/apache/unomi/itests/EventServiceIT.java
index e853b987b..9fd894912 100644
--- a/itests/src/test/java/org/apache/unomi/itests/EventServiceIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/EventServiceIT.java
@@ -16,6 +16,7 @@
*/
package org.apache.unomi.itests;
+import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.unomi.api.Event;
import org.apache.unomi.api.PartialList;
import org.apache.unomi.api.Profile;
@@ -184,4 +185,27 @@ public void test_EventGetNestedProperty() {
Assert.assertEquals(testValue, value);
}
+ @Test
+ public void test_DeleteEventViaRest() throws InterruptedException {
+ String eventId = "test-delete-event-id-" + System.currentTimeMillis();
+ String profileId = "test-delete-event-profile-id-" + System.currentTimeMillis();
+ String eventType = "test-delete-event-type";
+ Profile profile = new Profile(profileId);
+ Event event = new Event(eventId, eventType, null, profile, null, null, null, new Date());
+
+ profileService.save(profile);
+ keepTrying("Profile with id " + profileId + " not found in the required time", () -> profileService.load(profileId),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ eventService.send(event);
+ keepTrying("Event has not been raised", () -> eventService.getEvent(eventId), Objects::nonNull, DEFAULT_TRYING_TIMEOUT,
+ DEFAULT_TRYING_TRIES);
+
+ CloseableHttpResponse response = delete("/cxs/events/" + eventId);
+ Assert.assertEquals("Delete event should return 204 No Content", 204, response.getStatusLine().getStatusCode());
+
+ waitForNullValue("Event still present after deletion via REST", () -> eventService.getEvent(eventId), DEFAULT_TRYING_TIMEOUT,
+ DEFAULT_TRYING_TRIES);
+ }
+
}
diff --git a/itests/src/test/java/org/apache/unomi/itests/EventsCollectorIT.java b/itests/src/test/java/org/apache/unomi/itests/EventsCollectorIT.java
index 2553c7c59..7c7ed28bf 100644
--- a/itests/src/test/java/org/apache/unomi/itests/EventsCollectorIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/EventsCollectorIT.java
@@ -127,4 +127,56 @@ public void testEventsCollectorWithPublicApiKey() throws Exception {
}
}
+ @Test
+ public void testEventsCollectorWithExplain() throws Exception {
+ Event event = new Event();
+ event.setEventType(TEST_EVENT_TYPE);
+ event.setScope(TEST_SCOPE);
+ event.setSessionId(TEST_SESSION_ID);
+ event.setProfileId(TEST_PROFILE_ID);
+
+ EventsCollectorRequest eventsCollectorRequest = new EventsCollectorRequest();
+ eventsCollectorRequest.setSessionId(TEST_SESSION_ID);
+ eventsCollectorRequest.setEvents(Collections.singletonList(event));
+
+ HttpPost request = new HttpPost(getFullUrl(EVENTS_URL + "?explain=true"));
+ request.addHeader("Content-Type", "application/json");
+ String requestBody = getObjectMapper().writeValueAsString(eventsCollectorRequest);
+ request.setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON));
+
+ try (CloseableHttpResponse response = HttpClientThatWaitsForUnomi.doRequest(request, 200, true, true)) {
+ String responseContent = EntityUtils.toString(response.getEntity());
+ EventCollectorResponse eventResponse = getObjectMapper().readValue(responseContent, EventCollectorResponse.class);
+ Assert.assertNotNull("Event collector response should not be null", eventResponse);
+
+ int expectedFlags = EventService.SESSION_UPDATED | EventService.PROFILE_UPDATED;
+ Assert.assertEquals("Response should indicate both session and profile were updated",
+ expectedFlags, eventResponse.getUpdated());
+ Assert.assertNotNull("Tracing information should be present", eventResponse.getRequestTracing());
+ }
+ }
+
+ @Test
+ public void testEventsCollectorWithExplainUnauthorized() throws Exception {
+ Event event = new Event();
+ event.setEventType(TEST_EVENT_TYPE);
+ event.setScope(TEST_SCOPE);
+ event.setSessionId(TEST_SESSION_ID);
+ event.setProfileId(TEST_PROFILE_ID);
+
+ EventsCollectorRequest eventsCollectorRequest = new EventsCollectorRequest();
+ eventsCollectorRequest.setSessionId(TEST_SESSION_ID);
+ eventsCollectorRequest.setEvents(Collections.singletonList(event));
+
+ HttpPost request = new HttpPost(getFullUrl(EVENTS_URL + "?explain=true"));
+ request.addHeader("Content-Type", "application/json");
+ String requestBody = getObjectMapper().writeValueAsString(eventsCollectorRequest);
+ request.setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON));
+
+ try (CloseableHttpResponse response = HttpClientThatWaitsForUnomi.doRequest(request, 403)) {
+ Assert.assertEquals("Request with explain parameter but without admin privileges should return 403",
+ 403, response.getStatusLine().getStatusCode());
+ }
+ }
+
}
diff --git a/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java b/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java
index 353bc49a2..dff72a1dc 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java
@@ -16,6 +16,7 @@
*/
package org.apache.unomi.itests;
+import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.unomi.api.*;
import org.apache.unomi.api.conditions.Condition;
import org.apache.unomi.api.query.Query;
@@ -535,4 +536,25 @@ public void testPurgeSessions() throws Exception {
}
}
+
+ @Test
+ public void testDeleteSessionViaRest() throws Exception {
+ String profileId = "test-delete-session-profile-id-" + System.currentTimeMillis();
+ String sessionId = "test-delete-session-id-" + System.currentTimeMillis();
+ Profile profile = new Profile(profileId);
+ profileService.save(profile);
+ keepTrying("Profile with id " + profileId + " not found in the required time", () -> profileService.load(profileId),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ Session session = new Session(sessionId, profile, new Date(), "test-delete-session-scope");
+ profileService.saveSession(session);
+ keepTrying("Session with id " + sessionId + " not found in the required time", () -> profileService.loadSession(sessionId),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ CloseableHttpResponse response = delete("/cxs/profiles/sessions/" + sessionId);
+ assertEquals("Delete session should return 204 No Content", 204, response.getStatusLine().getStatusCode());
+
+ waitForNullValue("Session still present after deletion via REST", () -> profileService.loadSession(sessionId),
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ }
}
diff --git a/itests/src/test/java/org/apache/unomi/itests/shell/OtherCommandsIT.java b/itests/src/test/java/org/apache/unomi/itests/shell/OtherCommandsIT.java
index 040f02085..eba517fc9 100644
--- a/itests/src/test/java/org/apache/unomi/itests/shell/OtherCommandsIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/shell/OtherCommandsIT.java
@@ -16,6 +16,7 @@
*/
package org.apache.unomi.itests.shell;
+import org.apache.unomi.api.services.TypeResolutionService;
import org.junit.Assert;
import org.junit.Test;
@@ -30,10 +31,38 @@ public class OtherCommandsIT extends ShellCommandsBaseIT {
public void testRuleResetStats() throws Exception {
String output = executeCommandAndGetOutput("unomi:rule-reset-stats");
// Should confirm statistics were reset
- Assert.assertTrue("Should confirm rule statistics reset",
+ Assert.assertTrue("Should confirm rule statistics reset",
output.contains("Rule statistics successfully reset"));
}
+ @Test
+ public void testListInvalidObjects() throws Exception {
+ // Seed a genuinely invalid object directly in the tracking service so the command has
+ // something to report - otherwise this test only ever exercises the "no invalid objects
+ // found" branch and never validates the summary/table rendering. Going through
+ // rulesService.setRule() instead is unreliable here: RulesServiceImpl.ensureRuleResolved()
+ // skips resolution entirely for a rule that's never been seen before (it only resolves
+ // rules that were already tracked invalid or already had missingPlugins set), so a
+ // brand-new invalid rule causes setRule() to throw IllegalArgumentException rather than
+ // being marked invalid.
+ String objectType = "rules";
+ String objectId = "test-invalid-rule-" + System.currentTimeMillis();
+ TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService();
+
+ try {
+ typeResolutionService.markInvalid(objectType, objectId, "Unresolved condition type: nonExistentConditionType");
+
+ String output = executeCommandAndGetOutput("unomi:list-invalid-objects");
+ Assert.assertTrue("Should show invalid objects summary", output.contains("Invalid Objects Summary"));
+ Assert.assertTrue("Should report at least one invalid object", output.contains("Total invalid objects:"));
+ validateNumericValuesInOutput(output, new String[]{"Total invalid objects:"}, false);
+ validateTableHeaders(output, new String[]{"Object Type", "Object ID"});
+ Assert.assertTrue("Should list the invalid object's id", output.contains(objectId));
+ } finally {
+ typeResolutionService.markValid(objectType, objectId);
+ }
+ }
+
@Test
public void testDeployDefinition() throws Exception {
validateCommandExists("unomi:deploy-definition", "deploy", "definition");
diff --git a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
index 411273647..d900c6975 100644
--- a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
+++ b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
@@ -1381,7 +1381,10 @@ protected Boolean execute(Object... args) throws Exception {
itemType = customItemType;
}
String documentId = getDocumentIDForItemType(itemId, itemType);
- String index = getIndexNameForQuery(itemType);
+ // Single-document delete needs a concrete index/alias, not the wildcard
+ // pattern getIndexNameForQuery() returns for rollover types (e.g. "context-session-*"),
+ // which ES's Delete API rejects with index_not_found_exception.
+ String index = getIndex(itemType);
esClient.delete(DeleteRequest.of(builder -> builder.index(index).id(documentId)));
if (MetadataItem.class.isAssignableFrom(clazz)) {
diff --git a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
index c8c3969b9..0888dbc36 100644
--- a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
+++ b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
@@ -20,6 +20,7 @@
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.json.Json;
+import jakarta.json.JsonArrayBuilder;
import jakarta.json.JsonObject;
import jakarta.json.JsonObjectBuilder;
import jakarta.json.stream.JsonParser;
@@ -1398,6 +1399,23 @@ protected Boolean execute(Object... args) throws IOException {
rolloverActionBuilder.add("min_index_age", rolloverMaxAge);
}
+ // Index patterns for ism_template auto-attachment, using the actual rollover index glob
+ // (e.g. "context-event-*") rather than raw item type names (e.g. "event") - the latter
+ // never match real index names, which is why an earlier attempt at ism_template here was
+ // abandoned in favor of the explicit attachRolloverPolicy() call alone. That explicit call
+ // only fires when Unomi itself creates the first rollover index (-000001); subsequent
+ // indices created by ISM's own rollover action never go through Unomi's code path, so they
+ // only get the inert policy_id setting from the index template and are never actually
+ // managed. ism_template auto-attaches to any index matching the pattern - including those
+ // rollover-created indices - so both mechanisms are kept: explicit attach for immediate
+ // management of the first index, ism_template as the durable fallback for every index after.
+ JsonArrayBuilder indexPatternsBuilder = Json.createArrayBuilder();
+ if (rolloverIndices != null) {
+ for (String rolloverItemType : rolloverIndices) {
+ indexPatternsBuilder.add(getRolloverIndexForQuery(rolloverItemType));
+ }
+ }
+
// Build the policy from the inside out
JsonObjectBuilder policyBuilder = Json.createObjectBuilder()
.add("states", Json.createArrayBuilder()
@@ -1408,13 +1426,10 @@ protected Boolean execute(Object... args) throws IOException {
.add("rollover", rolloverActionBuilder)))
.add("transitions", Json.createArrayBuilder())))
.add("default_state", "ingest")
- .add("description", "Rollover lifecycle policy");
-
- // Policy attachment is handled deterministically via the plugins.index_state_management.policy_id
- // custom setting on each per-item rollover index template (internalCreateRolloverTemplate),
- // not via ism_template pattern-matching: rolloverIndices holds item type names (e.g. "event"),
- // which never match real index names (e.g. "context-event-000001"), so an ism_template block
- // here would never auto-attach the policy.
+ .add("description", "Rollover lifecycle policy")
+ .add("ism_template", Json.createObjectBuilder()
+ .add("index_patterns", indexPatternsBuilder)
+ .add("priority", 100));
// Wrap the policy and create
JsonObject policyWrapper = Json.createObjectBuilder()
diff --git a/rest/postman-readme.md b/rest/postman-readme.md
index 35391d932..c68fc7743 100644
--- a/rest/postman-readme.md
+++ b/rest/postman-readme.md
@@ -157,6 +157,7 @@ The collection is organized in a logical demo flow order with 12 main folders:
### 11. Profile Management
- Get, create, update, delete profiles
+- Delete sessions
- Search profiles
- Get profile segments and sessions
- **Authentication**: `tenantId:privateApiKey` (Basic Auth)
@@ -168,7 +169,7 @@ The collection is organized in a logical demo flow order with 12 main folders:
### 12. Additional Useful Requests
- Event type management
-- Event search
+- Event search and delete
- Condition and action definitions
- Property types
- **Authentication**: `tenantId:privateApiKey` (Basic Auth)
diff --git a/rest/src/main/java/org/apache/unomi/rest/endpoints/EventServiceEndpoint.java b/rest/src/main/java/org/apache/unomi/rest/endpoints/EventServiceEndpoint.java
index 46c0c2c39..0dc3df331 100644
--- a/rest/src/main/java/org/apache/unomi/rest/endpoints/EventServiceEndpoint.java
+++ b/rest/src/main/java/org/apache/unomi/rest/endpoints/EventServiceEndpoint.java
@@ -72,6 +72,17 @@ public Event getEvents(@PathParam("id") final String id) {
return eventService.getEvent(id);
}
+ /**
+ * Deletes an event by id.
+ *
+ * @param id the identifier for the event to delete
+ */
+ @DELETE
+ @Path("/{id}")
+ public void deleteEvent(@PathParam("id") final String id) {
+ eventService.deleteEvent(id);
+ }
+
/**
* Retrieves the list of event types identifiers that the server has processed.
* @return a Set of strings that contain event type identifiers.
diff --git a/rest/src/main/java/org/apache/unomi/rest/endpoints/ProfileServiceEndPoint.java b/rest/src/main/java/org/apache/unomi/rest/endpoints/ProfileServiceEndPoint.java
index 1ac88b474..26fe38b6c 100644
--- a/rest/src/main/java/org/apache/unomi/rest/endpoints/ProfileServiceEndPoint.java
+++ b/rest/src/main/java/org/apache/unomi/rest/endpoints/ProfileServiceEndPoint.java
@@ -405,6 +405,17 @@ public Session saveSession(Session session) {
return profileService.saveSession(session);
}
+ /**
+ * Delete the session by specifying its unique identifier.
+ *
+ * @param sessionId the session identifier for the session to delete
+ */
+ @DELETE
+ @Path("/sessions/{sessionId}")
+ public void deleteSession(@PathParam("sessionId") String sessionId) {
+ profileService.deleteSession(sessionId);
+ }
+
/**
* Retrieves {@link Event}s for the {@link Session} identified by the provided session identifier, matching any of the provided event types,
* ordered according to the specified {@code sortBy} String and paged: only {@code size} of them are retrieved, starting with the {@code offset}-th one.
diff --git a/rest/unomi-postman-collection.json b/rest/unomi-postman-collection.json
index 979e6bdcd..3cfe2a25e 100644
--- a/rest/unomi-postman-collection.json
+++ b/rest/unomi-postman-collection.json
@@ -2191,6 +2191,42 @@
},
"response": []
},
+ {
+ "name": "Delete Session",
+ "request": {
+ "method": "DELETE",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/cxs/profiles/sessions/{{sessionId}}",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "cxs",
+ "profiles",
+ "sessions",
+ "{{sessionId}}"
+ ]
+ },
+ "description": "Delete a session by its identifier",
+ "auth": {
+ "type": "basic",
+ "basic": [
+ {
+ "key": "username",
+ "value": "{{tenantId}}",
+ "type": "string"
+ },
+ {
+ "key": "password",
+ "value": "{{privateApiKey}}",
+ "type": "string"
+ }
+ ]
+ }
+ },
+ "response": []
+ },
{
"name": "Delete Profile",
"request": {
@@ -2372,6 +2408,41 @@
},
"response": []
},
+ {
+ "name": "Delete Event",
+ "request": {
+ "method": "DELETE",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/cxs/events/{{eventId}}",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "cxs",
+ "events",
+ "{{eventId}}"
+ ]
+ },
+ "description": "Delete an event by its identifier",
+ "auth": {
+ "type": "basic",
+ "basic": [
+ {
+ "key": "username",
+ "value": "{{tenantId}}",
+ "type": "string"
+ },
+ {
+ "key": "password",
+ "value": "{{privateApiKey}}",
+ "type": "string"
+ }
+ ]
+ }
+ },
+ "response": []
+ },
{
"name": "Get Session Events",
"request": {
@@ -2577,6 +2648,11 @@
"value": "profile-123",
"type": "string"
},
+ {
+ "key": "eventId",
+ "value": "event-123",
+ "type": "string"
+ },
{
"key": "tenantId",
"value": "mytenant",
@@ -2635,4 +2711,4 @@
"description": "Health check password"
}
]
-}
\ No newline at end of file
+}
diff --git a/tracing/tracing-api/pom.xml b/tracing/tracing-api/pom.xml
index 379a3b6c7..f52ea6040 100644
--- a/tracing/tracing-api/pom.xml
+++ b/tracing/tracing-api/pom.xml
@@ -47,6 +47,11 @@
org.osgi.service.component.annotations
provided
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ provided
+
diff --git a/tracing/tracing-api/src/main/java/org/apache/unomi/tracing/api/TraceNode.java b/tracing/tracing-api/src/main/java/org/apache/unomi/tracing/api/TraceNode.java
index 3224ca7dd..42eaf755a 100644
--- a/tracing/tracing-api/src/main/java/org/apache/unomi/tracing/api/TraceNode.java
+++ b/tracing/tracing-api/src/main/java/org/apache/unomi/tracing/api/TraceNode.java
@@ -16,6 +16,8 @@
*/
package org.apache.unomi.tracing.api;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
@@ -25,6 +27,7 @@
* Represents a node in the request tracing tree structure.
* Each node contains information about an operation, its timing, and any child operations.
*/
+@JsonIgnoreProperties(ignoreUnknown = true)
public class TraceNode implements Serializable {
private String operationType;
private String description;