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 5fa2d36..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 @@ -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/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..1ff72d4 --- /dev/null +++ b/src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java @@ -0,0 +1,300 @@ +package com.dotcms.google.analytics.rest; + +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; +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.Consumes; +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.concurrent.ConcurrentHashMap; +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(); + private final Map googleAnalyticsServiceMap = new ConcurrentHashMap<>(); + + /** + * 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") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response query( + @Context final HttpServletRequest request, + @Context final HttpServletResponse response, + 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) + .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 Host currentHost = WebAPILocator.getHostWebAPI().getHost(request); + final String siteId = currentHost.getIdentifier(); + + // Get or create cached Google Analytics service + final GoogleAnalyticsService analyticsService = + 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 = + 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); + + // 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()); + + // 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<>(); + + // 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; + }) + .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", "Error querying Google Analytics")) + .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; } + } +} 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