From 7c72c5d7979ce4aea11f1efafafd1d2d6b51cd9e Mon Sep 17 00:00:00 2001 From: freddyDOTCMS Date: Wed, 11 Mar 2026 10:47:31 -0600 Subject: [PATCH 01/14] Create proxy to dot-ca-event-manager --- .../event/EventAnalyticsProxyHelper.java | 197 ++++++++++++++++++ .../event/EventAnalyticsProxyResource.java | 190 +++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java new file mode 100644 index 000000000000..3baec1bcd6bc --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java @@ -0,0 +1,197 @@ +package com.dotcms.rest.api.v1.analytics.event; + +import com.dotcms.http.CircuitBreakerUrl; +import com.dotcms.rest.ErrorEntity; +import com.dotcms.rest.ResponseEntityView; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; + +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +/** + * Utility class that handles the low-level mechanics of the {@link EventAnalyticsProxyResource}: + * building upstream URLs, constructing the Basic-auth header, executing the HTTP call via + * {@link CircuitBreakerUrl}, and mapping the upstream response into the standard dotCMS response + * envelope. + * + *

Config properties consumed: + *

+ * + * @author dotCMS + * @since 2026 + */ +public class EventAnalyticsProxyHelper { + + static final String DOT_CA_EVENT_MANAGER_BASE_URL = "DOT_CA_EVENT_MANAGER_BASE_URL"; + static final String DOT_CA_EVENT_MANAGER_CUSTOMER_ID = "DOT_CA_EVENT_MANAGER_CUSTOMER_ID"; + static final String DOT_CA_EVENT_MANAGER_PASSWORD = "DOT_CA_EVENT_MANAGER_PASSWORD"; + + private EventAnalyticsProxyHelper() { + // utility class — no instances + } + + /** + * Proxies the request to the dot-ca-event-manager service. When {@code body} is non-null the + * upstream call is made as POST (with the body); otherwise a GET is issued. + * + *

The upstream path is built by prepending {@code /v1/} to {@code relativePath} and + * stripping any trailing slash from the configured base URL, e.g.: + *

+     *   relativePath = "event/total-events"
+     *   → upstream URL = {DOT_CA_EVENT_MANAGER_BASE_URL}/v1/event/total-events[?queryString]
+     * 
+ * + * @param relativePath path relative to {@code /v1/} on the upstream service + * @param uriInfo original URI info used to forward query parameters + * @param body optional JSON body; triggers a POST when non-null + * @return dotCMS-wrapped {@link Response} + */ + public static Response proxy(final String relativePath, + final UriInfo uriInfo, + final String body) { + final String baseUrl = Config.getStringProperty(DOT_CA_EVENT_MANAGER_BASE_URL, ""); + if (!UtilMethods.isSet(baseUrl)) { + Logger.error(EventAnalyticsProxyHelper.class, + "Configuration property '" + DOT_CA_EVENT_MANAGER_BASE_URL + "' is not set"); + return Response.serverError() + .entity(new ResponseEntityView<>( + List.of(new ErrorEntity("CONFIGURATION_ERROR", + "Analytics event manager URL is not configured")))) + .build(); + } + + final String upstreamUrl = buildUpstreamUrl(baseUrl, relativePath, uriInfo); + final String authHeader = buildBasicAuthHeader(); + final boolean isPost = UtilMethods.isSet(body); + + Logger.debug(EventAnalyticsProxyHelper.class, + () -> "Proxying analytics " + (isPost ? "POST" : "GET") + " request to: " + upstreamUrl); + + try { + final CircuitBreakerUrl.Response cbResponse = CircuitBreakerUrl.builder() + .setUrl(upstreamUrl) + .setMethod(isPost ? CircuitBreakerUrl.Method.POST : CircuitBreakerUrl.Method.GET) + .setAuthHeaders(authHeader) + .setRawData(isPost ? body : null) + .setThrowWhenError(false) + .build() + .doResponse(); + + return wrapUpstreamResponse(cbResponse); + } catch (final Exception e) { + Logger.error(EventAnalyticsProxyHelper.class, + "Error proxying analytics request to '" + upstreamUrl + "': " + e.getMessage(), e); + return Response.serverError() + .entity(new ResponseEntityView<>( + List.of(new ErrorEntity("PROXY_ERROR", + "Failed to forward request to analytics service")))) + .build(); + } + } + + /** + * Builds the full upstream URL from the base URL, the relative path, and any query parameters + * extracted from the original request. + * + * @param baseUrl configured base URL (trailing slash is stripped if present) + * @param relativePath path relative to {@code /v1/} (e.g. {@code event/total-events}) + * @param uriInfo original URI info carrying the query parameters to forward + * @return fully-formed upstream URL string + */ + static String buildUpstreamUrl(final String baseUrl, + final String relativePath, + final UriInfo uriInfo) { + final String cleanBase = baseUrl.endsWith("/") + ? baseUrl.substring(0, baseUrl.length() - 1) + : baseUrl; + final String upstreamPath = "/v1/" + (relativePath != null ? relativePath : ""); + + final StringBuilder queryString = new StringBuilder(); + uriInfo.getQueryParameters().forEach((key, values) -> + values.forEach(value -> { + if (queryString.length() > 0) { + queryString.append("&"); + } + queryString.append(key).append("=").append(value); + }) + ); + + return cleanBase + upstreamPath + (queryString.length() > 0 ? "?" + queryString : ""); + } + + /** + * Constructs the {@code Authorization: Basic } header value using the customer ID and + * password read from Config properties. + * + * @return full Authorization header value, e.g. {@code Basic dXNlcjpwYXNz} + */ + static String buildBasicAuthHeader() { + final String customerId = Config.getStringProperty(DOT_CA_EVENT_MANAGER_CUSTOMER_ID, ""); + final String password = Config.getStringProperty(DOT_CA_EVENT_MANAGER_PASSWORD, ""); + final byte[] credentials = (customerId + ":" + password).getBytes(StandardCharsets.UTF_8); + return "Basic " + Base64.getEncoder().encodeToString(credentials); + } + + /** + * Maps a raw upstream {@link CircuitBreakerUrl.Response} into the standard dotCMS response + * envelope: + *
    + *
  • Success: upstream {@code data} → {@code entity}
  • + *
  • Error: upstream {@code error.code} / {@code error.message} → {@code errors[]}
  • + *
+ * + * @param cbResponse raw response from the upstream service + * @return dotCMS-wrapped {@link Response} + */ + @SuppressWarnings("unchecked") + static Response wrapUpstreamResponse(final CircuitBreakerUrl.Response cbResponse) { + if (cbResponse == null || !UtilMethods.isSet(cbResponse.getResponse())) { + Logger.warn(EventAnalyticsProxyHelper.class, + "Received null or empty response from analytics service"); + return Response.serverError() + .entity(new ResponseEntityView<>( + List.of(new ErrorEntity("PROXY_ERROR", + "No response from analytics service")))) + .build(); + } + + try { + final Map upstreamJson = DotObjectMapperProvider.getInstance() + .getDefaultObjectMapper() + .readValue(cbResponse.getResponse(), Map.class); + + if (upstreamJson.containsKey("error")) { + final Map error = (Map) upstreamJson.get("error"); + final String errorCode = String.valueOf(error.getOrDefault("code", "UPSTREAM_ERROR")); + final String errorMessage = String.valueOf(error.getOrDefault("message", "Unknown upstream error")); + return Response.status(cbResponse.getStatusCode()) + .entity(new ResponseEntityView<>( + List.of(new ErrorEntity(errorCode, errorMessage)))) + .build(); + } + + return Response.ok(new ResponseEntityView<>(upstreamJson.get("data"))).build(); + + } catch (final Exception e) { + Logger.error(EventAnalyticsProxyHelper.class, + "Failed to parse upstream analytics response: " + e.getMessage(), e); + return Response.serverError() + .entity(new ResponseEntityView<>( + List.of(new ErrorEntity("PARSE_ERROR", + "Failed to parse analytics service response")))) + .build(); + } + } + +} \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java new file mode 100644 index 000000000000..3db310f5682c --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java @@ -0,0 +1,190 @@ +package com.dotcms.rest.api.v1.analytics.event; + +import com.dotcms.jitsu.validators.AnalyticsValidator.AnalyticsValidationException; +import com.dotcms.jitsu.validators.SiteAuthValidator; +import com.dotcms.rest.ErrorEntity; +import com.dotcms.rest.ResponseEntityView; +import com.dotcms.rest.WebResource; +import com.dotcms.rest.annotation.NoCache; +import com.dotcms.rest.api.v1.authentication.ResponseUtil; +import com.dotmarketing.util.Logger; +import com.google.common.annotations.VisibleForTesting; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.container.AsyncResponse; +import javax.ws.rs.container.Suspended; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import java.util.List; + +/** + * REST proxy resource that intercepts requests to {@code /v1/analytics/**} and forwards them to + * the {@code dot-ca-event-manager} service at {@code /v1/**} (stripping the {@code /analytics} + * segment). Requests under {@code /v1/analytics/event/**} are validated using + * {@link SiteAuthValidator} before being forwarded. All other GET requests require an authenticated + * backend user. + * + *

All proxying mechanics (URL building, auth header, HTTP call, response mapping) are handled + * by {@link EventAnalyticsProxyHelper}. + * + * @author dotCMS + * @since 2026 + */ +@Path("/v1/analytics") +@Tag(name = "Content Analytics", + description = "Proxy endpoints that forward analytics requests to the dot-ca-event-manager service.") +public class EventAnalyticsProxyResource { + + private final WebResource webResource; + + @Inject + public EventAnalyticsProxyResource() { + this(new WebResource()); + } + + @VisibleForTesting + public EventAnalyticsProxyResource(final WebResource webResource) { + this.webResource = webResource; + } + + /** + * Proxy endpoint for POST requests under {@code /v1/analytics/event/**}. Validates the + * {@code siteAuth} query parameter via {@link SiteAuthValidator} before asynchronously + * forwarding the request (with body) to the upstream analytics service. + * + *

Example routing: + *

+     *   POST /v1/analytics/event/total-events?siteAuth=xxx  {body}
+     *     → POST {DOT_CA_EVENT_MANAGER_BASE_URL}/v1/event/total-events?siteAuth=xxx  {body}
+     * 
+ * + * @param request the HTTP servlet request + * @param response the HTTP servlet response + * @param asyncResponse async response handle + * @param uriInfo URI info used to forward query parameters + * @param subPath path segment(s) after {@code /event/} + * @param siteAuth site authentication key to be validated + * @param body JSON request body to forward upstream + */ + @Operation( + operationId = "proxyAnalyticsEventRequest", + summary = "Proxy analytics event POST request with site auth validation", + description = "Validates the siteAuth query parameter and asynchronously forwards " + + "POST requests to /v1/analytics/event/** to the dot-ca-event-manager service " + + "at /v1/event/**. All query parameters and the request body are preserved.", + tags = {"Content Analytics"} + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successful upstream response", + content = @Content(mediaType = "application/json", + schema = @Schema(type = "object", + description = "dotCMS response envelope containing the upstream analytics data"))), + @ApiResponse(responseCode = "400", description = "Invalid siteAuth or upstream returned an error", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "500", description = "Internal server error or upstream unreachable", + content = @Content(mediaType = "application/json")) + }) + @POST + @Path("/event/{subPath:.*}") + @NoCache + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public void proxyEventRequest( + @Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @Suspended final AsyncResponse asyncResponse, + @Context final UriInfo uriInfo, + @Parameter(description = "Sub-path after /event/") + @PathParam("subPath") final String subPath, + @Parameter(description = "Site authentication key", required = true) + @QueryParam("siteAuth") final String siteAuth, + final String body) { + + try { + new SiteAuthValidator().validate(siteAuth); + } catch (final AnalyticsValidationException e) { + Logger.warn(this, "SiteAuth validation failed for analytics proxy: " + e.getMessage()); + asyncResponse.resume(Response.status(Response.Status.BAD_REQUEST) + .entity(new ResponseEntityView<>( + List.of(new ErrorEntity(e.getCode().name(), e.getMessage())))) + .build()); + return; + } + + ResponseUtil.handleAsyncResponse( + () -> EventAnalyticsProxyHelper.proxy("event/" + subPath, uriInfo, body), + asyncResponse); + } + + /** + * Catch-all proxy endpoint for GET requests to any path under {@code /v1/analytics/**} not + * handled by a more specific endpoint. Requires an authenticated backend user. + * + *

Example routing: + *

+     *   GET /v1/analytics/event/top-content?limit=50
+     *     → GET {DOT_CA_EVENT_MANAGER_BASE_URL}/v1/event/top-content?limit=50
+     * 
+ * + * @param request the HTTP servlet request + * @param response the HTTP servlet response + * @param uriInfo URI info used to forward query parameters + * @param path path segment(s) after {@code /v1/analytics/} + * @return proxied response wrapped in the dotCMS response envelope + */ + @Operation( + operationId = "proxyAnalyticsGetRequest", + summary = "Proxy any analytics GET request", + description = "Forwards any authenticated GET request to /v1/analytics/** to the " + + "dot-ca-event-manager service at /v1/**, preserving all query parameters.", + tags = {"Content Analytics"} + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successful upstream response", + content = @Content(mediaType = "application/json", + schema = @Schema(type = "object", + description = "dotCMS response envelope containing the upstream analytics data"))), + @ApiResponse(responseCode = "401", description = "Unauthorized – backend user required", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "500", description = "Internal server error or upstream unreachable", + content = @Content(mediaType = "application/json")) + }) + @GET + @Path("/{path:.*}") + @NoCache + @Produces(MediaType.APPLICATION_JSON) + public Response proxyGetRequest( + @Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @Context final UriInfo uriInfo, + @Parameter(description = "Path to forward to the upstream analytics service") + @PathParam("path") final String path) { + + new WebResource.InitBuilder(this.webResource) + .requestAndResponse(request, response) + .requiredBackendUser(true) + .rejectWhenNoUser(true) + .init(); + + return EventAnalyticsProxyHelper.proxy(path, uriInfo, null); + } + +} \ No newline at end of file From 7686a99cb23f47b73af19979262f4a038078e4a6 Mon Sep 17 00:00:00 2001 From: freddyDOTCMS Date: Wed, 11 Mar 2026 10:50:33 -0600 Subject: [PATCH 02/14] fix: update analytics proxy helper --- .../event/EventAnalyticsProxyHelper.java | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java index 3baec1bcd6bc..1b06c9dcd1b4 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java @@ -154,7 +154,6 @@ static String buildBasicAuthHeader() { * @param cbResponse raw response from the upstream service * @return dotCMS-wrapped {@link Response} */ - @SuppressWarnings("unchecked") static Response wrapUpstreamResponse(final CircuitBreakerUrl.Response cbResponse) { if (cbResponse == null || !UtilMethods.isSet(cbResponse.getResponse())) { Logger.warn(EventAnalyticsProxyHelper.class, @@ -166,30 +165,22 @@ static Response wrapUpstreamResponse(final CircuitBreakerUrl.Response cb .build(); } + final int statusCode = cbResponse.getStatusCode(); + try { - final Map upstreamJson = DotObjectMapperProvider.getInstance() + final Object parsedBody = DotObjectMapperProvider.getInstance() .getDefaultObjectMapper() - .readValue(cbResponse.getResponse(), Map.class); - - if (upstreamJson.containsKey("error")) { - final Map error = (Map) upstreamJson.get("error"); - final String errorCode = String.valueOf(error.getOrDefault("code", "UPSTREAM_ERROR")); - final String errorMessage = String.valueOf(error.getOrDefault("message", "Unknown upstream error")); - return Response.status(cbResponse.getStatusCode()) - .entity(new ResponseEntityView<>( - List.of(new ErrorEntity(errorCode, errorMessage)))) - .build(); - } + .readValue(cbResponse.getResponse(), Object.class); - return Response.ok(new ResponseEntityView<>(upstreamJson.get("data"))).build(); + return Response.status(statusCode) + .entity(new ResponseEntityView<>(parsedBody)) + .build(); } catch (final Exception e) { - Logger.error(EventAnalyticsProxyHelper.class, - "Failed to parse upstream analytics response: " + e.getMessage(), e); - return Response.serverError() - .entity(new ResponseEntityView<>( - List.of(new ErrorEntity("PARSE_ERROR", - "Failed to parse analytics service response")))) + Logger.warn(EventAnalyticsProxyHelper.class, + "Upstream response is not valid JSON (status=" + statusCode + "), forwarding as-is"); + return Response.status(statusCode) + .entity(new ResponseEntityView<>(cbResponse.getResponse())) .build(); } } From 67d6863777acf9d9b03fbee53164ab6901a35d17 Mon Sep 17 00:00:00 2001 From: freddyDOTCMS Date: Thu, 12 Mar 2026 15:20:00 -0600 Subject: [PATCH 03/14] dotcms docker compose changes --- .../single-node/docker-compose.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docker/docker-compose-examples/single-node/docker-compose.yml b/docker/docker-compose-examples/single-node/docker-compose.yml index f6451d81b221..bb91caf9094d 100644 --- a/docker/docker-compose-examples/single-node/docker-compose.yml +++ b/docker/docker-compose-examples/single-node/docker-compose.yml @@ -45,9 +45,9 @@ services: memory: 2G dotcms: - image: dotcms/dotcms:latest + image: dotcms/dotcms-test:1.0.0-SNAPSHOT environment: - CMS_JAVA_OPTS: '-Xmx1g ' + CMS_JAVA_OPTS: '-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:8000' LANG: 'C.UTF-8' TZ: 'UTC' DB_BASE_URL: "jdbc:postgresql://db/dotcms" @@ -62,6 +62,11 @@ services: #CMS_SSL_CERTIFICATE_FILE: '/certs/localhost.pem' # Can create cert with mkcert tool #CMS_SSL_CERTIFICATE_KEY_FILE: '/certs/localhost-key.pem' #CUSTOM_STARTER_URL: 'https://repo.dotcms.com/artifactory/libs-release-local/com/dotcms/starter/20260211/starter-20260211.zip' + + DOT_CA_EVENT_MANAGER_BASE_URL: 'http://host.docker.internal:8080' + DOT_CA_EVENT_MANAGER_CUSTOMER_ID: 'cust-001' + DOT_CA_EVENT_MANAGER_PASSWORD: 'abc' + DOT_ALLOW_ACCESS_TO_PRIVATE_SUBNETS: 'true' depends_on: - db - opensearch @@ -73,6 +78,7 @@ services: - db_net - opensearch-net ports: + - "8000:8000" - "8082:8082" - "8443:8443" - "4000:4000" # Glowroot web ui if enabled From 1ec258174d5cb72961c21fae549a0b0894863d66 Mon Sep 17 00:00:00 2001 From: freddyDOTCMS Date: Thu, 12 Mar 2026 17:07:18 -0600 Subject: [PATCH 04/14] FIxing error in the Analytics Proxy --- .../jitsu/validators/ValidationErrorCode.java | 4 +- .../event/EventAnalyticsProxyResource.java | 45 +++++++++++++++---- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/jitsu/validators/ValidationErrorCode.java b/dotCMS/src/main/java/com/dotcms/jitsu/validators/ValidationErrorCode.java index e2cecce1154e..65e543d3c58c 100644 --- a/dotCMS/src/main/java/com/dotcms/jitsu/validators/ValidationErrorCode.java +++ b/dotCMS/src/main/java/com/dotcms/jitsu/validators/ValidationErrorCode.java @@ -61,6 +61,8 @@ public enum ValidationErrorCode { * Indicates that a field expected to be a number is either empty or not a number. * This error occurs when validating fields that should contain number values. */ - INVALID_NUMBER_TYPE; + INVALID_NUMBER_TYPE, + + INVALID_JSON; } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java index 3db310f5682c..2d3c623dbc2c 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java @@ -2,11 +2,13 @@ import com.dotcms.jitsu.validators.AnalyticsValidator.AnalyticsValidationException; import com.dotcms.jitsu.validators.SiteAuthValidator; +import com.dotcms.jitsu.validators.ValidationErrorCode; import com.dotcms.rest.ErrorEntity; import com.dotcms.rest.ResponseEntityView; import com.dotcms.rest.WebResource; import com.dotcms.rest.annotation.NoCache; import com.dotcms.rest.api.v1.authentication.ResponseUtil; +import com.dotcms.util.JsonUtil; import com.dotmarketing.util.Logger; import com.google.common.annotations.VisibleForTesting; import io.swagger.v3.oas.annotations.Operation; @@ -33,7 +35,9 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; +import java.io.IOException; import java.util.List; +import java.util.Map; /** * REST proxy resource that intercepts requests to {@code /v1/analytics/**} and forwards them to @@ -80,8 +84,6 @@ public EventAnalyticsProxyResource(final WebResource webResource) { * @param response the HTTP servlet response * @param asyncResponse async response handle * @param uriInfo URI info used to forward query parameters - * @param subPath path segment(s) after {@code /event/} - * @param siteAuth site authentication key to be validated * @param body JSON request body to forward upstream */ @Operation( @@ -103,7 +105,7 @@ public EventAnalyticsProxyResource(final WebResource webResource) { content = @Content(mediaType = "application/json")) }) @POST - @Path("/event/{subPath:.*}") + @Path("/event/ingest") @NoCache @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @@ -113,13 +115,33 @@ public void proxyEventRequest( @Suspended final AsyncResponse asyncResponse, @Context final UriInfo uriInfo, @Parameter(description = "Sub-path after /event/") - @PathParam("subPath") final String subPath, - @Parameter(description = "Site authentication key", required = true) - @QueryParam("siteAuth") final String siteAuth, final String body) { try { - new SiteAuthValidator().validate(siteAuth); + final Map bodyMap = JsonUtil.getJsonFromString(body); + Object context = bodyMap.get("context"); + + if (context == null) { + Logger.warn(this, "Context is required"); + asyncResponse.resume(Response.status(Response.Status.BAD_REQUEST) + .entity(new ResponseEntityView<>( + List.of(new ErrorEntity(ValidationErrorCode.INVALID_SITE_AUTH.name(), "SiteAuth is required")))) + .build()); + return; + } + + Object siteAuth = ((Map) context).get("site_auth"); + + if (siteAuth == null) { + Logger.warn(this, "SiteAuth is required"); + asyncResponse.resume(Response.status(Response.Status.BAD_REQUEST) + .entity(new ResponseEntityView<>( + List.of(new ErrorEntity(ValidationErrorCode.INVALID_SITE_AUTH.name(), "SiteAuth is required")))) + .build()); + return; + } + + new SiteAuthValidator().validate(siteAuth.toString()); } catch (final AnalyticsValidationException e) { Logger.warn(this, "SiteAuth validation failed for analytics proxy: " + e.getMessage()); asyncResponse.resume(Response.status(Response.Status.BAD_REQUEST) @@ -127,10 +149,17 @@ public void proxyEventRequest( List.of(new ErrorEntity(e.getCode().name(), e.getMessage())))) .build()); return; + } catch (IOException e) { + Logger.warn(this, "SiteAuth validation failed for analytics proxy: " + e.getMessage()); + asyncResponse.resume(Response.status(Response.Status.BAD_REQUEST) + .entity(new ResponseEntityView<>( + List.of(new ErrorEntity(ValidationErrorCode.INVALID_JSON.name(), e.getMessage())))) + .build()); + return; } ResponseUtil.handleAsyncResponse( - () -> EventAnalyticsProxyHelper.proxy("event/" + subPath, uriInfo, body), + () -> EventAnalyticsProxyHelper.proxy("event/ingest", uriInfo, body), asyncResponse); } From e031b4554315abf4f8712bf97ea2823620eed587 Mon Sep 17 00:00:00 2001 From: freddyDOTCMS Date: Tue, 17 Mar 2026 17:08:44 -0600 Subject: [PATCH 05/14] Setting siteid to be sent to the event manager --- .../single-node/docker-compose.yml | 2 + .../content/ContentAnalyticsResource.java | 10 +- .../event/EventAnalyticsProxyHelper.java | 16 ++- .../event/EventAnalyticsProxyResource.java | 63 ++++++++- .../main/webapp/WEB-INF/openapi/openapi.yaml | 126 ++++++++---------- 5 files changed, 133 insertions(+), 84 deletions(-) diff --git a/docker/docker-compose-examples/single-node/docker-compose.yml b/docker/docker-compose-examples/single-node/docker-compose.yml index bb91caf9094d..410f8b290308 100644 --- a/docker/docker-compose-examples/single-node/docker-compose.yml +++ b/docker/docker-compose-examples/single-node/docker-compose.yml @@ -66,7 +66,9 @@ services: DOT_CA_EVENT_MANAGER_BASE_URL: 'http://host.docker.internal:8080' DOT_CA_EVENT_MANAGER_CUSTOMER_ID: 'cust-001' DOT_CA_EVENT_MANAGER_PASSWORD: 'abc' + DOT_ANALYTICS_ENVIRONMENT: 'dev' DOT_ALLOW_ACCESS_TO_PRIVATE_SUBNETS: 'true' + DOT_FEATURE_FLAG_CONTENT_ANALYTICS_AUTO_INJECT: 'true' depends_on: - db - opensearch diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java index 39917362ee6c..94ca05b80a3d 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java @@ -59,7 +59,7 @@ * @author Jose Castro * @since Sep 13th, 2024 */ -@Path("/v1/analytics/content") +//@Path("/v1/analytics/content") @Tag(name = "Content Analytics", description = "This REST Endpoint exposes information related to how dotCMS content is accessed and interacted with by users.") public class ContentAnalyticsResource { @@ -92,7 +92,7 @@ public ContentAnalyticsResource(final WebResource webResource, * @param cubeJsQueryJson the query form. * @return the report response entity view. */ - @Operation( + /*@Operation( operationId = "postContentAnalyticsQuery", summary = "Retrieve Content Analytics data **(Beta)**", description = "Returns information of specific dotCMS objects whose health and " + @@ -126,7 +126,7 @@ public ContentAnalyticsResource(final WebResource webResource, @JSONP @NoCache @Consumes(MediaType.APPLICATION_JSON) - @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Produces({MediaType.APPLICATION_JSON, "application/javascript"})*/ public ReportResponseEntityView queryCubeJs(@Context final HttpServletRequest request, @Context final HttpServletResponse response, final String cubeJsQueryJson) throws CustomAttributeProcessingException { @@ -162,7 +162,7 @@ public ReportResponseEntityView queryCubeJs(@Context final HttpServletRequest re * @param userEventPayload the query form. * @return the report response entity view. */ - @Operation( + /*@Operation( operationId = "fireUserCustomEvent", summary = "Fire an user custom event **(Beta)**.", description = "receives a custom event payload and fires the event to the collectors. " + @@ -189,7 +189,7 @@ public ReportResponseEntityView queryCubeJs(@Context final HttpServletRequest re @JSONP @NoCache @Consumes(MediaType.APPLICATION_JSON) - @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Produces({MediaType.APPLICATION_JSON, "application/javascript"})*/ public Response fireUserCustomEvent(@Context final HttpServletRequest request, @Context final HttpServletResponse response, final Map userEventPayload) throws DotSecurityException { diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java index 1b06c9dcd1b4..40283ed0566f 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java @@ -26,6 +26,7 @@ *
  • {@link #DOT_CA_EVENT_MANAGER_BASE_URL} – base URL of the dot-ca-event-manager service
  • *
  • {@link #DOT_CA_EVENT_MANAGER_CUSTOMER_ID} – customer ID for Basic auth
  • *
  • {@link #DOT_CA_EVENT_MANAGER_PASSWORD} – password for Basic auth
  • + *
  • {@link #ANALYTICS_ENVIRONMENT} – environment name appended as {@code ?environment=} query param
  • * * * @author dotCMS @@ -36,6 +37,7 @@ public class EventAnalyticsProxyHelper { static final String DOT_CA_EVENT_MANAGER_BASE_URL = "DOT_CA_EVENT_MANAGER_BASE_URL"; static final String DOT_CA_EVENT_MANAGER_CUSTOMER_ID = "DOT_CA_EVENT_MANAGER_CUSTOMER_ID"; static final String DOT_CA_EVENT_MANAGER_PASSWORD = "DOT_CA_EVENT_MANAGER_PASSWORD"; + static final String ANALYTICS_ENVIRONMENT = "DOT_ANALYTICS_ENVIRONMENT"; private EventAnalyticsProxyHelper() { // utility class — no instances @@ -127,6 +129,14 @@ static String buildUpstreamUrl(final String baseUrl, }) ); + final String analyticsEnvironment = Config.getStringProperty(ANALYTICS_ENVIRONMENT, ""); + if (UtilMethods.isSet(analyticsEnvironment)) { + if (queryString.length() > 0) { + queryString.append("&"); + } + queryString.append("environment=").append(analyticsEnvironment); + } + return cleanBase + upstreamPath + (queryString.length() > 0 ? "?" + queryString : ""); } @@ -155,7 +165,7 @@ static String buildBasicAuthHeader() { * @return dotCMS-wrapped {@link Response} */ static Response wrapUpstreamResponse(final CircuitBreakerUrl.Response cbResponse) { - if (cbResponse == null || !UtilMethods.isSet(cbResponse.getResponse())) { + if (cbResponse == null ) { Logger.warn(EventAnalyticsProxyHelper.class, "Received null or empty response from analytics service"); return Response.serverError() @@ -168,9 +178,9 @@ static Response wrapUpstreamResponse(final CircuitBreakerUrl.Response cb final int statusCode = cbResponse.getStatusCode(); try { - final Object parsedBody = DotObjectMapperProvider.getInstance() + final Object parsedBody = UtilMethods.isSet(cbResponse.getResponse()) ? DotObjectMapperProvider.getInstance() .getDefaultObjectMapper() - .readValue(cbResponse.getResponse(), Object.class); + .readValue(cbResponse.getResponse(), Object.class) : ""; return Response.status(statusCode) .entity(new ResponseEntityView<>(parsedBody)) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java index 2d3c623dbc2c..9ed7489d2871 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java @@ -4,13 +4,21 @@ import com.dotcms.jitsu.validators.SiteAuthValidator; import com.dotcms.jitsu.validators.ValidationErrorCode; import com.dotcms.rest.ErrorEntity; +import com.dotcms.rest.InitDataObject; import com.dotcms.rest.ResponseEntityView; import com.dotcms.rest.WebResource; import com.dotcms.rest.annotation.NoCache; +import com.dotcms.rest.api.v1.analytics.content.util.AnalyticsEventsResult; +import com.dotcms.rest.api.v1.analytics.content.util.ContentAnalyticsUtil; import com.dotcms.rest.api.v1.authentication.ResponseUtil; import com.dotcms.util.JsonUtil; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.util.Logger; import com.google.common.annotations.VisibleForTesting; +import com.liferay.portal.model.User; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -18,6 +26,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import org.glassfish.jersey.server.JSONP; import javax.inject.Inject; import javax.servlet.http.HttpServletRequest; @@ -38,6 +47,9 @@ import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.Objects; + +import static com.dotmarketing.util.Constants.DONT_RESPECT_FRONT_END_ROLES; /** * REST proxy resource that intercepts requests to {@code /v1/analytics/**} and forwards them to @@ -105,7 +117,7 @@ public EventAnalyticsProxyResource(final WebResource webResource) { content = @Content(mediaType = "application/json")) }) @POST - @Path("/event/ingest") + @Path("/content/event") @NoCache @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @@ -117,6 +129,7 @@ public void proxyEventRequest( @Parameter(description = "Sub-path after /event/") final String body) { + String proxyBody = body; try { final Map bodyMap = JsonUtil.getJsonFromString(body); Object context = bodyMap.get("context"); @@ -142,6 +155,10 @@ public void proxyEventRequest( } new SiteAuthValidator().validate(siteAuth.toString()); + + final Host site = ContentAnalyticsUtil.getSiteFromRequest(request); + ((Map) bodyMap.get("context")).put("site_id", site.getIdentifier()); + proxyBody = JsonUtil.getJsonStringFromObject(bodyMap); } catch (final AnalyticsValidationException e) { Logger.warn(this, "SiteAuth validation failed for analytics proxy: " + e.getMessage()); asyncResponse.resume(Response.status(Response.Status.BAD_REQUEST) @@ -158,8 +175,9 @@ public void proxyEventRequest( return; } + final String finalProxyBody = proxyBody; ResponseUtil.handleAsyncResponse( - () -> EventAnalyticsProxyHelper.proxy("event/ingest", uriInfo, body), + () -> EventAnalyticsProxyHelper.proxy("event/ingest", uriInfo, finalProxyBody), asyncResponse); } @@ -216,4 +234,45 @@ public Response proxyGetRequest( return EventAnalyticsProxyHelper.proxy(path, uriInfo, null); } + @Operation( + operationId = "generateSiteAuth", + summary = "Generate Site Auth", + description = "Generates and returns a Site Key that must be used by the client-side JS " + + "code to send custom Content Analytics Events", + tags = {"Content Analytics"}, + responses = { + @ApiResponse(responseCode = "200", description = "The Site key was generated and " + + "returned successfully"), + @ApiResponse(responseCode = "400", description = "Bad Request"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "404", description = "Site ID in path is not found or " + + "incorrect path"), + @ApiResponse(responseCode = "405", description = "Method Not Allowed"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) + @GET + @Path("/content/siteauth/generate/{siteId}") + @JSONP + @NoCache + @Produces({MediaType.TEXT_PLAIN, "text/plain"}) + public Response generateSiteKey(@PathParam("siteId") final String siteId, + @Context final HttpServletRequest request, + @Context final HttpServletResponse response) throws DotDataException, DotSecurityException { + final InitDataObject initDataObject = new WebResource.InitBuilder(this.webResource) + .requestAndResponse(request, response) + .requiredBackendUser(true) + .rejectWhenNoUser(true) + .init(); + final User user = initDataObject.getUser(); + final Host site = APILocator.getHostAPI().find(siteId, user, DONT_RESPECT_FRONT_END_ROLES); + Objects.requireNonNull(site, String.format("Site with ID '%s' was not found", siteId)); + return Response.ok().entity(ContentAnalyticsUtil.generateInternalSiteKey(site.getIdentifier())).build(); + } + + private int getResponseStatus(final AnalyticsEventsResult analyticsEventsResult) { + return analyticsEventsResult.getStatus() == AnalyticsEventsResult.ResponseStatus.ERROR ? 400 + : analyticsEventsResult.getStatus() == AnalyticsEventsResult.ResponseStatus.SUCCESS ? 200 + : 207; + } } \ No newline at end of file diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index 926c856620d7..9daa6a86e8ce 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -25,8 +25,8 @@ tags: - description: Endpoints that perform operations related to validating accessibility in content. name: Accessibility Checker -- description: This REST Endpoint exposes information related to how dotCMS content - is accessed and interacted with by users. +- description: Proxy endpoints that forward analytics requests to the dot-ca-event-manager + service. name: Content Analytics - description: System announcements and notifications name: Announcements @@ -3023,64 +3023,36 @@ paths: description: default response tags: - AI - /v1/analytics/content/_query/cube: + /v1/analytics/content/event: post: - description: "Returns information of specific dotCMS objects whose health and\ - \ engagement data is tracked, using a CubeJS JSON query. **Note:** This endpoint\ - \ is in **beta** and may change." - operationId: postContentAnalyticsQuery + description: Validates the siteAuth query parameter and asynchronously forwards + POST requests to /v1/analytics/event/** to the dot-ca-event-manager service + at /v1/event/**. All query parameters and the request body are preserved. + operationId: proxyAnalyticsEventRequest requestBody: content: application/json: schema: type: string + description: Sub-path after /event/ responses: "200": content: application/json: - example: - dimensions: - - Events.experiment - - Events.variant - description: Content Analytics data being queried - "400": - description: Bad Request - "401": - description: Unauthorized - "415": - description: Unsupported Media Type - "500": - description: Internal Server Error - summary: Retrieve Content Analytics data **(Beta)** - tags: - - Content Analytics - /v1/analytics/content/event: - post: - description: receives a custom event payload and fires the event to the collectors. - **Note:** This endpoint is in **beta** and may change. - operationId: fireUserCustomEvent - requestBody: - content: - application/json: - schema: - type: object - additionalProperties: + schema: type: object - responses: - "200": - content: - application/json: - example: TBD - description: If the event was created successfully + description: dotCMS response envelope containing the upstream analytics + data + description: Successful upstream response "400": - description: Bad Request - "403": - description: Forbidden - "415": - description: Unsupported Media Type + content: + application/json: {} + description: Invalid siteAuth or upstream returned an error "500": - description: Internal Server Error - summary: Fire an user custom event **(Beta)**. + content: + application/json: {} + description: Internal server error or upstream unreachable + summary: Proxy analytics event POST request with site auth validation tags: - Content Analytics /v1/analytics/content/siteauth/generate/{siteId}: @@ -3110,6 +3082,39 @@ paths: summary: Generate Site Auth tags: - Content Analytics + /v1/analytics/{path}: + get: + description: "Forwards any authenticated GET request to /v1/analytics/** to\ + \ the dot-ca-event-manager service at /v1/**, preserving all query parameters." + operationId: proxyAnalyticsGetRequest + parameters: + - description: Path to forward to the upstream analytics service + in: path + name: path + required: true + schema: + type: string + pattern: .* + responses: + "200": + content: + application/json: + schema: + type: object + description: dotCMS response envelope containing the upstream analytics + data + description: Successful upstream response + "401": + content: + application/json: {} + description: Unauthorized – backend user required + "500": + content: + application/json: {} + description: Internal server error or upstream unreachable + summary: Proxy any analytics GET request + tags: + - Content Analytics /v1/announcements: get: operationId: announcements @@ -27384,33 +27389,6 @@ components: urlTimeoutSeconds: type: integer format: int32 - ReportResponseEntityView: - type: object - properties: - entity: - type: array - items: - type: object - additionalProperties: - type: object - errors: - type: array - items: - $ref: "#/components/schemas/ErrorEntity" - i18nMessagesMap: - type: object - additionalProperties: - type: string - messages: - type: array - items: - $ref: "#/components/schemas/MessageEntity" - pagination: - $ref: "#/components/schemas/Pagination" - permissions: - type: array - items: - type: string ResetAssetPermissionsView: type: object properties: From d6a7a6abaac757b435e5ee58690827bc0096b019 Mon Sep 17 00:00:00 2001 From: freddyDOTCMS Date: Wed, 15 Apr 2026 10:43:35 -0600 Subject: [PATCH 06/14] Resend user agent to the event manager --- .../event/EventAnalyticsProxyHelper.java | 17 +++++++++++++++-- .../event/EventAnalyticsProxyResource.java | 5 +++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java index 40283ed0566f..e8a82d84e8e7 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java @@ -8,10 +8,13 @@ import com.dotmarketing.util.Logger; import com.dotmarketing.util.UtilMethods; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -57,11 +60,13 @@ private EventAnalyticsProxyHelper() { * @param relativePath path relative to {@code /v1/} on the upstream service * @param uriInfo original URI info used to forward query parameters * @param body optional JSON body; triggers a POST when non-null + * @param userAgent {@code User-Agent} header value from the original request; forwarded as-is * @return dotCMS-wrapped {@link Response} */ public static Response proxy(final String relativePath, final UriInfo uriInfo, - final String body) { + final String body, + final String userAgent) { final String baseUrl = Config.getStringProperty(DOT_CA_EVENT_MANAGER_BASE_URL, ""); if (!UtilMethods.isSet(baseUrl)) { Logger.error(EventAnalyticsProxyHelper.class, @@ -77,6 +82,14 @@ public static Response proxy(final String relativePath, final String authHeader = buildBasicAuthHeader(); final boolean isPost = UtilMethods.isSet(body); + final Map requestHeaders = new HashMap<>(); + requestHeaders.put(HttpHeaders.AUTHORIZATION, authHeader); + requestHeaders.put(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON); + requestHeaders.put(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + if (UtilMethods.isSet(userAgent)) { + requestHeaders.put(HttpHeaders.USER_AGENT, userAgent); + } + Logger.debug(EventAnalyticsProxyHelper.class, () -> "Proxying analytics " + (isPost ? "POST" : "GET") + " request to: " + upstreamUrl); @@ -84,7 +97,7 @@ public static Response proxy(final String relativePath, final CircuitBreakerUrl.Response cbResponse = CircuitBreakerUrl.builder() .setUrl(upstreamUrl) .setMethod(isPost ? CircuitBreakerUrl.Method.POST : CircuitBreakerUrl.Method.GET) - .setAuthHeaders(authHeader) + .setHeaders(requestHeaders) .setRawData(isPost ? body : null) .setThrowWhenError(false) .build() diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java index 9ed7489d2871..716a4602741b 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyResource.java @@ -177,7 +177,8 @@ public void proxyEventRequest( final String finalProxyBody = proxyBody; ResponseUtil.handleAsyncResponse( - () -> EventAnalyticsProxyHelper.proxy("event/ingest", uriInfo, finalProxyBody), + () -> EventAnalyticsProxyHelper.proxy("event/ingest", uriInfo, finalProxyBody, + request.getHeader("User-Agent")), asyncResponse); } @@ -231,7 +232,7 @@ public Response proxyGetRequest( .rejectWhenNoUser(true) .init(); - return EventAnalyticsProxyHelper.proxy(path, uriInfo, null); + return EventAnalyticsProxyHelper.proxy(path, uriInfo, null, request.getHeader("User-Agent")); } @Operation( From 8f6e4379658f1c82be0bf1ff94af6349942d380c Mon Sep 17 00:00:00 2001 From: freddyDOTCMS Date: Thu, 23 Apr 2026 12:24:54 -0600 Subject: [PATCH 07/14] Renaming analytics environments variables --- .../single-node/docker-compose.yml | 12 ++++++----- .../event/EventAnalyticsProxyHelper.java | 20 +++++++++---------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docker/docker-compose-examples/single-node/docker-compose.yml b/docker/docker-compose-examples/single-node/docker-compose.yml index d0bf4343b701..8dd6fd1e2c37 100644 --- a/docker/docker-compose-examples/single-node/docker-compose.yml +++ b/docker/docker-compose-examples/single-node/docker-compose.yml @@ -45,9 +45,9 @@ services: memory: 2G dotcms: - image: dotcms/dotcms:latest + image: dotcms/dotcms-test:1.0.0-SNAPSHOT environment: - CMS_JAVA_OPTS: '-Xmx1g ' + CMS_JAVA_OPTS: '-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:8000' LANG: 'C.UTF-8' TZ: 'UTC' DB_BASE_URL: "jdbc:postgresql://db/dotcms" @@ -63,10 +63,11 @@ services: #CMS_SSL_CERTIFICATE_KEY_FILE: '/certs/localhost-key.pem' #CUSTOM_STARTER_URL: 'https://repo.dotcms.com/artifactory/libs-release-local/com/dotcms/starter/20260211/starter-20260211.zip' - DOT_CA_EVENT_MANAGER_BASE_URL: 'http://host.docker.internal:8080' - DOT_CA_EVENT_MANAGER_CUSTOMER_ID: 'cust-001' - DOT_CA_EVENT_MANAGER_PASSWORD: 'abc' + DOT_ANALYTICS_BASE_URL: 'http://host.docker.internal:8080' + DOT_ANALYTICS_CUSTOMER_ID: 'cust-001' + DOT_ANALYTICS_PASSWORD: 'abc' DOT_ANALYTICS_ENVIRONMENT: 'dev' + DOT_ALLOW_ACCESS_TO_PRIVATE_SUBNETS: 'true' DOT_FEATURE_FLAG_CONTENT_ANALYTICS_AUTO_INJECT: 'true' depends_on: @@ -80,6 +81,7 @@ services: - db_net - opensearch-net ports: + - "8000:8000" - "8082:8082" - "8443:8443" - "4000:4000" # Glowroot web ui if enabled diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java index e8a82d84e8e7..99b4b0b2cdf8 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/event/EventAnalyticsProxyHelper.java @@ -26,9 +26,9 @@ * *

    Config properties consumed: *

      - *
    • {@link #DOT_CA_EVENT_MANAGER_BASE_URL} – base URL of the dot-ca-event-manager service
    • - *
    • {@link #DOT_CA_EVENT_MANAGER_CUSTOMER_ID} – customer ID for Basic auth
    • - *
    • {@link #DOT_CA_EVENT_MANAGER_PASSWORD} – password for Basic auth
    • + *
    • {@link #DOT_ANALYTICS_BASE_URL} – base URL of the dot-ca-event-manager service
    • + *
    • {@link #DOT_ANALYTICS_CUSTOMER_ID} – customer ID for Basic auth
    • + *
    • {@link #DOT_ANALYTICS_PASSWORD} – password for Basic auth
    • *
    • {@link #ANALYTICS_ENVIRONMENT} – environment name appended as {@code ?environment=} query param
    • *
    * @@ -37,9 +37,9 @@ */ public class EventAnalyticsProxyHelper { - static final String DOT_CA_EVENT_MANAGER_BASE_URL = "DOT_CA_EVENT_MANAGER_BASE_URL"; - static final String DOT_CA_EVENT_MANAGER_CUSTOMER_ID = "DOT_CA_EVENT_MANAGER_CUSTOMER_ID"; - static final String DOT_CA_EVENT_MANAGER_PASSWORD = "DOT_CA_EVENT_MANAGER_PASSWORD"; + static final String DOT_ANALYTICS_BASE_URL = "DOT_ANALYTICS_BASE_URL"; + static final String DOT_ANALYTICS_CUSTOMER_ID = "DOT_ANALYTICS_CUSTOMER_ID"; + static final String DOT_ANALYTICS_PASSWORD = "DOT_ANALYTICS_PASSWORD"; static final String ANALYTICS_ENVIRONMENT = "DOT_ANALYTICS_ENVIRONMENT"; private EventAnalyticsProxyHelper() { @@ -67,10 +67,10 @@ public static Response proxy(final String relativePath, final UriInfo uriInfo, final String body, final String userAgent) { - final String baseUrl = Config.getStringProperty(DOT_CA_EVENT_MANAGER_BASE_URL, ""); + final String baseUrl = Config.getStringProperty(DOT_ANALYTICS_BASE_URL, ""); if (!UtilMethods.isSet(baseUrl)) { Logger.error(EventAnalyticsProxyHelper.class, - "Configuration property '" + DOT_CA_EVENT_MANAGER_BASE_URL + "' is not set"); + "Configuration property '" + DOT_ANALYTICS_BASE_URL + "' is not set"); return Response.serverError() .entity(new ResponseEntityView<>( List.of(new ErrorEntity("CONFIGURATION_ERROR", @@ -160,8 +160,8 @@ static String buildUpstreamUrl(final String baseUrl, * @return full Authorization header value, e.g. {@code Basic dXNlcjpwYXNz} */ static String buildBasicAuthHeader() { - final String customerId = Config.getStringProperty(DOT_CA_EVENT_MANAGER_CUSTOMER_ID, ""); - final String password = Config.getStringProperty(DOT_CA_EVENT_MANAGER_PASSWORD, ""); + final String customerId = Config.getStringProperty(DOT_ANALYTICS_CUSTOMER_ID, ""); + final String password = Config.getStringProperty(DOT_ANALYTICS_PASSWORD, ""); final byte[] credentials = (customerId + ":" + password).getBytes(StandardCharsets.UTF_8); return "Basic " + Base64.getEncoder().encodeToString(credentials); } From e411b635c5f6e01fcfcad7d964f0bd7183a4df74 Mon Sep 17 00:00:00 2001 From: freddyDOTCMS Date: Fri, 24 Apr 2026 08:27:19 -0600 Subject: [PATCH 08/14] Removing unneed changes --- docker/docker-compose-examples/single-node/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/docker-compose-examples/single-node/docker-compose.yml b/docker/docker-compose-examples/single-node/docker-compose.yml index 8dd6fd1e2c37..bda1567e2541 100644 --- a/docker/docker-compose-examples/single-node/docker-compose.yml +++ b/docker/docker-compose-examples/single-node/docker-compose.yml @@ -45,9 +45,9 @@ services: memory: 2G dotcms: - image: dotcms/dotcms-test:1.0.0-SNAPSHOT + image: dotcms/dotcms:latest environment: - CMS_JAVA_OPTS: '-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:8000' + CMS_JAVA_OPTS: '-Xmx1g ' LANG: 'C.UTF-8' TZ: 'UTC' DB_BASE_URL: "jdbc:postgresql://db/dotcms" From b26cc8cf31e7cbd427ca3e554c15bff34ca1a623 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Wed, 29 Apr 2026 12:11:19 -0400 Subject: [PATCH 09/14] Enhance analytics service with health check functionality - Added new health status types: AVAILABLE and NOT_AVAILABLE to `HealthStatusTypes` enum. - Implemented `healthCheck` and `healthCheckWithCache` methods in `DotAnalyticsService` to check analytics availability. - Updated `analyticsHealthGuard` to use the new health check methods. - Modified `DotAnalyticsErrorComponent` to include a retry button that clears the health cache and navigates back to the dashboard. - Adjusted routes to use dynamic component loading for better performance. - Enhanced error handling in the analytics error component. This update improves the overall reliability and user experience of the analytics feature by providing real-time health status checks and a retry mechanism. --- .../src/lib/dot-experiments-constants.ts | 4 +- .../src/lib/services/dot-analytics.service.ts | 54 ++++++++++++-- .../dot-analytics-error.component.html | 6 +- .../dot-analytics-error.component.ts | 30 ++++++-- .../lib/guards/analytics-health.guard.spec.ts | 72 +++++++------------ .../src/lib/guards/analytics-health.guard.ts | 40 +++-------- .../portlet/src/lib/lib.routes.ts | 9 +-- 7 files changed, 114 insertions(+), 101 deletions(-) diff --git a/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts b/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts index 675439ccda03..4ea298b3d860 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts @@ -291,7 +291,9 @@ export const CONFIGURATION_CONFIRM_DIALOG_KEY = 'confirmDialog'; export enum HealthStatusTypes { OK = 'OK', NOT_CONFIGURED = 'NOT_CONFIGURED', - CONFIGURATION_ERROR = 'CONFIGURATION_ERROR' + CONFIGURATION_ERROR = 'CONFIGURATION_ERROR', + AVAILABLE = 'AVAILABLE', + NOT_AVAILABLE = 'NOT_AVAILABLE' } export const RUNNING_UNTIL_DATE_FORMAT = 'EEE, LLL dd'; diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts index 62b1c1657654..25577507d157 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts @@ -1,17 +1,16 @@ -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { map } from 'rxjs/operators'; +import { catchError, map, shareReplay } from 'rxjs/operators'; + +import { HealthStatusTypes } from '@dotcms/dotcms-models'; import { AnalyticsApiResponse, CubeJSQuery } from '../../index'; /** - * Generic analytics service for CubeJS queries. - * - * This service provides a single method to execute any CubeJS query. - * Query construction is handled by the store using the CubeQueryBuilder. + * Generic analytics service for CubeJS queries and health checks. * * @example * ```typescript @@ -22,7 +21,7 @@ import { AnalyticsApiResponse, CubeJSQuery } from '../../index'; * .siteId(siteId) * .build(); * - * analyticsService.query(query).pipe( + * analyticsService.cubeQuery(query).pipe( * map(entities => entities[0]) * ); * ``` @@ -32,8 +31,49 @@ import { AnalyticsApiResponse, CubeJSQuery } from '../../index'; }) export class DotAnalyticsService { readonly #BASE_URL = '/api/v1/analytics/content/_query/cube'; + readonly #HEALTH_URL = '/api/v1/analytics/check'; readonly #http = inject(HttpClient); + #healthCache$: Observable | null = null; + + /** + * Checks analytics availability via the health endpoint. + * Always makes a fresh HTTP request. + * + * @returns Observable of HealthStatusTypes (AVAILABLE or NOT_AVAILABLE) + */ + healthCheck(): Observable { + return this.#http.get<{ available: string }>(this.#HEALTH_URL).pipe( + map((response) => + response.available === 'true' + ? HealthStatusTypes.AVAILABLE + : HealthStatusTypes.NOT_AVAILABLE + ), + catchError(() => of(HealthStatusTypes.AVAILABLE)) + ); + } + + /** + * Cached version of healthCheck. Uses shareReplay to avoid + * multiple HTTP calls across guards/components in the same navigation. + * + * @returns Observable of HealthStatusTypes (cached) + */ + healthCheckWithCache(): Observable { + if (!this.#healthCache$) { + this.#healthCache$ = this.healthCheck().pipe(shareReplay(1)); + } + + return this.#healthCache$; + } + + /** + * Clears the cached health check result, forcing a fresh request on next call. + */ + clearHealthCache(): void { + this.#healthCache$ = null; + } + /** * Executes a CubeJS query and returns the entity array. * diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.html index 09595d81fa8a..f24d3b64c1dc 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.html @@ -1,3 +1,7 @@
    - +
    diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.ts index 7adf828689f5..8a51462f33d7 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-error/dot-analytics-error.component.ts @@ -1,8 +1,9 @@ import { Component, computed, inject } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { DotMessageService } from '@dotcms/data-access'; import { HealthStatusTypes } from '@dotcms/dotcms-models'; +import { DotAnalyticsService } from '@dotcms/portlets/dot-analytics/data-access'; import { DotEmptyContainerComponent, PrincipalConfiguration } from '@dotcms/ui'; /** @@ -16,7 +17,9 @@ import { DotEmptyContainerComponent, PrincipalConfiguration } from '@dotcms/ui'; }) export default class DotAnalyticsErrorComponent { private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); private readonly dotMessageService = inject(DotMessageService); + private readonly analyticsService = inject(DotAnalyticsService); /** * Computed configuration for the empty state component based on route parameters @@ -29,6 +32,16 @@ export default class DotAnalyticsErrorComponent { return this.getErrorConfig(status, isEnterprise); }); + protected readonly $retryLabel = this.dotMessageService.get('analytics.error.retry'); + + /** + * Clears the health check cache and navigates back to analytics dashboard. + */ + onRetry(): void { + this.analyticsService.clearHealthCache(); + this.router.navigate(['/analytics']); + } + /** * Gets the appropriate error configuration based on health status and enterprise license */ @@ -36,7 +49,6 @@ export default class DotAnalyticsErrorComponent { status: HealthStatusTypes, isEnterprise: boolean ): PrincipalConfiguration { - // If not enterprise, show license error regardless of health status if (!isEnterprise) { return { title: this.dotMessageService.get('analytics.search.no.license'), @@ -45,8 +57,14 @@ export default class DotAnalyticsErrorComponent { }; } - // Enterprise license configurations based on health status - const enterpriseConfigs: Record = { + const defaultConfig: PrincipalConfiguration = { + title: this.dotMessageService.get('analytics.error.not.available'), + subtitle: this.dotMessageService.get('analytics.error.not.available.subtitle'), + icon: 'pi-exclamation-triangle' + }; + + const enterpriseConfigs: Partial> = { + [HealthStatusTypes.NOT_AVAILABLE]: defaultConfig, [HealthStatusTypes.NOT_CONFIGURED]: { title: this.dotMessageService.get('analytics.search.no.configured'), subtitle: this.dotMessageService.get('analytics.search.no.configured.subtitle'), @@ -64,8 +82,6 @@ export default class DotAnalyticsErrorComponent { } }; - return ( - enterpriseConfigs[status] || enterpriseConfigs[HealthStatusTypes.CONFIGURATION_ERROR] - ); + return enterpriseConfigs[status] ?? defaultConfig; } } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.spec.ts index a0c6698eb018..60b8ef99e0f7 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.spec.ts @@ -3,23 +3,20 @@ import { of } from 'rxjs'; import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, Route, Router } from '@angular/router'; -import { DotExperimentsService } from '@dotcms/data-access'; import { HealthStatusTypes } from '@dotcms/dotcms-models'; +import { DotAnalyticsService } from '@dotcms/portlets/dot-analytics/data-access'; -import { analyticsHealthGuard, clearAnalyticsHealthCache } from './analytics-health.guard'; +import { analyticsHealthGuard } from './analytics-health.guard'; describe('analyticsHealthGuard', () => { let mockRouter: Router; let mockActivatedRoute: ActivatedRoute; - let mockDotExperimentsService: DotExperimentsService; + let mockAnalyticsService: DotAnalyticsService; const mockRoute = {} as Route; const mockSegments = []; beforeEach(() => { - // Clear cache before each test to ensure isolation - clearAnalyticsHealthCache(); - mockRouter = { navigate: jest.fn() } as unknown as Router; @@ -30,22 +27,24 @@ describe('analyticsHealthGuard', () => { } } as unknown as ActivatedRoute; - mockDotExperimentsService = { - healthCheck: jest.fn() - } as unknown as DotExperimentsService; + mockAnalyticsService = { + healthCheck: jest.fn(), + healthCheckWithCache: jest.fn(), + clearHealthCache: jest.fn() + } as unknown as DotAnalyticsService; TestBed.configureTestingModule({ providers: [ { provide: Router, useValue: mockRouter }, { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: DotExperimentsService, useValue: mockDotExperimentsService } + { provide: DotAnalyticsService, useValue: mockAnalyticsService } ] }); }); - it('should allow access when health status is OK', (done) => { - (mockDotExperimentsService.healthCheck as jest.Mock).mockReturnValue( - of(HealthStatusTypes.OK) + it('should allow access when health status is AVAILABLE', (done) => { + (mockAnalyticsService.healthCheckWithCache as jest.Mock).mockReturnValue( + of(HealthStatusTypes.AVAILABLE) ); TestBed.runInInjectionContext(() => { @@ -61,9 +60,9 @@ describe('analyticsHealthGuard', () => { }); }); - it('should redirect to error page when health status is NOT_CONFIGURED', (done) => { - (mockDotExperimentsService.healthCheck as jest.Mock).mockReturnValue( - of(HealthStatusTypes.NOT_CONFIGURED) + it('should redirect to error page when health status is NOT_AVAILABLE', (done) => { + (mockAnalyticsService.healthCheck as jest.Mock).mockReturnValue( + of(HealthStatusTypes.NOT_AVAILABLE) ); TestBed.runInInjectionContext(() => { @@ -74,30 +73,7 @@ describe('analyticsHealthGuard', () => { expect(canActivate).toBe(false); expect(mockRouter.navigate).toHaveBeenCalledWith(['/analytics/error'], { queryParams: { - status: HealthStatusTypes.NOT_CONFIGURED, - isEnterprise: true - } - }); - done(); - }); - } - }); - }); - - it('should redirect to error page when health status is CONFIGURATION_ERROR', (done) => { - (mockDotExperimentsService.healthCheck as jest.Mock).mockReturnValue( - of(HealthStatusTypes.CONFIGURATION_ERROR) - ); - - TestBed.runInInjectionContext(() => { - const result = analyticsHealthGuard(mockRoute, mockSegments); - - if (result && typeof result === 'object' && 'subscribe' in result) { - result.subscribe((canActivate) => { - expect(canActivate).toBe(false); - expect(mockRouter.navigate).toHaveBeenCalledWith(['/analytics/error'], { - queryParams: { - status: HealthStatusTypes.CONFIGURATION_ERROR, + status: HealthStatusTypes.NOT_AVAILABLE, isEnterprise: true } }); @@ -108,10 +84,10 @@ describe('analyticsHealthGuard', () => { }); it('should handle missing isEnterprise data by defaulting to true', (done) => { - (mockDotExperimentsService.healthCheck as jest.Mock).mockReturnValue( - of(HealthStatusTypes.NOT_CONFIGURED) + (mockAnalyticsService.healthCheck as jest.Mock).mockReturnValue( + of(HealthStatusTypes.NOT_AVAILABLE) ); - mockActivatedRoute.snapshot.data = {}; // No isEnterprise data + mockActivatedRoute.snapshot.data = {}; TestBed.runInInjectionContext(() => { const result = analyticsHealthGuard(mockRoute, mockSegments); @@ -121,8 +97,8 @@ describe('analyticsHealthGuard', () => { expect(canActivate).toBe(false); expect(mockRouter.navigate).toHaveBeenCalledWith(['/analytics/error'], { queryParams: { - status: HealthStatusTypes.NOT_CONFIGURED, - isEnterprise: true // Should default to true + status: HealthStatusTypes.NOT_AVAILABLE, + isEnterprise: true } }); done(); @@ -132,8 +108,8 @@ describe('analyticsHealthGuard', () => { }); it('should pass isEnterprise false when it is set to false', (done) => { - (mockDotExperimentsService.healthCheck as jest.Mock).mockReturnValue( - of(HealthStatusTypes.NOT_CONFIGURED) + (mockAnalyticsService.healthCheck as jest.Mock).mockReturnValue( + of(HealthStatusTypes.NOT_AVAILABLE) ); mockActivatedRoute.snapshot.data = { isEnterprise: false }; @@ -145,7 +121,7 @@ describe('analyticsHealthGuard', () => { expect(canActivate).toBe(false); expect(mockRouter.navigate).toHaveBeenCalledWith(['/analytics/error'], { queryParams: { - status: HealthStatusTypes.NOT_CONFIGURED, + status: HealthStatusTypes.NOT_AVAILABLE, isEnterprise: false } }); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.ts index 289fa684d0a9..267ce14eda10 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/guards/analytics-health.guard.ts @@ -1,43 +1,28 @@ -import { Observable } from 'rxjs'; - import { inject } from '@angular/core'; import { ActivatedRoute, CanMatchFn, Router } from '@angular/router'; -import { map, shareReplay } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; -import { DotExperimentsService } from '@dotcms/data-access'; import { HealthStatusTypes } from '@dotcms/dotcms-models'; - -// Cache global para el health check - compartido entre todas las ejecuciones del guard -let healthCheckCache$: Observable | null = null; +import { DotAnalyticsService } from '@dotcms/portlets/dot-analytics/data-access'; /** - * Guard optimizado que protege las rutas de analytics. - * Usa shareReplay para evitar múltiples llamadas al health check. + * Guard that protects analytics routes by checking service availability. + * Cache is managed internally by DotAnalyticsService. */ export const analyticsHealthGuard: CanMatchFn = (_route, _segments) => { - const dotExperimentsService = inject(DotExperimentsService); + const analyticsService = inject(DotAnalyticsService); const router = inject(Router); const activatedRoute = inject(ActivatedRoute); - // Si no hay cache, crear uno con shareReplay - if (!healthCheckCache$) { - healthCheckCache$ = dotExperimentsService.healthCheck().pipe( - shareReplay(1) // ← CLAVE: Comparte el último resultado entre múltiples suscriptores - ); - } - - // Usar el observable cacheado - return healthCheckCache$.pipe( + return analyticsService.healthCheckWithCache().pipe( map((healthStatus) => { - if (healthStatus === HealthStatusTypes.OK) { - return true; // Allow access to the route + if (healthStatus === HealthStatusTypes.AVAILABLE) { + return true; } - // Get isEnterprise from route data (resolved at parent level) const isEnterprise = activatedRoute.snapshot.data?.['isEnterprise'] ?? true; - // Redirect to error page with status information router.navigate(['/analytics/error'], { queryParams: { status: healthStatus, @@ -45,14 +30,7 @@ export const analyticsHealthGuard: CanMatchFn = (_route, _segments) => { } }); - return false; // Block access to the route + return false; }) ); }; - -/** - * Función para limpiar el cache del health check (útil para testing o forzar revalidación) - */ -export function clearAnalyticsHealthCache(): void { - healthCheckCache$ = null; -} diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/lib.routes.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/lib.routes.ts index cf312ae77b16..59a08742483a 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/lib.routes.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/lib.routes.ts @@ -1,26 +1,23 @@ import { Route } from '@angular/router'; -import DotAnalyticsDashboardComponent from './dot-analytics-dashboard/dot-analytics-dashboard.component'; import { analyticsHealthGuard } from './guards/analytics-health.guard'; export const dotAnalyticsRoutes: Route[] = [ { path: 'error', title: 'analytics.error.title', - loadComponent: () => - import('./dot-analytics-error/dot-analytics-error.component').then((m) => m.default) + loadComponent: () => import('./dot-analytics-error/dot-analytics-error.component') }, { path: 'search', title: 'analytics.search.title', canMatch: [analyticsHealthGuard], - loadComponent: () => - import('./dot-analytics-search/dot-analytics-search.component').then((m) => m.default) + loadComponent: () => import('./dot-analytics-search/dot-analytics-search.component') }, { path: 'dashboard', canMatch: [analyticsHealthGuard], - component: DotAnalyticsDashboardComponent + loadComponent: () => import('./dot-analytics-dashboard/dot-analytics-dashboard.component') }, { path: '', From 3843762c2a1ac8b81322284e6facb02cbf940f1d Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Wed, 29 Apr 2026 17:04:54 -0400 Subject: [PATCH 10/14] Enhance analytics functionality with new event API integration - Introduced `TIME_RANGE_API_MAPPING` for mapping internal time range options to the new analytics event API. - Updated `DotAnalyticsService` to include a new method `getTotalEvents` for fetching total events from the new analytics event endpoint, supporting both predefined ranges and custom date queries. - Modified `withPageview` feature to utilize the new `getTotalEvents` method instead of the previous CubeJS query approach. - Added new types for handling analytics event responses, including `AnalyticsEventResponse`, `TotalEventsData`, and `TotalEventsByDayData`. - Updated utility functions to convert time range inputs to the new API format. These changes improve the analytics service by integrating with the new event API, enhancing data retrieval capabilities and overall performance. --- .../lib/constants/dot-analytics.constants.ts | 6 +++ .../src/lib/services/dot-analytics.service.ts | 53 +++++++++++++++++-- .../store/features/with-pageview.feature.ts | 20 +++---- .../src/lib/types/entities.types.ts | 29 +++++++++- .../utils/data/analytics-data.utils.spec.ts | 14 ++--- .../lib/utils/data/analytics-data.utils.ts | 33 +++++++++++- 6 files changed, 133 insertions(+), 22 deletions(-) diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/constants/dot-analytics.constants.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/constants/dot-analytics.constants.ts index cf9752eb3e33..21fe4a9a5409 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/constants/dot-analytics.constants.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/constants/dot-analytics.constants.ts @@ -10,6 +10,12 @@ export const TIME_RANGE_CUBEJS_MAPPING = { last30days: 'from 30 days ago to now' } as const; +/** Maps internal time range options to the new analytics event API `range` param */ +export const TIME_RANGE_API_MAPPING: Record = { + [TIME_RANGE_OPTIONS.last7days]: 'last_7_days', + [TIME_RANGE_OPTIONS.last30days]: 'last_30_days' +} as const; + /** Maps time range options to comparison label days count */ export const TIME_RANGE_DAYS_MAP: Record = { [TIME_RANGE_OPTIONS.last7days]: 7, diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts index 25577507d157..ab2d45bedf67 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts @@ -1,13 +1,20 @@ import { Observable, of } from 'rxjs'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { catchError, map, shareReplay } from 'rxjs/operators'; -import { HealthStatusTypes } from '@dotcms/dotcms-models'; +import { DotCMSResponse, HealthStatusTypes } from '@dotcms/dotcms-models'; -import { AnalyticsApiResponse, CubeJSQuery } from '../../index'; +import { + AnalyticsApiResponse, + AnalyticsEventResponse, + CubeJSQuery, + TotalEventsByDayData, + TotalEventsData +} from '../../index'; +import { ApiRangeParams } from '../utils/data/analytics-data.utils'; /** * Generic analytics service for CubeJS queries and health checks. @@ -31,6 +38,7 @@ import { AnalyticsApiResponse, CubeJSQuery } from '../../index'; }) export class DotAnalyticsService { readonly #BASE_URL = '/api/v1/analytics/content/_query/cube'; + readonly #EVENT_URL = '/api/v1/analytics/event'; readonly #HEALTH_URL = '/api/v1/analytics/check'; readonly #http = inject(HttpClient); @@ -74,6 +82,45 @@ export class DotAnalyticsService { this.#healthCache$ = null; } + /** + * Fetches total events from the new analytics event endpoint. + * Supports predefined ranges (`?range=last_7_days`) or custom dates (`?from=...&to=...`). + * + * @param rangeParams - Object with either `range` or `from`+`to` query params + * @param granularity - Optional granularity (e.g. 'day', 'hour') + * @returns Observable of TotalEventsData (single object) or TotalEventsByDayData[] (array with granularity) + */ + getTotalEvents(rangeParams: ApiRangeParams): Observable; + getTotalEvents( + rangeParams: ApiRangeParams, + granularity: string + ): Observable; + getTotalEvents( + rangeParams: ApiRangeParams, + granularity?: string + ): Observable { + let params = new HttpParams(); + + if (rangeParams.range) { + params = params.set('range', rangeParams.range); + } + if (rangeParams.from) { + params = params.set('from', rangeParams.from); + } + if (rangeParams.to) { + params = params.set('to', rangeParams.to); + } + if (granularity) { + params = params.set('granularity', granularity); + } + + return this.#http + .get< + DotCMSResponse> + >(`${this.#EVENT_URL}/total-events`, { params }) + .pipe(map((response) => response.entity.data)); + } + /** * Executes a CubeJS query and returns the entity array. * diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts index 9c50e8506a2e..b48ef56d15f2 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts @@ -22,6 +22,7 @@ import { PageViewTimeLineEntity, RequestState, TimeRangeInput, + TotalEventsData, TopPagePerformanceEntity, TopPerformanceTableEntity, TotalPageViewsEntity, @@ -32,6 +33,7 @@ import { createEmptyAnalyticsEntity, createInitialRequestState, fillMissingDates, + toApiRangeParams, toTimeRangeCubeJS } from '../../utils/data/analytics-data.utils'; @@ -92,17 +94,15 @@ export function withPageview() { } }) ), - switchMap(({ timeRange, currentSiteId }) => { - const query = createCubeQuery() - .fromCube('EventSummary') - .pageviews() - .measures(['totalEvents']) - .siteId(currentSiteId) - .timeRange('day', toTimeRangeCubeJS(timeRange)) - .build(); + switchMap(({ timeRange }) => { + const rangeParams = toApiRangeParams(timeRange); - return analyticsService.cubeQuery(query).pipe( - map((entities) => entities[0]), + return analyticsService.getTotalEvents(rangeParams).pipe( + map( + (data: TotalEventsData): TotalPageViewsEntity => ({ + totalEvents: data.totalEvents + }) + ), tapResponse({ next: (data) => { patchState(store, { diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts index da7341ac3c19..b819095c6ee1 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts @@ -4,10 +4,35 @@ */ /** - * Total page views entity response + * Response wrapper for the new analytics event endpoints. + * Maps the `entity` field from DotCMSResponse which contains `data` + metadata. + */ +export interface AnalyticsEventResponse { + data: T; + params?: Record; + query?: Record; +} + +/** + * Total events data from `/api/v1/analytics/event/total-events` (no granularity). + */ +export interface TotalEventsData { + totalEvents: number; +} + +/** + * Total events data by day from `/api/v1/analytics/event/total-events?granularity=day`. + */ +export interface TotalEventsByDayData { + day: string; + totalEvents: number; +} + +/** + * Total page views entity response from the new analytics event API. */ export interface TotalPageViewsEntity { - 'EventSummary.totalEvents': string; + totalEvents: number; } /** diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts index a3b2c5130759..2c577bb2b355 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts @@ -64,7 +64,7 @@ describe('Analytics Data Utils', () => { describe('extractPageViews', () => { it('should extract page views from valid data', () => { const mockData: TotalPageViewsEntity = { - 'EventSummary.totalEvents': '1250' + totalEvents: 1250 }; const result = extractPageViews(mockData); @@ -76,16 +76,18 @@ describe('Analytics Data Utils', () => { expect(result).toBeNull(); }); - it('should return null when totalRequest is missing', () => { - const mockData: Partial = {}; + it('should return null when totalEvents is zero', () => { + const mockData: TotalPageViewsEntity = { + totalEvents: 0 + }; - const result = extractPageViews(mockData as TotalPageViewsEntity); + const result = extractPageViews(mockData); expect(result).toBeNull(); }); - it('should handle string numbers correctly', () => { + it('should handle numeric values correctly', () => { const mockData: TotalPageViewsEntity = { - 'EventSummary.totalEvents': '5000' + totalEvents: 5000 }; const result = extractPageViews(mockData); diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts index bfe506d89c5e..332f514a87d1 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts @@ -15,6 +15,7 @@ import { ComponentStatus } from '@dotcms/dotcms-models'; import { AnalyticsChartColors, BAR_CHART_STYLE, + TIME_RANGE_API_MAPPING, TIME_RANGE_CUBEJS_MAPPING, TIME_RANGE_OPTIONS } from '../../constants'; @@ -76,12 +77,42 @@ export function toTimeRangeCubeJS(timeRange: TimeRangeInput): TimeRangeCubeJS { ); } +/** + * Query params for the new analytics event API. + * Uses `range` for predefined ranges OR `from`+`to` for custom date ranges. + */ +export interface ApiRangeParams { + range?: string; + from?: string; + to?: string; +} + +/** + * Converts TimeRangeInput to the new analytics event API query params. + * For predefined ranges returns `{ range: 'last_7_days' }`. + * For custom date arrays returns `{ from: '2026-03-30', to: '2026-04-29' }`. + * + * @param timeRange - The time range input (predefined option or custom date array) + * @returns Object with either `range` or `from`+`to` params + */ +export function toApiRangeParams(timeRange: TimeRangeInput): ApiRangeParams { + if (Array.isArray(timeRange)) { + return { from: timeRange[0], to: timeRange[1] }; + } + + return { + range: + TIME_RANGE_API_MAPPING[timeRange as keyof typeof TIME_RANGE_API_MAPPING] || + TIME_RANGE_API_MAPPING[TIME_RANGE_OPTIONS.last7days] + }; +} + /** * Extracts page views count from TotalPageViewsEntity */ export const extractPageViews = (data: TotalPageViewsEntity | null): number | null => { if (!data) return null; - const value = Number(data['EventSummary.totalEvents'] ?? 0); + const value = data.totalEvents ?? 0; return value === 0 ? null : value; }; From 0d0a241ed05890f474bffbf7d108167b0277703a Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Wed, 29 Apr 2026 17:44:15 -0400 Subject: [PATCH 11/14] Enhance DotAnalyticsService with new analytics event API methods - Added methods `getUniqueVisitors` and `getTopContent` to `DotAnalyticsService` for fetching unique visitors and top content data from the new analytics event API. - Updated existing `getTotalEvents` method to utilize a new private method `#buildRangeParams` for cleaner parameter handling. - Introduced new types for the analytics event API, including `ApiGranularity`, `ApiRangeParams`, `UniqueVisitorsData`, and `TopContentData`. - Modified the `withPageview` feature to leverage the new API methods, improving data retrieval for unique visitors and top content. - Updated utility functions to support the new data structures and ensure compatibility with the new API. These changes enhance the analytics service by integrating additional data retrieval capabilities from the new event API, improving overall functionality and performance. --- .../src/lib/services/dot-analytics.service.ts | 84 ++++- .../store/features/with-pageview.feature.ts | 82 ++--- .../src/lib/types/analytics-api.types.ts | 50 +++ .../src/lib/types/entities.types.ts | 46 +-- .../data-access/src/lib/types/index.ts | 3 + .../utils/data/analytics-data.utils.spec.ts | 329 ++++-------------- .../lib/utils/data/analytics-data.utils.ts | 68 +++- 7 files changed, 276 insertions(+), 386 deletions(-) create mode 100644 core-web/libs/portlets/dot-analytics/data-access/src/lib/types/analytics-api.types.ts diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts index ab2d45bedf67..eb2b48a488a3 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts @@ -10,11 +10,15 @@ import { DotCMSResponse, HealthStatusTypes } from '@dotcms/dotcms-models'; import { AnalyticsApiResponse, AnalyticsEventResponse, + ApiGranularity, + ApiRangeParams, CubeJSQuery, + TopContentData, TotalEventsByDayData, - TotalEventsData + TotalEventsData, + UniqueVisitorsByDayData, + UniqueVisitorsData } from '../../index'; -import { ApiRangeParams } from '../utils/data/analytics-data.utils'; /** * Generic analytics service for CubeJS queries and health checks. @@ -93,23 +97,13 @@ export class DotAnalyticsService { getTotalEvents(rangeParams: ApiRangeParams): Observable; getTotalEvents( rangeParams: ApiRangeParams, - granularity: string + granularity: ApiGranularity ): Observable; getTotalEvents( rangeParams: ApiRangeParams, - granularity?: string + granularity?: ApiGranularity ): Observable { - let params = new HttpParams(); - - if (rangeParams.range) { - params = params.set('range', rangeParams.range); - } - if (rangeParams.from) { - params = params.set('from', rangeParams.from); - } - if (rangeParams.to) { - params = params.set('to', rangeParams.to); - } + let params = this.#buildRangeParams(rangeParams); if (granularity) { params = params.set('granularity', granularity); } @@ -121,6 +115,66 @@ export class DotAnalyticsService { .pipe(map((response) => response.entity.data)); } + /** + * Fetches unique visitors from the new analytics event endpoint. + * Supports predefined ranges (`?range=last_7_days`) or custom dates (`?from=...&to=...`). + * + * @param rangeParams - Object with either `range` or `from`+`to` query params + * @param granularity - Optional granularity (e.g. 'day', 'hour') + * @returns Observable of UniqueVisitorsData (single object) or UniqueVisitorsByDayData[] (array with granularity) + */ + getUniqueVisitors(rangeParams: ApiRangeParams): Observable; + getUniqueVisitors( + rangeParams: ApiRangeParams, + granularity: ApiGranularity + ): Observable; + getUniqueVisitors( + rangeParams: ApiRangeParams, + granularity?: ApiGranularity + ): Observable { + let params = this.#buildRangeParams(rangeParams); + if (granularity) { + params = params.set('granularity', granularity); + } + + return this.#http + .get< + DotCMSResponse< + AnalyticsEventResponse + > + >(`${this.#EVENT_URL}/unique-visitors`, { params }) + .pipe(map((response) => response.entity.data)); + } + + /** + * Fetches top content from the new analytics event endpoint. + * Returns an array of content items ordered by total events descending. + * + * @param rangeParams - Object with either `range` or `from`+`to` query params + * @returns Observable of TopContentData[] + */ + getTopContent(rangeParams: ApiRangeParams): Observable { + const params = this.#buildRangeParams(rangeParams); + + return this.#http + .get< + DotCMSResponse> + >(`${this.#EVENT_URL}/top-content`, { params }) + .pipe(map((response) => response.entity.data)); + } + + #buildRangeParams(rangeParams: ApiRangeParams): HttpParams { + let params = new HttpParams(); + if ('range' in rangeParams) { + params = params.set('range', rangeParams.range); + } else { + params = params.set('from', rangeParams.from); + params = params.set('to', rangeParams.to); + } + + return params; + } + /** * Executes a CubeJS query and returns the entity array. * diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts index b48ef56d15f2..31db72a053e0 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts @@ -1,6 +1,7 @@ import { tapResponse } from '@ngrx/operators'; import { patchState, signalStoreFeature, type, withMethods, withState } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { format } from 'date-fns'; import { pipe } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; @@ -17,22 +18,22 @@ import { FiltersState } from './with-filters.feature'; import { DotAnalyticsService } from '../../services/dot-analytics.service'; import { DEFAULT_COUNT_LIMIT, - DEFAULT_GRANULARITY, PageViewDeviceBrowsersEntity, - PageViewTimeLineEntity, RequestState, TimeRangeInput, + TopContentData, + TotalEventsByDayData, TotalEventsData, TopPagePerformanceEntity, TopPerformanceTableEntity, TotalPageViewsEntity, + UniqueVisitorsData, UniqueVisitorsEntity } from '../../types'; import { createCubeQuery } from '../../utils/cube/cube-query-builder.util'; import { - createEmptyAnalyticsEntity, createInitialRequestState, - fillMissingDates, + fillMissingApiDates, toApiRangeParams, toTimeRangeCubeJS } from '../../utils/data/analytics-data.utils'; @@ -45,7 +46,7 @@ export interface PageviewState { totalPageViews: RequestState; uniqueVisitors: RequestState; topPagePerformance: RequestState; - pageViewTimeLine: RequestState; + pageViewTimeLine: RequestState; pageViewDeviceBrowsers: RequestState; topPagesTable: RequestState; } @@ -145,17 +146,15 @@ export function withPageview() { } }) ), - switchMap(({ timeRange, currentSiteId }) => { - const query = createCubeQuery() - .fromCube('EventSummary') - .pageviews() - .measures(['uniqueVisitors']) - .siteId(currentSiteId) - .timeRange('day', toTimeRangeCubeJS(timeRange)) - .build(); + switchMap(({ timeRange }) => { + const rangeParams = toApiRangeParams(timeRange); - return analyticsService.cubeQuery(query).pipe( - map((entities) => entities[0]), + return analyticsService.getUniqueVisitors(rangeParams).pipe( + map( + (data: UniqueVisitorsData): UniqueVisitorsEntity => ({ + uniqueVisitors: data.uniqueVisitors + }) + ), tapResponse({ next: (data) => { patchState(store, { @@ -201,20 +200,23 @@ export function withPageview() { } }) ), - switchMap(({ timeRange, currentSiteId }) => { - const query = createCubeQuery() - .fromCube('EventSummary') - .pageviews() - .dimensions(['identifier', 'title']) - .measures(['totalEvents']) - .siteId(currentSiteId) - .orderBy('totalEvents', 'desc') - .timeRange('day', toTimeRangeCubeJS(timeRange)) - .limit(1) - .build(); + switchMap(({ timeRange }) => { + const rangeParams = toApiRangeParams(timeRange); + + return analyticsService.getTopContent(rangeParams).pipe( + map((items: TopContentData[]): TopPagePerformanceEntity | null => { + if (!items?.length) { + return null; + } + + const top = items[0]; - return analyticsService.cubeQuery(query).pipe( - map((entities) => entities[0]), + return { + identifier: top.identifier, + title: top.title, + totalEvents: top.totalEvents + }; + }), tapResponse({ next: (data) => { patchState(store, { @@ -260,23 +262,15 @@ export function withPageview() { } }) ), - switchMap(({ timeRange, currentSiteId }) => { - const query = createCubeQuery() - .fromCube('EventSummary') - .pageviews() - .measures(['totalEvents']) - .siteId(currentSiteId) - .timeRange('day', toTimeRangeCubeJS(timeRange), DEFAULT_GRANULARITY) - .build(); + switchMap(({ timeRange }) => { + const rangeParams = toApiRangeParams(timeRange); - return analyticsService.cubeQuery(query).pipe( - map((entities) => - fillMissingDates( - entities, - timeRange, - DEFAULT_GRANULARITY, - createEmptyAnalyticsEntity - ) + return analyticsService.getTotalEvents(rangeParams, 'day').pipe( + map((items) => + fillMissingApiDates(items, timeRange, 'day', (date) => ({ + day: format(date, 'yyyy-MM-dd'), + totalEvents: 0 + })) ), tapResponse({ next: (data) => { diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/analytics-api.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/analytics-api.types.ts new file mode 100644 index 000000000000..9541ea0e7cd5 --- /dev/null +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/analytics-api.types.ts @@ -0,0 +1,50 @@ +/** + * Types for the new Analytics Event API (microservice). + * Separate from CubeJS types which will be removed in the future. + */ + +/** Granularity options for the new analytics event API */ +export type ApiGranularity = 'hour' | 'day' | 'week' | 'month'; + +/** + * Query params for the analytics event API. + * Either a predefined `range` OR both `from` + `to` (never partial). + * The API returns 400 if only one of from/to is provided. + */ +export type ApiRangeParams = { range: string } | { from: string; to: string }; + +/** Response wrapper from analytics event endpoints (entity.data field) */ +export interface AnalyticsEventResponse { + data: T; + params?: Record; + query?: Record; +} + +/** Total events (no granularity) */ +export interface TotalEventsData { + totalEvents: number; +} + +/** Total events by day (with granularity) */ +export interface TotalEventsByDayData { + day: string; + totalEvents: number; +} + +/** Unique visitors (no granularity) */ +export interface UniqueVisitorsData { + uniqueVisitors: number; +} + +/** Unique visitors by day (with granularity) */ +export interface UniqueVisitorsByDayData { + day: string; + uniqueVisitors: number; +} + +/** Top content item from /api/v1/analytics/event/top-content */ +export interface TopContentData { + identifier: string; + title: string; + totalEvents: number; +} diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts index b819095c6ee1..cdff2abb5910 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts @@ -3,31 +3,6 @@ * TODO: Move dashboard specific types here (e.g. Engagement types) */ -/** - * Response wrapper for the new analytics event endpoints. - * Maps the `entity` field from DotCMSResponse which contains `data` + metadata. - */ -export interface AnalyticsEventResponse { - data: T; - params?: Record; - query?: Record; -} - -/** - * Total events data from `/api/v1/analytics/event/total-events` (no granularity). - */ -export interface TotalEventsData { - totalEvents: number; -} - -/** - * Total events data by day from `/api/v1/analytics/event/total-events?granularity=day`. - */ -export interface TotalEventsByDayData { - day: string; - totalEvents: number; -} - /** * Total page views entity response from the new analytics event API. */ @@ -36,19 +11,19 @@ export interface TotalPageViewsEntity { } /** - * Unique visitors entity response + * Unique visitors entity response from the new analytics event API. */ export interface UniqueVisitorsEntity { - 'EventSummary.uniqueVisitors': string; + uniqueVisitors: number; } /** - * Top page performance entity response + * Top page performance entity response from the new analytics event API. */ export interface TopPagePerformanceEntity { - 'EventSummary.totalEvents': string; - 'EventSummary.title': string; - 'EventSummary.identifier': string; + identifier: string; + title: string; + totalEvents: number; } /** @@ -60,15 +35,6 @@ export interface TopPerformanceTableEntity { 'EventSummary.identifier': string; } -/** - * Page view timeline entity response - */ -export interface PageViewTimeLineEntity { - 'EventSummary.totalEvents': string; - 'EventSummary.day': string; - 'EventSummary.day.day': string; -} - /** * Page view device browsers entity response */ diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/index.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/index.ts index 5242aefd2687..5d7f12f22264 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/index.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/index.ts @@ -10,6 +10,9 @@ export * from './common.types'; // CubeJS query types export * from './cubequery.types'; +// New Analytics Event API types (microservice) +export * from './analytics-api.types'; + // API entity types export * from './engagement.types'; export * from './entities.types'; diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts index 2c577bb2b355..85bd42e49990 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts @@ -1,4 +1,4 @@ -import { addHours, endOfDay, format, startOfDay } from 'date-fns'; +import { format } from 'date-fns'; import { ComponentStatus } from '@dotcms/dotcms-models'; @@ -25,11 +25,11 @@ import { Granularity } from '../../types'; // eslint-disable-next-line no-duplicate-imports import type { PageViewDeviceBrowsersEntity, - PageViewTimeLineEntity, TablePageData, TopPagePerformanceEntity, TopPerformanceTableEntity, TotalConversionsEntity, + TotalEventsByDayData, TotalPageViewsEntity, UniqueVisitorsEntity } from '../../types'; @@ -98,7 +98,7 @@ describe('Analytics Data Utils', () => { describe('extractSessions', () => { it('should extract sessions from valid data', () => { const mockData: UniqueVisitorsEntity = { - 'EventSummary.uniqueVisitors': '342' + uniqueVisitors: 342 }; const result = extractSessions(mockData); @@ -110,20 +110,22 @@ describe('Analytics Data Utils', () => { expect(result).toBeNull(); }); - it('should return NaN when totalUsers is missing', () => { - const mockData: Partial = {}; + it('should return null when uniqueVisitors is zero', () => { + const mockData: UniqueVisitorsEntity = { + uniqueVisitors: 0 + }; const result = extractSessions(mockData as UniqueVisitorsEntity); - expect(result).toBeNaN(); + expect(result).toBeNull(); }); }); describe('extractTopPageValue', () => { it('should extract top page value from valid data', () => { const mockData: TopPagePerformanceEntity = { - 'EventSummary.totalEvents': '890', - 'EventSummary.title': 'Home Page', - 'EventSummary.identifier': '/home' + totalEvents: 890, + title: 'Home Page', + identifier: '/home' }; const result = extractTopPageValue(mockData); @@ -135,23 +137,24 @@ describe('Analytics Data Utils', () => { expect(result).toBeNull(); }); - it('should return NaN when totalRequest is missing', () => { - const mockData: Partial = { - 'EventSummary.title': 'Home Page', - 'EventSummary.identifier': '/home' + it('should return null when totalEvents is zero', () => { + const mockData: TopPagePerformanceEntity = { + totalEvents: 0, + title: 'Home Page', + identifier: '/home' }; - const result = extractTopPageValue(mockData as TopPagePerformanceEntity); - expect(result).toBeNaN(); + const result = extractTopPageValue(mockData); + expect(result).toBeNull(); }); }); describe('extractPageTitle', () => { it('should extract page title from valid data', () => { const mockData: TopPagePerformanceEntity = { - 'EventSummary.totalEvents': '100', - 'EventSummary.title': 'Home Page', - 'EventSummary.identifier': '/home' + totalEvents: 100, + title: 'Home Page', + identifier: '/home' }; const result = extractPageTitle(mockData); @@ -163,21 +166,21 @@ describe('Analytics Data Utils', () => { expect(result).toBe('analytics.metrics.pageTitle.not-available'); }); - it('should return default message when pageTitle is missing', () => { + it('should return default message when title is missing', () => { const mockData: Partial = { - 'EventSummary.totalEvents': '100', - 'EventSummary.identifier': '/home' + totalEvents: 100, + identifier: '/home' }; const result = extractPageTitle(mockData as TopPagePerformanceEntity); expect(result).toBe('analytics.metrics.pageTitle.not-available'); }); - it('should return default message when pageTitle is empty', () => { + it('should return default message when title is empty', () => { const mockData: TopPagePerformanceEntity = { - 'EventSummary.totalEvents': '100', - 'EventSummary.title': '', - 'EventSummary.identifier': '/home' + totalEvents: 100, + title: '', + identifier: '/home' }; const result = extractPageTitle(mockData); @@ -343,17 +346,9 @@ describe('Analytics Data Utils', () => { describe('transformPageViewTimeLineData', () => { it('should transform valid timeline data correctly', () => { - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2023-12-01T00:00:00Z', - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2023-12-02T00:00:00Z', - 'EventSummary.day.day': '2023-12-02', - 'EventSummary.totalEvents': '150' - } + const mockData: TotalEventsByDayData[] = [ + { day: '2023-12-01', totalEvents: 100 }, + { day: '2023-12-02', totalEvents: 150 } ]; const result = transformPageViewTimeLineData(mockData); @@ -387,284 +382,80 @@ describe('Analytics Data Utils', () => { }); it('should sort data by date correctly', () => { - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2023-12-03T00:00:00Z', - 'EventSummary.day.day': '2023-12-03', - 'EventSummary.totalEvents': '200' - }, - { - 'EventSummary.day': '2023-12-01T00:00:00Z', - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2023-12-02T00:00:00Z', - 'EventSummary.day.day': '2023-12-02', - 'EventSummary.totalEvents': '150' - } + const mockData: TotalEventsByDayData[] = [ + { day: '2023-12-03', totalEvents: 200 }, + { day: '2023-12-01', totalEvents: 100 }, + { day: '2023-12-02', totalEvents: 150 } ]; const result = transformPageViewTimeLineData(mockData); - // Should be sorted chronologically expect(result.datasets[0].data).toEqual([100, 150, 200]); }); - it('should handle missing totalRequest fields', () => { - const mockData: Partial[] = [ - { - 'EventSummary.day': '2023-12-01T00:00:00Z', - 'EventSummary.day.day': '2023-12-01' - } - ]; + it('should handle zero totalEvents', () => { + const mockData: TotalEventsByDayData[] = [{ day: '2023-12-01', totalEvents: 0 }]; - const result = transformPageViewTimeLineData(mockData as PageViewTimeLineEntity[]); + const result = transformPageViewTimeLineData(mockData); expect(result.datasets[0].data).toEqual([0]); }); describe('Date and Time Formatting', () => { - it('should format labels as hours when all data is from the same day', () => { - // Use local dates to ensure same day detection works properly - const baseDate = new Date('2023-12-01T12:00:00'); // Local time, midday - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': new Date( - baseDate.getTime() - 3 * 60 * 60 * 1000 - ).toISOString(), // 9 AM - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': new Date( - baseDate.getTime() + 2 * 60 * 60 * 1000 - ).toISOString(), // 2 PM - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '150' - }, - { - 'EventSummary.day': new Date( - baseDate.getTime() + 6 * 60 * 60 * 1000 - ).toISOString(), // 6 PM - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '200' - } - ]; - - const result = transformPageViewTimeLineData(mockData); - - // Should format as hours (HH:mm format) when all data is from same day - expect(result.labels).toHaveLength(3); - // Check that labels contain time format with HH:mm (24-hour format) - result.labels?.forEach((label) => { - expect(typeof label).toBe('string'); - expect(label as string).toMatch(/^\d{1,2}:\d{2}$/); - }); - }); - it('should format labels as short date when data spans multiple days', () => { - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2023-12-01T12:00:00', - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2023-12-02T12:00:00', - 'EventSummary.day.day': '2023-12-02', - 'EventSummary.totalEvents': '150' - }, - { - 'EventSummary.day': '2023-12-03T12:00:00', - 'EventSummary.day.day': '2023-12-03', - 'EventSummary.totalEvents': '200' - } + const mockData: TotalEventsByDayData[] = [ + { day: '2023-12-01', totalEvents: 100 }, + { day: '2023-12-02', totalEvents: 150 }, + { day: '2023-12-03', totalEvents: 200 } ]; const result = transformPageViewTimeLineData(mockData); - // Should format as day + month when data spans multiple days expect(result.labels).toHaveLength(3); - // Check that labels contain date format (MMM dd) - result.labels?.forEach((label) => { - expect(typeof label).toBe('string'); - expect(label as string).toMatch(/^[A-Za-z]{3}\s+\d{1,2}$/); - }); - }); - - it('should handle same day detection correctly for edge cases', () => { - // Test data with same date but different times - use local time - const baseDate = new Date('2023-12-01T12:00:00'); - - const sameDayData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': startOfDay(baseDate).toISOString(), // startOfDay - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '50' - }, - { - 'EventSummary.day': endOfDay(baseDate).toISOString(), // endOfDay - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '75' - } - ]; - - const result = transformPageViewTimeLineData(sameDayData); - - // Should still format as hours since it's the same day - expect(result.labels).toHaveLength(2); result.labels?.forEach((label) => { expect(typeof label).toBe('string'); - expect(label as string).toMatch(/^\d{1,2}:\d{2}$/); - }); - }); - - it('should handle data spanning just two different days', () => { - // Use dates that will definitely be different days even after timezone conversion - const twoDayData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2023-12-01T12:00:00.000', // Noon UTC - safe for most timezones - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2023-12-03T12:00:00.000', // Two days later at noon UTC - 'EventSummary.day.day': '2023-12-03', - 'EventSummary.totalEvents': '120' - } - ]; - - const result = transformPageViewTimeLineData(twoDayData); - - // Should format as dates since data spans multiple days in any timezone - expect(result.labels).toHaveLength(2); - result.labels?.forEach((label) => { - expect(typeof label).toBe('string'); - // Should use date format (MMM dd) expect(label as string).toMatch(/^[A-Za-z]{3}\s+\d{1,2}$/); }); }); - it('should maintain chronological order when formatting hours', () => { - const baseDate = startOfDay(new Date('2023-12-01T12:00:00')); - const unorderedSameDayData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': addHours(baseDate, 7).toISOString(), // 7am - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '200' - }, - { - 'EventSummary.day': addHours(baseDate, 1).toISOString(), // 1am - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': addHours(baseDate, 13).toISOString(), // 3pm - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '150' - } - ]; - - const result = transformPageViewTimeLineData(unorderedSameDayData); - - // Should be sorted chronologically: 1am, 7am, 3pm - expect(result.datasets[0].data).toEqual([100, 200, 150]); - expect(result.labels).toHaveLength(3); - - // Verify hour format is used (HH:mm) - result.labels?.forEach((label) => { - expect(typeof label).toBe('string'); - expect(label as string).toMatch(/^\d{1,2}:\d{2}$/); - }); - }); - - it('should convert UTC dates to user local timezone for labels', () => { - // Mock UTC dates in the format that comes from the endpoint (without Z) - // These should be converted to user's local timezone - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2023-12-01T14:00:00.000', // 2 PM UTC (from endpoint format) - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2023-12-01T18:30:00.000', // 6:30 PM UTC (from endpoint format) - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '150' - } + it('should format labels as hours when all data is from the same day', () => { + const mockData: TotalEventsByDayData[] = [ + { day: '2023-12-01', totalEvents: 100 } ]; const result = transformPageViewTimeLineData(mockData); - // The dates should be formatted using user's locale and timezone - // We can't predict the exact output since it depends on user's timezone, - // but we can verify the format is correct for local time - expect(result.labels).toHaveLength(2); - expect(result.datasets[0].data).toEqual([100, 150]); - - // Check that labels are formatted as local time (HH:mm format) - result.labels?.forEach((label) => { - expect(typeof label).toBe('string'); - expect(label as string).toMatch(/^\d{1,2}:\d{2}$/); - }); + expect(result.labels).toHaveLength(1); + expect(result.datasets[0].data).toEqual([100]); }); - it('should handle dates across different days in local timezone', () => { - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2023-12-01T22:00:00.000', // 10 PM UTC (endpoint format) - 'EventSummary.day.day': '2023-12-01', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2023-12-02T02:00:00.000', // 2 AM UTC next day (endpoint format) - 'EventSummary.day.day': '2023-12-02', - 'EventSummary.totalEvents': '150' - } + it('should handle data spanning two different days', () => { + const mockData: TotalEventsByDayData[] = [ + { day: '2023-12-01', totalEvents: 100 }, + { day: '2023-12-03', totalEvents: 120 } ]; const result = transformPageViewTimeLineData(mockData); expect(result.labels).toHaveLength(2); - expect(result.datasets[0].data).toEqual([100, 150]); - - // Should have date format since they're different days in local time + expect(result.datasets[0].data).toEqual([100, 120]); result.labels?.forEach((label) => { expect(typeof label).toBe('string'); - // Either time format (HH:mm) or date format (MMM dd) depending on timezone - expect(label as string).toMatch( - /^(\d{1,2}:\d{2})|([A-Za-z]{3}\s+\d{1,2})$/ - ); + expect(label as string).toMatch(/^[A-Za-z]{3}\s+\d{1,2}$/); }); }); - it('should handle endpoint date format without Z suffix', () => { - // Test with the exact format that comes from the endpoint - const mockData: PageViewTimeLineEntity[] = [ - { - 'EventSummary.day': '2025-08-05T16:00:00.000', // Endpoint format (no Z) - 'EventSummary.day.day': '2025-08-05', - 'EventSummary.totalEvents': '100' - }, - { - 'EventSummary.day': '2025-08-05T17:00:00.000', // Endpoint format (no Z) - 'EventSummary.day.day': '2025-08-05', - 'EventSummary.totalEvents': '150' - } + it('should maintain chronological order', () => { + const mockData: TotalEventsByDayData[] = [ + { day: '2023-12-07', totalEvents: 200 }, + { day: '2023-12-01', totalEvents: 100 }, + { day: '2023-12-04', totalEvents: 150 } ]; const result = transformPageViewTimeLineData(mockData); - // Should parse correctly and convert to local timezone - expect(result.labels).toHaveLength(2); - expect(result.datasets[0].data).toEqual([100, 150]); - - // Should format as time (same day) - HH:mm format - result.labels?.forEach((label) => { - expect(typeof label).toBe('string'); - expect(label as string).toMatch(/^\d{1,2}:\d{2}$/); - }); + expect(result.datasets[0].data).toEqual([100, 150, 200]); + expect(result.labels).toHaveLength(3); }); }); }); diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts index 332f514a87d1..7982e71305e8 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts @@ -20,12 +20,13 @@ import { TIME_RANGE_OPTIONS } from '../../constants'; import { + ApiGranularity, + ApiRangeParams, ChartData, ChartDataset, ContentAttributionEntity, Granularity, PageViewDeviceBrowsersEntity, - PageViewTimeLineEntity, RequestState, TablePageData, TimeRangeCubeJS, @@ -33,6 +34,7 @@ import { TopPagePerformanceEntity, TopPerformanceTableEntity, TotalConversionsEntity, + TotalEventsByDayData, TotalPageViewsEntity, UniqueVisitorsEntity } from '../../types'; @@ -77,16 +79,6 @@ export function toTimeRangeCubeJS(timeRange: TimeRangeInput): TimeRangeCubeJS { ); } -/** - * Query params for the new analytics event API. - * Uses `range` for predefined ranges OR `from`+`to` for custom date ranges. - */ -export interface ApiRangeParams { - range?: string; - from?: string; - to?: string; -} - /** * Converts TimeRangeInput to the new analytics event API query params. * For predefined ranges returns `{ range: 'last_7_days' }`. @@ -122,7 +114,7 @@ export const extractPageViews = (data: TotalPageViewsEntity | null): number | nu */ export const extractSessions = (data: UniqueVisitorsEntity | null): number | null => { if (!data) return null; - const value = Number(data['EventSummary.uniqueVisitors']); + const value = data.uniqueVisitors ?? 0; return value === 0 ? null : value; }; @@ -132,7 +124,7 @@ export const extractSessions = (data: UniqueVisitorsEntity | null): number | nul */ export const extractTopPageValue = (data: TopPagePerformanceEntity | null): number | null => { if (!data) return null; - const value = Number(data['EventSummary.totalEvents']); + const value = data.totalEvents ?? 0; return value === 0 ? null : value; }; @@ -141,7 +133,7 @@ export const extractTopPageValue = (data: TopPagePerformanceEntity | null): numb * Extracts page title from TopPagePerformanceEntity */ export const extractPageTitle = (data: TopPagePerformanceEntity | null): string => - data?.['EventSummary.title'] || 'analytics.metrics.pageTitle.not-available'; + data?.title || 'analytics.metrics.pageTitle.not-available'; /** * Aggregates total conversions from an array of TotalConversionsEntity. @@ -186,9 +178,9 @@ export const transformTopPagesTableData = ( }; /** - * Transforms PageViewTimeLineEntity array to Chart.js compatible format + * Transforms TotalEventsByDayData array to Chart.js compatible format */ -export const transformPageViewTimeLineData = (data: PageViewTimeLineEntity[] | null): ChartData => { +export const transformPageViewTimeLineData = (data: TotalEventsByDayData[] | null): ChartData => { if (!data || !Array.isArray(data)) { return { labels: [], @@ -208,8 +200,8 @@ export const transformPageViewTimeLineData = (data: PageViewTimeLineEntity[] | n const transformedData = data .map((item) => ({ - date: new Date(item['EventSummary.day']), - value: Number(item['EventSummary.totalEvents'] || '0') + date: new Date(item.day), + value: item.totalEvents })) .sort((a, b) => a.date.getTime() - b.date.getTime()); @@ -653,6 +645,46 @@ export const fillMissingDates = ( return filledData; }; +/** Base type for new API timeline entities */ +type ApiTimelineEntity = { day: string }; + +/** + * Fills missing dates for new API timeline data (shape: { day: string, ... }). + * The API returns sparse data (only days with events), this fills gaps with zeros. + */ +export const fillMissingApiDates = ( + data: T[], + timeRange: TimeRangeInput, + granularity: ApiGranularity, + createEmptyEntity: (date: Date) => T +): T[] => { + if (!data || !Array.isArray(data)) { + return []; + } + + const [startDate, endDate] = getDateRange(timeRange); + + const dataMap = new Map(); + data.forEach((item) => { + dataMap.set(item.day, item); + }); + + const filledData: T[] = []; + let currentDate = startDate; + while (currentDate <= endDate) { + const currentDateKey = format(currentDate, 'yyyy-MM-dd'); + + if (dataMap.has(currentDateKey)) { + filledData.push(dataMap.get(currentDateKey)!); + } else { + filledData.push(createEmptyEntity(currentDate)); + } + currentDate = granularity === 'hour' ? addHours(currentDate, 1) : addDays(currentDate, 1); + } + + return filledData; +}; + /** * Get the date range for the given time range * @param timeRange - The time range to get the date range for From 860d0ed161d752ef7a9a968adee7503fb4e4bdc4 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Wed, 29 Apr 2026 17:45:03 -0400 Subject: [PATCH 12/14] Refactor fillMissingApiDates function for improved readability - Updated the `fillMissingApiDates` function in `analytics-data.utils.ts` to enhance code clarity. - Replaced direct access to `dataMap` with a variable `existing` to simplify the conditional check for existing data. - This change improves maintainability and readability of the code without altering its functionality. --- .../data-access/src/lib/utils/data/analytics-data.utils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts index 7982e71305e8..68cc6bbc5820 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts @@ -674,8 +674,9 @@ export const fillMissingApiDates = ( while (currentDate <= endDate) { const currentDateKey = format(currentDate, 'yyyy-MM-dd'); - if (dataMap.has(currentDateKey)) { - filledData.push(dataMap.get(currentDateKey)!); + const existing = dataMap.get(currentDateKey); + if (existing) { + filledData.push(existing); } else { filledData.push(createEmptyEntity(currentDate)); } From 3bf54152615202e8b8da7a307669e020672c214d Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Wed, 29 Apr 2026 17:53:14 -0400 Subject: [PATCH 13/14] Implement Device and Browser Analytics Feature - Added a new method `getPageviewsByDeviceBrowser` in `DotAnalyticsService` to fetch pageviews categorized by device and browser from the new analytics event API. - Introduced the `DeviceBrowserData` type to represent the structure of the data returned by the new API. - Updated the `withPageview` feature to utilize the new method, enhancing data retrieval for pageviews. - Refactored utility functions to transform the new data structure for chart representation, improving the analytics dashboard's functionality. These changes enhance the analytics service by integrating detailed device and browser analytics, providing better insights into user interactions. --- .../src/lib/services/dot-analytics.service.ts | 18 +++ .../store/features/with-pageview.feature.ts | 77 ++++++------ .../src/lib/types/analytics-api.types.ts | 7 ++ .../src/lib/types/entities.types.ts | 7 +- .../utils/data/analytics-data.utils.spec.ts | 111 ++++++------------ .../lib/utils/data/analytics-data.utils.ts | 75 +++--------- 6 files changed, 115 insertions(+), 180 deletions(-) diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts index eb2b48a488a3..886a00b741af 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/services/dot-analytics.service.ts @@ -13,6 +13,7 @@ import { ApiGranularity, ApiRangeParams, CubeJSQuery, + DeviceBrowserData, TopContentData, TotalEventsByDayData, TotalEventsData, @@ -163,6 +164,23 @@ export class DotAnalyticsService { .pipe(map((response) => response.entity.data)); } + /** + * Fetches pageviews by device and browser from the new analytics event endpoint. + * Returns an array of items with browser, device, and total count. + * + * @param rangeParams - Object with either `range` or `from`+`to` query params + * @returns Observable of DeviceBrowserData[] + */ + getPageviewsByDeviceBrowser(rangeParams: ApiRangeParams): Observable { + const params = this.#buildRangeParams(rangeParams); + + return this.#http + .get< + DotCMSResponse> + >(`${this.#EVENT_URL}/pageviews-by-device-browser`, { params }) + .pipe(map((response) => response.entity.data)); + } + #buildRangeParams(rangeParams: ApiRangeParams): HttpParams { let params = new HttpParams(); if ('range' in rangeParams) { diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts index 31db72a053e0..e17482da4b09 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts @@ -18,6 +18,7 @@ import { FiltersState } from './with-filters.feature'; import { DotAnalyticsService } from '../../services/dot-analytics.service'; import { DEFAULT_COUNT_LIMIT, + DeviceBrowserData, PageViewDeviceBrowsersEntity, RequestState, TimeRangeInput, @@ -316,47 +317,43 @@ export function withPageview() { } }) ), - switchMap(({ timeRange, currentSiteId }) => { - const query = createCubeQuery() - .fromCube('request') - .pageviews() - .dimensions(['userAgent']) - .measures(['count']) - .siteId(currentSiteId) - .orderBy('totalRequest', 'desc') - .timeRange('createdAt', toTimeRangeCubeJS(timeRange)) - .limit(DEFAULT_COUNT_LIMIT) - .build(); + switchMap(({ timeRange }) => { + const rangeParams = toApiRangeParams(timeRange); - return analyticsService - .cubeQuery(query) - .pipe( - tapResponse({ - next: (data) => { - patchState(store, { - pageViewDeviceBrowsers: { - status: ComponentStatus.LOADED, - data, - error: null - } - }); - }, - error: (error: HttpErrorResponse) => { - const errorMessage = - error.message || - dotMessageService.get( - 'analytics.error.loading.device-breakdown' - ); - patchState(store, { - pageViewDeviceBrowsers: { - status: ComponentStatus.ERROR, - data: null, - error: errorMessage - } - }); - } - }) - ); + return analyticsService.getPageviewsByDeviceBrowser(rangeParams).pipe( + map((items: DeviceBrowserData[]): PageViewDeviceBrowsersEntity[] => + items.map((item) => ({ + browser: item.browser, + device: item.device, + total: item.total + })) + ), + tapResponse({ + next: (data) => { + patchState(store, { + pageViewDeviceBrowsers: { + status: ComponentStatus.LOADED, + data, + error: null + } + }); + }, + error: (error: HttpErrorResponse) => { + const errorMessage = + error.message || + dotMessageService.get( + 'analytics.error.loading.device-breakdown' + ); + patchState(store, { + pageViewDeviceBrowsers: { + status: ComponentStatus.ERROR, + data: null, + error: errorMessage + } + }); + } + }) + ); }) ) ), diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/analytics-api.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/analytics-api.types.ts index 9541ea0e7cd5..e8dc3fed9177 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/analytics-api.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/analytics-api.types.ts @@ -48,3 +48,10 @@ export interface TopContentData { title: string; totalEvents: number; } + +/** Device browser item from /api/v1/analytics/event/pageviews-by-device-browser */ +export interface DeviceBrowserData { + browser: string; + device: string; + total: number; +} diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts index cdff2abb5910..78bf359ffff9 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts @@ -36,11 +36,12 @@ export interface TopPerformanceTableEntity { } /** - * Page view device browsers entity response + * Page view device browsers entity response from the new analytics event API. */ export interface PageViewDeviceBrowsersEntity { - 'request.count': string; - 'request.userAgent': string; + browser: string; + device: string; + total: number; } /** diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts index 85bd42e49990..e58f3d6f58d7 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts @@ -22,6 +22,8 @@ import { import { AnalyticsChartColors } from '../../constants'; import { Granularity } from '../../types'; +// eslint-disable-next-line no-duplicate-imports +import type { ConversionTrendEntity } from './analytics-data.utils'; // eslint-disable-next-line no-duplicate-imports import type { PageViewDeviceBrowsersEntity, @@ -463,16 +465,8 @@ describe('Analytics Data Utils', () => { describe('transformDeviceBrowsersData', () => { it('should transform valid device browsers data correctly', () => { const mockData: PageViewDeviceBrowsersEntity[] = [ - { - 'request.userAgent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'request.count': '500' - }, - { - 'request.userAgent': - 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1', - 'request.count': '300' - } + { browser: 'Chrome', device: 'Desktop', total: 500 }, + { browser: 'Safari', device: 'Mobile', total: 300 } ]; const result = transformDeviceBrowsersData(mockData); @@ -502,84 +496,47 @@ describe('Analytics Data Utils', () => { expect(result.datasets[0].data).toEqual([]); }); - it('should return "No Data" when no valid entries found', () => { + it('should format labels as "browser (device)"', () => { const mockData: PageViewDeviceBrowsersEntity[] = [ - { - 'request.userAgent': 'Some browser', - 'request.count': '0' - } + { browser: 'Chrome', device: 'Desktop', total: 500 }, + { browser: 'Safari', device: 'Mobile', total: 300 } ]; const result = transformDeviceBrowsersData(mockData); - expect(result.labels).toEqual(['No Data']); - expect(result.datasets[0].data).toEqual([1]); - expect(result.datasets[0].backgroundColor).toEqual([ - AnalyticsChartColors.neutral.line - ]); + expect(result.labels[0]).toBe('Chrome (Desktop)'); + expect(result.labels[1]).toBe('Safari (Mobile)'); }); - it('should group by browser and device type correctly', () => { + it('should sort results by total descending', () => { const mockData: PageViewDeviceBrowsersEntity[] = [ - { - 'request.userAgent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'request.count': '200' - }, - { - 'request.userAgent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'request.count': '300' - } + { browser: 'Safari', device: 'Mobile', total: 100 }, + { browser: 'Chrome', device: 'Desktop', total: 500 }, + { browser: 'Firefox', device: 'Desktop', total: 300 } ]; const result = transformDeviceBrowsersData(mockData); - // Should combine the two Chrome Desktop entries - expect(result.labels).toHaveLength(1); - expect(result.labels[0]).toContain('Chrome (Desktop)'); - expect(result.datasets[0].data).toEqual([500]); // 200 + 300 + expect(result.labels[0]).toBe('Chrome (Desktop)'); + expect(result.labels[1]).toBe('Firefox (Desktop)'); + expect(result.labels[2]).toBe('Safari (Mobile)'); + expect(result.datasets[0].data).toEqual([500, 300, 100]); }); - it('should sort results by usage descending', () => { - const mockData: PageViewDeviceBrowsersEntity[] = [ - { - 'request.userAgent': - 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1', - 'request.count': '100' // Less usage - }, - { - 'request.userAgent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'request.count': '500' // More usage - } - ]; + it('should limit results to top 10', () => { + const mockData: PageViewDeviceBrowsersEntity[] = Array.from( + { length: 15 }, + (_, i) => ({ + browser: `Browser${i}`, + device: 'Desktop', + total: 100 - i + }) + ); const result = transformDeviceBrowsersData(mockData); - // Chrome Desktop should come first (higher usage) - expect(result.labels[0]).toContain('Chrome (Desktop)'); - expect(result.labels[1]).toContain('Safari (Mobile)'); - expect(result.datasets[0].data).toEqual([500, 100]); - }); - - it('should handle invalid user agents gracefully', () => { - const mockData: Partial[] = [ - { - 'request.userAgent': '', - 'request.count': '100' - }, - { - 'request.count': '200' - } - ]; - - const result = transformDeviceBrowsersData( - mockData as PageViewDeviceBrowsersEntity[] - ); - - expect(result.labels).toEqual(['No Data']); - expect(result.datasets[0].data).toEqual([1]); + expect(result.labels).toHaveLength(10); + expect(result.datasets[0].data).toHaveLength(10); }); }); }); @@ -652,10 +609,10 @@ describe('Analytics Data Utils', () => { }); describe('fillMissingDates', () => { - describe('with PageViewTimeLineEntity', () => { + describe('with ConversionTrendEntity', () => { it('should return empty array when data is null', () => { const result = fillMissingDates( - null as unknown as PageViewTimeLineEntity[], + null as unknown as ConversionTrendEntity[], ['2024-01-01', '2024-01-03'], Granularity.DAY, createEmptyAnalyticsEntity @@ -666,7 +623,7 @@ describe('Analytics Data Utils', () => { it('should return empty array when data is not an array', () => { const result = fillMissingDates( - {} as unknown as PageViewTimeLineEntity[], + {} as unknown as ConversionTrendEntity[], ['2024-01-01', '2024-01-03'], Granularity.DAY, createEmptyAnalyticsEntity @@ -676,7 +633,7 @@ describe('Analytics Data Utils', () => { }); it('should fill all dates in range when data is empty', () => { - const result = fillMissingDates( + const result = fillMissingDates( [], ['2024-01-01', '2024-01-03'], Granularity.DAY, @@ -691,7 +648,7 @@ describe('Analytics Data Utils', () => { }); it('should return correct number of entries for date range', () => { - const result = fillMissingDates( + const result = fillMissingDates( [], ['2024-01-01', '2024-01-05'], Granularity.DAY, @@ -753,7 +710,7 @@ describe('Analytics Data Utils', () => { describe('createEmptyAnalyticsEntity', () => { it('should create entity with correct structure', () => { - const result = createEmptyAnalyticsEntity( + const result = createEmptyAnalyticsEntity( testDate, testDateKey ); diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts index 68cc6bbc5820..44b580632172 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts @@ -38,7 +38,6 @@ import { TotalPageViewsEntity, UniqueVisitorsEntity } from '../../types'; -import { parseUserAgent } from '../browser/userAgentParser'; /** * Time formats for different chart types @@ -460,7 +459,8 @@ export const transformContentConversionsData = ( }; /** - * Transforms PageViewDeviceBrowsersEntity array to pie chart ChartData format + * Transforms PageViewDeviceBrowsersEntity array to pie chart ChartData format. + * The new API returns browser and device already parsed, no user-agent parsing needed. */ export const transformDeviceBrowsersData = ( data: PageViewDeviceBrowsersEntity[] | null @@ -478,67 +478,22 @@ export const transformDeviceBrowsersData = ( }; } - // Group data by browser + device type combination - const browserDeviceGroups = new Map(); + const sorted = [...data].sort((a, b) => b.total - a.total).slice(0, 10); - data.forEach((item) => { - const userAgent = item['request.userAgent']; - const totalRequests = parseInt(item['request.count'] || '0', 10); - - if (userAgent && totalRequests > 0) { - const parsed = parseUserAgent(userAgent); - const browserName = parsed.browser.name; - const deviceType = parsed.device.type; - - // Create combined label: "Chrome (Mobile)", "Safari (Desktop)", etc. - // Note: Device labels are hardcoded as they go directly to chart library - const deviceLabel = - deviceType === 'mobile' ? 'Mobile' : deviceType === 'tablet' ? 'Tablet' : 'Desktop'; - const combinedLabel = `${browserName} (${deviceLabel})`; - - const currentTotal = browserDeviceGroups.get(combinedLabel) || 0; - browserDeviceGroups.set(combinedLabel, currentTotal + totalRequests); - } - }); - - // Convert map to arrays and sort by usage - const sortedBrowserDevices = Array.from(browserDeviceGroups.entries()) - .sort(([, a], [, b]) => b - a) - .slice(0, 10); // Increase limit to 10 for more device combinations - - if (sortedBrowserDevices.length === 0) { - return { - labels: ['No Data'], - datasets: [ - { - label: 'analytics.charts.device-breakdown.dataset-label', - data: [1], - backgroundColor: [AnalyticsChartColors.neutral.line] - } - ] - }; - } - - const labels = sortedBrowserDevices.map(([browserDevice]) => browserDevice); - const chartData = sortedBrowserDevices.map(([, count]) => count); + const labels = sorted.map((item) => `${item.browser} (${item.device})`); + const chartData = sorted.map((item) => item.total); - // Enhanced color palette for browser + device combinations const colorPalette = [ - AnalyticsChartColors.primary.line, // Chrome Desktop - Blue - '#1E40AF', // Chrome Mobile - Dark Blue - '#60A5FA', // Chrome Tablet - Light Blue - '#8B5CF6', // Safari Desktop - Purple - '#6D28D9', // Safari Mobile - Dark Purple - '#A78BFA', // Safari Tablet - Light Purple - AnalyticsChartColors.secondary.line, // Firefox Desktop - Green - '#047857', // Firefox Mobile - Dark Green - '#34D399', // Firefox Tablet - Light Green - '#F59E0B', // Edge Desktop - Orange - '#D97706', // Edge Mobile - Dark Orange - '#FBBF24', // Edge Tablet - Light Orange - '#EF4444', // Others Desktop - Red - '#DC2626', // Others Mobile - Dark Red - '#F87171' // Others Tablet - Light Red + AnalyticsChartColors.primary.line, + '#1E40AF', + '#60A5FA', + '#8B5CF6', + '#6D28D9', + '#A78BFA', + AnalyticsChartColors.secondary.line, + '#047857', + '#34D399', + '#F59E0B' ]; return { From 6a5b94db96983555cf8aaa2d2b7e39d6fff87112 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Wed, 29 Apr 2026 18:01:04 -0400 Subject: [PATCH 14/14] Refactor analytics data handling to integrate new TopContentData structure - Replaced the `TopPerformanceTableEntity` with `TopContentData` across various files to align with the new analytics event API. - Updated the `withPageview` feature to utilize the `getTopContent` method for fetching top content data, enhancing data retrieval. - Modified utility functions and tests to accommodate the new data structure, ensuring compatibility and improving overall functionality. - This refactor streamlines the analytics dashboard by providing a more consistent and efficient way to handle top page performance data. --- .../store/features/with-pageview.feature.ts | 77 ++++++++----------- .../src/lib/types/entities.types.ts | 9 --- .../utils/data/analytics-data.utils.spec.ts | 40 +++------- .../lib/utils/data/analytics-data.utils.ts | 14 ++-- ...nalytics-top-pages-table.component.spec.ts | 37 +++------ ...dot-analytics-top-pages-table.component.ts | 4 +- 6 files changed, 57 insertions(+), 124 deletions(-) diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts index e17482da4b09..7af9bad8f29b 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-pageview.feature.ts @@ -17,7 +17,6 @@ import { FiltersState } from './with-filters.feature'; import { DotAnalyticsService } from '../../services/dot-analytics.service'; import { - DEFAULT_COUNT_LIMIT, DeviceBrowserData, PageViewDeviceBrowsersEntity, RequestState, @@ -26,17 +25,14 @@ import { TotalEventsByDayData, TotalEventsData, TopPagePerformanceEntity, - TopPerformanceTableEntity, TotalPageViewsEntity, UniqueVisitorsData, UniqueVisitorsEntity } from '../../types'; -import { createCubeQuery } from '../../utils/cube/cube-query-builder.util'; import { createInitialRequestState, fillMissingApiDates, - toApiRangeParams, - toTimeRangeCubeJS + toApiRangeParams } from '../../utils/data/analytics-data.utils'; /** @@ -49,7 +45,7 @@ export interface PageviewState { topPagePerformance: RequestState; pageViewTimeLine: RequestState; pageViewDeviceBrowsers: RequestState; - topPagesTable: RequestState; + topPagesTable: RequestState; } /** @@ -370,47 +366,36 @@ export function withPageview() { } }) ), - switchMap(({ timeRange, currentSiteId }) => { - const query = createCubeQuery() - .fromCube('EventSummary') - .pageviews() - .dimensions(['identifier', 'title']) - .measures(['totalEvents']) - .siteId(currentSiteId) - .orderBy('totalEvents', 'desc') - .timeRange('day', toTimeRangeCubeJS(timeRange)) - .limit(DEFAULT_COUNT_LIMIT) - .build(); + switchMap(({ timeRange }) => { + const rangeParams = toApiRangeParams(timeRange); - return analyticsService - .cubeQuery(query) - .pipe( - tapResponse({ - next: (data) => { - patchState(store, { - topPagesTable: { - status: ComponentStatus.LOADED, - data, - error: null - } - }); - }, - error: (error: HttpErrorResponse) => { - const errorMessage = - error.message || - dotMessageService.get( - 'analytics.error.loading.top-pages-table' - ); - patchState(store, { - topPagesTable: { - status: ComponentStatus.ERROR, - data: null, - error: errorMessage - } - }); - } - }) - ); + return analyticsService.getTopContent(rangeParams).pipe( + tapResponse({ + next: (data) => { + patchState(store, { + topPagesTable: { + status: ComponentStatus.LOADED, + data, + error: null + } + }); + }, + error: (error: HttpErrorResponse) => { + const errorMessage = + error.message || + dotMessageService.get( + 'analytics.error.loading.top-pages-table' + ); + patchState(store, { + topPagesTable: { + status: ComponentStatus.ERROR, + data: null, + error: errorMessage + } + }); + } + }) + ); }) ) ) diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts index 78bf359ffff9..5aac1f398a73 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts @@ -26,15 +26,6 @@ export interface TopPagePerformanceEntity { totalEvents: number; } -/** - * Top performance table entity response - */ -export interface TopPerformanceTableEntity { - 'EventSummary.totalEvents': string; - 'EventSummary.title': string; - 'EventSummary.identifier': string; -} - /** * Page view device browsers entity response from the new analytics event API. */ diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts index e58f3d6f58d7..9c5f71001eb8 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts @@ -28,8 +28,8 @@ import type { ConversionTrendEntity } from './analytics-data.utils'; import type { PageViewDeviceBrowsersEntity, TablePageData, + TopContentData, TopPagePerformanceEntity, - TopPerformanceTableEntity, TotalConversionsEntity, TotalEventsByDayData, TotalPageViewsEntity, @@ -279,31 +279,15 @@ describe('Analytics Data Utils', () => { describe('Transformation Functions', () => { describe('transformTopPagesTableData', () => { it('should transform valid table data correctly', () => { - const mockData: TopPerformanceTableEntity[] = [ - { - 'EventSummary.title': 'Home Page', - 'EventSummary.identifier': '/home', - 'EventSummary.totalEvents': '1250' - }, - { - 'EventSummary.title': 'About Us', - 'EventSummary.identifier': '/about', - 'EventSummary.totalEvents': '890' - } + const mockData: TopContentData[] = [ + { title: 'Home Page', identifier: '/home', totalEvents: 1250 }, + { title: 'About Us', identifier: '/about', totalEvents: 890 } ]; const result = transformTopPagesTableData(mockData); const expected: TablePageData[] = [ - { - pageTitle: 'Home Page', - path: '/home', - views: 1250 - }, - { - pageTitle: 'About Us', - path: '/about', - views: 890 - } + { pageTitle: 'Home Page', path: '/home', views: 1250 }, + { pageTitle: 'About Us', path: '/about', views: 890 } ]; expect(result).toEqual(expected); @@ -315,20 +299,14 @@ describe('Analytics Data Utils', () => { }); it('should return empty array when data is not an array', () => { - const result = transformTopPagesTableData( - {} as unknown as TopPerformanceTableEntity[] - ); + const result = transformTopPagesTableData({} as unknown as TopContentData[]); expect(result).toEqual([]); }); it('should handle missing fields with defaults', () => { - const mockData: Partial[] = [ - { - 'EventSummary.totalEvents': '500' - } - ]; + const mockData: Partial[] = [{ totalEvents: 500 }]; - const result = transformTopPagesTableData(mockData as TopPerformanceTableEntity[]); + const result = transformTopPagesTableData(mockData as TopContentData[]); const expected: TablePageData[] = [ { pageTitle: 'analytics.table.data.not-available', diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts index 44b580632172..cf1b332f2965 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts @@ -31,8 +31,8 @@ import { TablePageData, TimeRangeCubeJS, TimeRangeInput, + TopContentData, TopPagePerformanceEntity, - TopPerformanceTableEntity, TotalConversionsEntity, TotalEventsByDayData, TotalPageViewsEntity, @@ -160,19 +160,17 @@ export const aggregateTotalConversions = ( }; /** - * Transforms TopPerformanceTableEntity array to table-friendly format + * Transforms TopContentData array to table-friendly format */ -export const transformTopPagesTableData = ( - data: TopPerformanceTableEntity[] | null -): TablePageData[] => { +export const transformTopPagesTableData = (data: TopContentData[] | null): TablePageData[] => { if (!data || !Array.isArray(data)) { return []; } return data.map((item) => ({ - pageTitle: item['EventSummary.title'] || 'analytics.table.data.not-available', - path: item['EventSummary.identifier'] || 'analytics.table.data.not-available', - views: Number(item['EventSummary.totalEvents']) || 0 + pageTitle: item.title || 'analytics.table.data.not-available', + path: item.identifier || 'analytics.table.data.not-available', + views: item.totalEvents })); }; diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.spec.ts index f330244078ab..ec6ccbf08c04 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.spec.ts @@ -6,38 +6,23 @@ import { TableModule } from 'primeng/table'; import { DotMessageService } from '@dotcms/data-access'; import { ComponentStatus } from '@dotcms/dotcms-models'; -import { - RequestState, - TopPerformanceTableEntity -} from '@dotcms/portlets/dot-analytics/data-access'; +import { RequestState, TopContentData } from '@dotcms/portlets/dot-analytics/data-access'; import { DotAnalyticsTopPagesTableComponent } from './dot-analytics-top-pages-table.component'; describe('DotAnalyticsTopPagesTableComponent', () => { let spectator: Spectator; - const mockTableData: TopPerformanceTableEntity[] = [ - { - 'EventSummary.title': 'Home Page', - 'EventSummary.identifier': '/home', - 'EventSummary.totalEvents': '1250' - }, - { - 'EventSummary.title': 'About Us', - 'EventSummary.identifier': '/about', - 'EventSummary.totalEvents': '890' - }, - { - 'EventSummary.title': 'Contact', - 'EventSummary.identifier': '/contact', - 'EventSummary.totalEvents': '567' - } + const mockTableData: TopContentData[] = [ + { title: 'Home Page', identifier: '/home', totalEvents: 1250 }, + { title: 'About Us', identifier: '/about', totalEvents: 890 }, + { title: 'Contact', identifier: '/contact', totalEvents: 567 } ]; const createMockTableState = ( - data: TopPerformanceTableEntity[] | null = mockTableData, + data: TopContentData[] | null = mockTableData, status: ComponentStatus = ComponentStatus.LOADED - ): RequestState => ({ + ): RequestState => ({ data, status, error: null @@ -90,12 +75,8 @@ describe('DotAnalyticsTopPagesTableComponent', () => { describe('Data Handling', () => { it('should handle data changes', () => { - const newData: TopPerformanceTableEntity[] = [ - { - 'EventSummary.title': 'New Page', - 'EventSummary.identifier': '/new', - 'EventSummary.totalEvents': '100' - } + const newData: TopContentData[] = [ + { title: 'New Page', identifier: '/new', totalEvents: 100 } ]; spectator.setInput('tableState', createMockTableState(newData, ComponentStatus.LOADED)); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.ts index 6f7c39979d21..438b505581c1 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.ts @@ -7,7 +7,7 @@ import { TableModule } from 'primeng/table'; import { ComponentStatus } from '@dotcms/dotcms-models'; import { RequestState, - TopPerformanceTableEntity, + TopContentData, transformTopPagesTableData } from '@dotcms/portlets/dot-analytics/data-access'; import { DotMessagePipe } from '@dotcms/ui'; @@ -46,7 +46,7 @@ const SKELETON_WIDTH_MAP = { }) export class DotAnalyticsTopPagesTableComponent { /** Complete table state from analytics store */ - readonly $tableState = input.required>({ + readonly $tableState = input.required>({ alias: 'tableState' });