From f4db71173b35ae540c3a1a44270666b873740ebe Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Mon, 16 Feb 2026 14:44:54 -0600 Subject: [PATCH 1/4] Add REST endpoint for headless Google Analytics queries Adds /api/v1/googleanalytics/query endpoint for headless/JavaScript clients. ## Changes - New REST resource at GoogleAnalyticsResource.java - POST /api/v1/googleanalytics/query endpoint - Accepts JSON request with propertyId, dates, metrics, dimensions, filters, sort, maxResults - Returns JSON-friendly response format (rows, dimensions, metrics, metadata) - Requires backend user authentication - Registers REST resource in Activator ## Example Request ```json { "propertyId": "123456789", "startDate": "2026-02-09", "endDate": "2026-02-16", "metrics": ["sessions", "activeUsers"], "dimensions": ["date"], "maxResults": 100 } ``` Version bumped to 0.5.0 (minor version for new feature). Co-Authored-By: Claude Sonnet 4.5 --- build.gradle | 3 +- .../google/analytics/osgi/Activator.java | 4 + .../rest/GoogleAnalyticsResource.java | 267 ++++++++++++++++++ 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java diff --git a/build.gradle b/build.gradle index f1b9b66..66af656 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { sourceCompatibility = JavaVersion.VERSION_11 -version = '0.4.1' +version = '0.5.0' repositories { @@ -36,6 +36,7 @@ dependencies { //compileOnly('org.apache.httpcomponents:httpclient:4.5.9') // https://mvnrepository.com/artifact/javax.servlet/servlet-api compileOnly group: 'javax.servlet', name: 'servlet-api', version: '2.5' + compileOnly group: 'javax.ws.rs', name: 'javax.ws.rs-api', version: '2.1.1' } diff --git a/src/main/java/com/dotcms/google/analytics/osgi/Activator.java b/src/main/java/com/dotcms/google/analytics/osgi/Activator.java index 120204c..3966018 100644 --- a/src/main/java/com/dotcms/google/analytics/osgi/Activator.java +++ b/src/main/java/com/dotcms/google/analytics/osgi/Activator.java @@ -1,6 +1,7 @@ package com.dotcms.google.analytics.osgi; import com.dotcms.google.analytics.app.AnalyticsAppService; +import com.dotcms.google.analytics.rest.GoogleAnalyticsResource; import com.dotcms.google.analytics.view.AnalyticsToolInfo; import com.dotmarketing.business.CacheLocator; import com.dotmarketing.loggers.Log4jUtil; @@ -45,6 +46,9 @@ public final void start(final BundleContext bundleContext) throws Exception { // copy the yaml copyAppYml(); + // Register REST resources + publishBundleServices(bundleContext); + // Register all ViewTool services registerViewToolService(bundleContext, new AnalyticsToolInfo()); diff --git a/src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java b/src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java new file mode 100644 index 0000000..2ed9835 --- /dev/null +++ b/src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java @@ -0,0 +1,267 @@ +package com.dotcms.google.analytics.rest; + +import com.dotcms.google.analytics.app.AnalyticsApp; +import com.dotcms.google.analytics.app.AnalyticsAppService; +import com.dotcms.google.analytics.model.AnalyticsRequest; +import com.dotcms.google.analytics.model.FilterRequest; +import com.dotcms.google.analytics.service.GoogleAnalyticsService; +import com.dotcms.rest.WebResource; +import com.dotmarketing.util.Logger; +import com.google.analytics.data.v1beta.RunReportResponse; +import com.liferay.portal.model.User; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * REST endpoint for querying Google Analytics data. + * + * @author dotCMS + */ +@Path("/v1/googleanalytics") +public class GoogleAnalyticsResource { + + private final WebResource webResource = new WebResource(); + private final AnalyticsAppService analyticsAppService = new AnalyticsAppService(); + + /** + * Query Google Analytics 4 data via REST API. + * + * Example request: + * POST /api/v1/googleanalytics/query + * { + * "propertyId": "123456789", + * "startDate": "2026-02-09", + * "endDate": "2026-02-16", + * "metrics": ["sessions", "activeUsers"], + * "dimensions": ["date"], + * "maxResults": 100 + * } + * + * @param request HTTP request + * @param response HTTP response + * @param queryRequest Analytics query parameters + * @return JSON response with analytics data + */ + @POST + @Path("/query") + @Produces(MediaType.APPLICATION_JSON) + public Response query( + @Context final HttpServletRequest request, + @Context final HttpServletResponse response, + final GoogleAnalyticsQueryRequest queryRequest) { + + try { + // Authenticate user + final User user = new WebResource.InitBuilder(webResource) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .requestAndResponse(request, response) + .rejectWhenNoUser(true) + .init() + .getUser(); + + Logger.debug(this, () -> "User authenticated: " + user.getEmailAddress()); + + // Validate request + if (queryRequest.getPropertyId() == null || queryRequest.getPropertyId().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "propertyId is required")) + .build(); + } + + // Get analytics app configuration for current site + final String siteId = request.getServerName(); // Or extract from request + final AnalyticsApp analyticsApp = analyticsAppService.getAnalyticsApp(siteId); + + // Create Google Analytics service + final GoogleAnalyticsService analyticsService = + new GoogleAnalyticsService(analyticsApp.getJsonKeyFile()); + + // Build analytics request + final AnalyticsRequest analyticsRequest = + new AnalyticsRequest(queryRequest.getPropertyId()); + + // Set date range + if (queryRequest.getStartDate() != null) { + analyticsRequest.setStartDate(queryRequest.getStartDate()); + } + if (queryRequest.getEndDate() != null) { + analyticsRequest.setEndDate(queryRequest.getEndDate()); + } + + // Set metrics + if (queryRequest.getMetrics() != null && !queryRequest.getMetrics().isEmpty()) { + analyticsRequest.setMetrics(String.join(",", queryRequest.getMetrics())); + } + + // Set dimensions + if (queryRequest.getDimensions() != null && !queryRequest.getDimensions().isEmpty()) { + analyticsRequest.setDimensions(String.join(",", queryRequest.getDimensions())); + } + + // Set filters + if (queryRequest.getFilters() != null) { + if (queryRequest.getFilters().getDimension() != null) { + for (FilterRequestDTO filter : queryRequest.getFilters().getDimension()) { + final FilterRequest filterRequest = new FilterRequest( + filter.getField(), + filter.getOperator(), + filter.getValue() + ); + analyticsRequest.getDimensionFilterList().add(filterRequest); + } + } + + if (queryRequest.getFilters().getMetric() != null) { + for (FilterRequestDTO filter : queryRequest.getFilters().getMetric()) { + final FilterRequest filterRequest = new FilterRequest( + filter.getField(), + filter.getOperator(), + filter.getValue() + ); + analyticsRequest.getMetricFilterList().add(filterRequest); + } + } + } + + // Set sort + if (queryRequest.getSort() != null) { + analyticsRequest.setSort(queryRequest.getSort()); + } + + // Set max results + if (queryRequest.getMaxResults() != null && queryRequest.getMaxResults() > 0) { + analyticsRequest.setMaxResults(queryRequest.getMaxResults()); + } + + // Execute query + final RunReportResponse gaResponse = analyticsService.query(analyticsRequest); + + // Convert to JSON-friendly format + final Map responseData = new HashMap<>(); + responseData.put("rowCount", gaResponse.getRowCount()); + + // Convert rows to simple structure + final List> rows = gaResponse.getRowsList().stream() + .map(row -> { + final Map rowData = new HashMap<>(); + + // Extract dimensions + final List dimensions = row.getDimensionValuesList().stream() + .map(dv -> dv.getValue()) + .collect(Collectors.toList()); + rowData.put("dimensions", dimensions); + + // Extract metrics + final List metrics = row.getMetricValuesList().stream() + .map(mv -> mv.getValue()) + .collect(Collectors.toList()); + rowData.put("metrics", metrics); + + return rowData; + }) + .collect(Collectors.toList()); + + responseData.put("rows", rows); + + // Add metadata + final Map metadata = new HashMap<>(); + if (gaResponse.getMetadata() != null) { + metadata.put("currencyCode", gaResponse.getMetadata().getCurrencyCode()); + metadata.put("timeZone", gaResponse.getMetadata().getTimeZone()); + } + responseData.put("metadata", metadata); + + return Response.ok(responseData).build(); + + } catch (Exception e) { + Logger.error(this, "Error querying Google Analytics", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", e.getMessage())) + .build(); + } + } + + /** + * Request DTO for Google Analytics query. + */ + public static class GoogleAnalyticsQueryRequest { + private String propertyId; + private String startDate; + private String endDate; + private List metrics; + private List dimensions; + private FiltersDTO filters; + private String sort; + private Integer maxResults; + + // Getters and setters + public String getPropertyId() { return propertyId; } + public void setPropertyId(String propertyId) { this.propertyId = propertyId; } + + public String getStartDate() { return startDate; } + public void setStartDate(String startDate) { this.startDate = startDate; } + + public String getEndDate() { return endDate; } + public void setEndDate(String endDate) { this.endDate = endDate; } + + public List getMetrics() { return metrics; } + public void setMetrics(List metrics) { this.metrics = metrics; } + + public List getDimensions() { return dimensions; } + public void setDimensions(List dimensions) { this.dimensions = dimensions; } + + public FiltersDTO getFilters() { return filters; } + public void setFilters(FiltersDTO filters) { this.filters = filters; } + + public String getSort() { return sort; } + public void setSort(String sort) { this.sort = sort; } + + public Integer getMaxResults() { return maxResults; } + public void setMaxResults(Integer maxResults) { this.maxResults = maxResults; } + } + + /** + * Filters container DTO. + */ + public static class FiltersDTO { + private List dimension; + private List metric; + + public List getDimension() { return dimension; } + public void setDimension(List dimension) { this.dimension = dimension; } + + public List getMetric() { return metric; } + public void setMetric(List metric) { this.metric = metric; } + } + + /** + * Filter DTO. + */ + public static class FilterRequestDTO { + private String field; + private String value; + private String operator; + + public String getField() { return field; } + public void setField(String field) { this.field = field; } + + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + + public String getOperator() { return operator; } + public void setOperator(String operator) { this.operator = operator; } + } +} From 8aa51b29d7594a00e9b6cf9d7d74e4d3e051c769 Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Mon, 16 Feb 2026 15:01:57 -0600 Subject: [PATCH 2/4] feat: flatten REST endpoint response with named fields Improved REST API response format from positional arrays to self-documenting named objects for better developer experience. **Before:** ```json { "rows": [ { "dimensions": ["2026-02-09", "/products"], "metrics": ["150", "120"] } ] } ``` **After:** ```json { "dimensions": ["date", "pagePath"], "metrics": ["sessions", "activeUsers"], "rows": [ { "date": "2026-02-09", "pagePath": "/products", "sessions": "150", "activeUsers": "120" } ] } ``` **Changes:** - Added dimension/metric name arrays to response metadata - Flattened row structure with named fields instead of positional arrays - Updated README with REST API usage examples - Added dimensions & metrics explainer section **Breaking change:** Response format changed, but acceptable since endpoint not yet released. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 92 ++++++++++++++++++- .../rest/GoogleAnalyticsResource.java | 44 ++++++--- 2 files changed, 116 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 5fa2d36..e21f0c1 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,58 @@ $gaRequest.setDimensions("date") ``` +### REST API Usage + +Query Google Analytics data via REST endpoint: + +```bash +curl -X POST http://localhost:8080/api/v1/googleanalytics/query \ + -H "Content-Type: application/json" \ + -u admin@dotcms.com:admin \ + -d '{ + "propertyId": "123456789", + "startDate": "2026-02-09", + "endDate": "2026-02-16", + "metrics": ["sessions", "activeUsers"], + "dimensions": ["date", "pagePath"], + "filters": { + "dimension": [ + {"field": "pagePath", "value": "/products", "operator": "CONTAINS"} + ] + }, + "sort": "sessions", + "maxResults": 100 + }' +``` + +**Response:** + +```json +{ + "rowCount": 7, + "dimensions": ["date", "pagePath"], + "metrics": ["sessions", "activeUsers"], + "rows": [ + { + "date": "20260209", + "pagePath": "/products", + "sessions": "150", + "activeUsers": "120" + }, + { + "date": "20260210", + "pagePath": "/home", + "sessions": "200", + "activeUsers": "180" + } + ], + "metadata": { + "currencyCode": "USD", + "timeZone": "America/New_York" + } +} +``` + ## Documentation For complete setup instructions including Google Cloud configuration, Google Analytics permissions, advanced usage, and troubleshooting: @@ -86,16 +138,46 @@ For complete setup instructions including Google Cloud configuration, Google Ana - **Troubleshooting** - OSGi issues, metric errors, variable name conflicts - **Available Metrics & Dimensions** - GA4 API schema reference -## Available Metrics +## Understanding Dimensions and Metrics + +When querying Google Analytics, you combine **dimensions** and **metrics** to get the data you need: + +### Dimensions (What to group by) -Common GA4 metrics you can query: -- `sessions` - Number of sessions +Dimensions are categorical attributes that describe your data—they answer "what are we breaking this down by?" + +Common dimensions: +- `date` - When the activity happened (e.g., "20260209") +- `pagePath` - Which page was viewed (e.g., "/products") +- `country` - Where users are located (e.g., "United States") +- `deviceCategory` - Device type (e.g., "desktop", "mobile", "tablet") +- `browser` - Browser used (e.g., "Chrome", "Safari") +- `city` - User's city (e.g., "New York") +- `source` - Traffic source (e.g., "google", "facebook", "direct") + +### Metrics (What to measure) + +Metrics are the numerical measurements you want to analyze: +- `sessions` - Number of sessions (visits) - `activeUsers` - Number of distinct users -- `screenPageViews` - Total page and screen views +- `screenPageViews` - Total page views - `bounceRate` - Percentage of single-page sessions - `averageSessionDuration` - Average session duration in seconds -See the [GA4 API Schema](https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema) for the full list. +### Example Query + +"Show me sessions and active users, broken down by date and page path" + +```json +{ + "dimensions": ["date", "pagePath"], + "metrics": ["sessions", "activeUsers"] +} +``` + +Each row in the response represents one unique combination of dimension values with its associated metrics. + +See the [GA4 API Schema](https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema) for the complete list of available dimensions and metrics. ## Version History diff --git a/src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java b/src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java index 2ed9835..745f0dc 100644 --- a/src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java +++ b/src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java @@ -7,6 +7,8 @@ import com.dotcms.google.analytics.service.GoogleAnalyticsService; import com.dotcms.rest.WebResource; import com.dotmarketing.util.Logger; +import com.google.analytics.data.v1beta.DimensionValue; +import com.google.analytics.data.v1beta.MetricValue; import com.google.analytics.data.v1beta.RunReportResponse; import com.liferay.portal.model.User; @@ -149,26 +151,38 @@ public Response query( // Execute query final RunReportResponse gaResponse = analyticsService.query(analyticsRequest); + // Capture field names for flattened response + final List dimensionNames = queryRequest.getDimensions(); + final List metricNames = queryRequest.getMetrics(); + // Convert to JSON-friendly format final Map responseData = new HashMap<>(); responseData.put("rowCount", gaResponse.getRowCount()); - // Convert rows to simple structure - final List> rows = gaResponse.getRowsList().stream() + // Add metadata with dimension and metric names + responseData.put("dimensions", dimensionNames != null ? dimensionNames : new ArrayList<>()); + responseData.put("metrics", metricNames != null ? metricNames : new ArrayList<>()); + + // Convert rows to flattened structure with named fields + final List> rows = gaResponse.getRowsList().stream() .map(row -> { - final Map rowData = new HashMap<>(); - - // Extract dimensions - final List dimensions = row.getDimensionValuesList().stream() - .map(dv -> dv.getValue()) - .collect(Collectors.toList()); - rowData.put("dimensions", dimensions); - - // Extract metrics - final List metrics = row.getMetricValuesList().stream() - .map(mv -> mv.getValue()) - .collect(Collectors.toList()); - rowData.put("metrics", metrics); + final Map rowData = new HashMap<>(); + + // Map dimension values to names + final List dimensionValues = row.getDimensionValuesList(); + if (dimensionNames != null) { + for (int i = 0; i < dimensionNames.size() && i < dimensionValues.size(); i++) { + rowData.put(dimensionNames.get(i), dimensionValues.get(i).getValue()); + } + } + + // Map metric values to names + final List metricValues = row.getMetricValuesList(); + if (metricNames != null) { + for (int i = 0; i < metricNames.size() && i < metricValues.size(); i++) { + rowData.put(metricNames.get(i), metricValues.get(i).getValue()); + } + } return rowData; }) From 4f77477a54c1495d466a53a626abf728292dfcc4 Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Thu, 19 Feb 2026 07:31:15 -0600 Subject: [PATCH 3/4] rename viewtool from $analytics to $googleanalytics Avoids potential naming conflicts with other analytics tools by using a more specific viewtool key. Co-Authored-By: Claude Opus 4.6 (1M context) --- CONTRIBUTING.md | 2 +- README.md | 6 +++--- .../com/dotcms/google/analytics/view/AnalyticsToolInfo.java | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 814b9d7..61b8d13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -136,7 +136,7 @@ Before submitting a PR, verify: - [ ] Plugin builds without errors: `./gradlew clean jar` - [ ] JAR uploads successfully to dotCMS - [ ] OSGi bundle starts without errors (check logs for "Starting Google Analytics OSGI plugin") -- [ ] Viewtool is available in Velocity (`$analytics`) +- [ ] Viewtool is available in Velocity (`$googleanalytics`) - [ ] Can create analytics request and query GA4 data - [ ] No breaking changes to existing Velocity code (or documented if necessary) - [ ] Works with dotCMS 23.01.10 and newer diff --git a/README.md b/README.md index e21f0c1..c7b7178 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ OSGi plugin that integrates Google Analytics 4 (GA4) Data API with dotCMS, enabl ## What It Does -This plugin provides a `$analytics` viewtool in Velocity templates for querying Google Analytics 4 data directly from your dotCMS pages. Retrieve metrics like sessions, active users, page views, and more—filtered by dimensions like date, page path, device category, etc. +This plugin provides a `$googleanalytics` viewtool in Velocity templates for querying Google Analytics 4 data directly from your dotCMS pages. Retrieve metrics like sessions, active users, page views, and more—filtered by dimensions like date, page path, device category, etc. **Note:** This plugin *fetches* analytics data from Google Analytics. It does NOT add tracking code to your site. @@ -46,14 +46,14 @@ This plugin provides a `$analytics` viewtool in Velocity templates for querying #set($propertyId = "123456789") ## Create and configure request -#set($gaRequest = $analytics.createAnalyticsRequest($propertyId)) +#set($gaRequest = $googleanalytics.createAnalyticsRequest($propertyId)) $gaRequest.setStartDate("2026-02-09") $gaRequest.setEndDate("2026-02-16") $gaRequest.setMetrics("sessions,activeUsers") $gaRequest.setDimensions("date") ## Execute query -#set($gaResponse = $analytics.query($gaRequest)) +#set($gaResponse = $googleanalytics.query($gaRequest)) ## Display results diff --git a/src/main/java/com/dotcms/google/analytics/view/AnalyticsToolInfo.java b/src/main/java/com/dotcms/google/analytics/view/AnalyticsToolInfo.java index d8d1bf1..85a6112 100644 --- a/src/main/java/com/dotcms/google/analytics/view/AnalyticsToolInfo.java +++ b/src/main/java/com/dotcms/google/analytics/view/AnalyticsToolInfo.java @@ -11,7 +11,7 @@ public class AnalyticsToolInfo extends ServletToolInfo { @Override public final String getKey() { - return "analytics"; + return "googleanalytics"; } @Override From 9612316af3ad1e8314c2c4f9ca0c8adfb7d2a83a Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Thu, 19 Feb 2026 07:36:55 -0600 Subject: [PATCH 4/4] fix: address code review issues in REST endpoint - Fix site identification using WebAPILocator instead of getServerName() - Add @Consumes(APPLICATION_JSON) annotation to POST endpoint - Return generic error message instead of leaking exception details - Add null check for request body before processing - Cache GoogleAnalyticsService per site to avoid recreating on each request Co-Authored-By: Claude Opus 4.6 (1M context) --- .../rest/GoogleAnalyticsResource.java | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java b/src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java index 745f0dc..1ff72d4 100644 --- a/src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java +++ b/src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java @@ -1,11 +1,12 @@ package com.dotcms.google.analytics.rest; -import com.dotcms.google.analytics.app.AnalyticsApp; import com.dotcms.google.analytics.app.AnalyticsAppService; import com.dotcms.google.analytics.model.AnalyticsRequest; import com.dotcms.google.analytics.model.FilterRequest; import com.dotcms.google.analytics.service.GoogleAnalyticsService; import com.dotcms.rest.WebResource; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.web.WebAPILocator; import com.dotmarketing.util.Logger; import com.google.analytics.data.v1beta.DimensionValue; import com.google.analytics.data.v1beta.MetricValue; @@ -14,6 +15,7 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.Consumes; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.Produces; @@ -24,6 +26,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; /** @@ -36,6 +39,7 @@ public class GoogleAnalyticsResource { private final WebResource webResource = new WebResource(); private final AnalyticsAppService analyticsAppService = new AnalyticsAppService(); + private final Map googleAnalyticsServiceMap = new ConcurrentHashMap<>(); /** * Query Google Analytics 4 data via REST API. @@ -58,6 +62,7 @@ public class GoogleAnalyticsResource { */ @POST @Path("/query") + @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Response query( @Context final HttpServletRequest request, @@ -65,6 +70,13 @@ public Response query( final GoogleAnalyticsQueryRequest queryRequest) { try { + // Validate request body + if (queryRequest == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Request body is required")) + .build(); + } + // Authenticate user final User user = new WebResource.InitBuilder(webResource) .requiredBackendUser(true) @@ -84,12 +96,19 @@ public Response query( } // Get analytics app configuration for current site - final String siteId = request.getServerName(); // Or extract from request - final AnalyticsApp analyticsApp = analyticsAppService.getAnalyticsApp(siteId); + final Host currentHost = WebAPILocator.getHostWebAPI().getHost(request); + final String siteId = currentHost.getIdentifier(); - // Create Google Analytics service + // Get or create cached Google Analytics service final GoogleAnalyticsService analyticsService = - new GoogleAnalyticsService(analyticsApp.getJsonKeyFile()); + this.googleAnalyticsServiceMap.computeIfAbsent(siteId, key -> { + try { + final AnalyticsApp analyticsApp = analyticsAppService.getAnalyticsApp(siteId); + return new GoogleAnalyticsService(analyticsApp.getJsonKeyFile()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); // Build analytics request final AnalyticsRequest analyticsRequest = @@ -203,7 +222,7 @@ public Response query( } catch (Exception e) { Logger.error(this, "Error querying Google Analytics", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(Map.of("error", e.getMessage())) + .entity(Map.of("error", "Error querying Google Analytics")) .build(); } }