From 2d5adfb764257ba8ce5df2a174f12494b4492efe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:44:32 +0000 Subject: [PATCH 01/15] Initial plan From 73e2734ba23d84d460cf27ff67bb059e20e824aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:50:07 +0000 Subject: [PATCH 02/15] feat: implement Google Analytics tracking code injection via WebInterceptor - Created GoogleAnalyticsWebInterceptor to automatically inject GA tracking code - Supports both GA4 (G-XXXXXXXXXX) and UA (UA-XXXXXXXXXX) formats - Injects before
tag for optimal performance - Configuration via GOOGLE_ANALYTICS_AUTO_INJECT environment variable - Skips injection in EDIT_MODE and PREVIEW_MODE - Only processes HTML responses (text/html content type) - Registered interceptor in InterceptorFilter - Added comprehensive unit tests Co-authored-by: fmontes <751424+fmontes@users.noreply.github.com> --- .../GoogleAnalyticsWebInterceptor.java | 257 +++++++++++++++++ .../filters/InterceptorFilter.java | 2 + .../GoogleAnalyticsWebInterceptorTest.java | 259 ++++++++++++++++++ 3 files changed, 518 insertions(+) create mode 100644 dotCMS/src/main/java/com/dotcms/analytics/GoogleAnalyticsWebInterceptor.java create mode 100644 dotCMS/src/test/java/com/dotcms/analytics/GoogleAnalyticsWebInterceptorTest.java diff --git a/dotCMS/src/main/java/com/dotcms/analytics/GoogleAnalyticsWebInterceptor.java b/dotCMS/src/main/java/com/dotcms/analytics/GoogleAnalyticsWebInterceptor.java new file mode 100644 index 000000000000..1c23b78692f2 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/GoogleAnalyticsWebInterceptor.java @@ -0,0 +1,257 @@ +package com.dotcms.analytics; + +import com.dotcms.filters.interceptor.Result; +import com.dotcms.filters.interceptor.WebInterceptor; +import com.dotcms.rest.api.v1.site.SiteResource; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.web.WebAPILocator; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.PageMode; +import com.dotmarketing.util.UtilMethods; +import com.google.common.annotations.VisibleForTesting; + +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; + +/** + * Web Interceptor that automatically injects Google Analytics tracking code into HTML pages + * when the googleAnalytics field is populated on a site. + * + * Supports both: + * - Google Analytics 4 (GA4) format: G-XXXXXXXXXX + * - Universal Analytics (UA) format: UA-XXXXXXXXXX-X + * + * Configuration: + * - GOOGLE_ANALYTICS_AUTO_INJECT: Enable/disable auto-injection (default: true) + * + * The tracking code is injected before the closing tag for optimal performance. + * Injection is skipped in EDIT_MODE and PREVIEW_MODE to avoid tracking during content editing. + * + * @author dotCMS + */ +public class GoogleAnalyticsWebInterceptor implements WebInterceptor { + + private static final String CONFIG_AUTO_INJECT = "GOOGLE_ANALYTICS_AUTO_INJECT"; + private static final String CONTENT_TYPE_HTML = "text/html"; + private static final String BODY_CLOSE_TAG = ""; + + // GA4 tracking code template + private static final String GA4_SCRIPT_TEMPLATE = + "\n" + + "\n" + + "\n"; + + // Universal Analytics tracking code template + private static final String UA_SCRIPT_TEMPLATE = + "\n" + + "\n"; + + @Override + public Result intercept(final HttpServletRequest request, final HttpServletResponse response) + throws IOException { + + // Check if auto-injection is enabled via configuration + if (!Config.getBooleanProperty(CONFIG_AUTO_INJECT, true)) { + Logger.debug(this, "Google Analytics auto-injection is disabled"); + return Result.NEXT; + } + + // Skip injection in edit/preview modes + final PageMode pageMode = PageMode.get(request); + if (pageMode.isAdmin || pageMode == PageMode.EDIT_MODE || pageMode == PageMode.PREVIEW_MODE) { + Logger.debug(this, () -> "Skipping GA injection in " + pageMode + " mode"); + return Result.NEXT; + } + + // Get current site and check for GA tracking ID + final Host site = WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); + if (site == null) { + return Result.NEXT; + } + + final String gaTrackingId = site.getStringProperty(SiteResource.GOOGLE_ANALYTICS); + if (!UtilMethods.isSet(gaTrackingId)) { + return Result.NEXT; + } + + Logger.debug(this, () -> "Google Analytics tracking ID found for site '" + + site.getHostname() + "': " + gaTrackingId); + + // Wrap response to capture and modify HTML output + final GAResponseWrapper wrappedResponse = new GAResponseWrapper(response, gaTrackingId); + + return Result.wrap(request, wrappedResponse); + } + + /** + * Determines the appropriate tracking script based on the tracking ID format + * + * @param trackingId The Google Analytics tracking ID + * @return The formatted tracking script HTML + */ + @VisibleForTesting + static String generateTrackingScript(final String trackingId) { + if (trackingId.startsWith("G-")) { + // GA4 format + return String.format(GA4_SCRIPT_TEMPLATE, trackingId, trackingId); + } else if (trackingId.startsWith("UA-")) { + // Universal Analytics format + return String.format(UA_SCRIPT_TEMPLATE, trackingId); + } else { + // Default to GA4 format if format is unclear + Logger.warn(GoogleAnalyticsWebInterceptor.class, + "Unknown Google Analytics tracking ID format: " + trackingId + + ". Using GA4 format."); + return String.format(GA4_SCRIPT_TEMPLATE, trackingId, trackingId); + } + } + + /** + * Response wrapper that captures HTML output and injects GA tracking code before + */ + private static class GAResponseWrapper extends HttpServletResponseWrapper { + + private final String trackingId; + private ByteArrayOutputStream outputStream; + private ServletOutputStream servletOutputStream; + private PrintWriter writer; + private boolean isHtmlResponse = false; + + public GAResponseWrapper(final HttpServletResponse response, final String trackingId) { + super(response); + this.trackingId = trackingId; + } + + @Override + public void setContentType(final String type) { + super.setContentType(type); + if (type != null && type.toLowerCase().contains(CONTENT_TYPE_HTML)) { + this.isHtmlResponse = true; + this.outputStream = new ByteArrayOutputStream(); + } + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + if (writer != null) { + throw new IllegalStateException("getWriter() has already been called"); + } + + if (!isHtmlResponse) { + return super.getOutputStream(); + } + + if (servletOutputStream == null) { + servletOutputStream = new ServletOutputStream() { + @Override + public void write(int b) throws IOException { + outputStream.write(b); + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setWriteListener(WriteListener writeListener) { + // Not implemented for this use case + } + }; + } + + return servletOutputStream; + } + + @Override + public PrintWriter getWriter() throws IOException { + if (servletOutputStream != null) { + throw new IllegalStateException("getOutputStream() has already been called"); + } + + if (!isHtmlResponse) { + return super.getWriter(); + } + + if (writer == null) { + writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); + } + + return writer; + } + + @Override + public void flushBuffer() throws IOException { + if (isHtmlResponse && outputStream != null) { + injectTrackingCodeAndWrite(); + } + super.flushBuffer(); + } + + /** + * Called when the response is being finalized. + * Injects the GA tracking code before and writes to the actual response. + */ + private void injectTrackingCodeAndWrite() throws IOException { + if (writer != null) { + writer.flush(); + } + + final String originalHtml = outputStream.toString(StandardCharsets.UTF_8.name()); + final String modifiedHtml = injectTrackingCode(originalHtml, trackingId); + + // Write the modified HTML to the actual response + final ServletOutputStream realOutputStream = getResponse().getOutputStream(); + realOutputStream.write(modifiedHtml.getBytes(StandardCharsets.UTF_8)); + realOutputStream.flush(); + + // Clear the buffer to avoid double-writing + outputStream.reset(); + } + + /** + * Injects the Google Analytics tracking code before the closing tag + * + * @param html The original HTML content + * @param trackingId The Google Analytics tracking ID + * @return The modified HTML with tracking code injected + */ + @VisibleForTesting + static String injectTrackingCode(final String html, final String trackingId) { + final int bodyCloseIndex = html.toLowerCase().lastIndexOf(BODY_CLOSE_TAG); + + if (bodyCloseIndex < 0) { + Logger.debug(GoogleAnalyticsWebInterceptor.class, + "No tag found, skipping GA injection"); + return html; + } + + final String trackingScript = generateTrackingScript(trackingId); + + return html.substring(0, bodyCloseIndex) + + trackingScript + + html.substring(bodyCloseIndex); + } + } +} diff --git a/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java b/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java index 700a3cdbf4ba..2f6bb3bf756c 100644 --- a/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java +++ b/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java @@ -1,5 +1,6 @@ package com.dotmarketing.filters; +import com.dotcms.analytics.GoogleAnalyticsWebInterceptor; import com.dotcms.analytics.track.AnalyticsTrackWebInterceptor; import com.dotcms.business.SystemTableUpdatedKeyEvent; import com.dotcms.ema.EMAWebInterceptor; @@ -55,6 +56,7 @@ private void addInterceptors(final FilterConfig config) { delegate.add(new ResponseMetaDataWebInterceptor()); delegate.add(new EventLogWebInterceptor()); delegate.add(new CurrentVariantWebInterceptor()); + delegate.add(new GoogleAnalyticsWebInterceptor()); delegate.add(analyticsTrackWebInterceptor); APILocator.getLocalSystemEventsAPI().subscribe(SystemTableUpdatedKeyEvent.class, analyticsTrackWebInterceptor); } // addInterceptors. diff --git a/dotCMS/src/test/java/com/dotcms/analytics/GoogleAnalyticsWebInterceptorTest.java b/dotCMS/src/test/java/com/dotcms/analytics/GoogleAnalyticsWebInterceptorTest.java new file mode 100644 index 000000000000..7d1257c66d6b --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/analytics/GoogleAnalyticsWebInterceptorTest.java @@ -0,0 +1,259 @@ +package com.dotcms.analytics; + +import com.dotcms.UnitTestBase; +import com.dotcms.filters.interceptor.Result; +import com.dotcms.rest.api.v1.site.SiteResource; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.web.HostWebAPI; +import com.dotmarketing.business.web.WebAPILocator; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.PageMode; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for GoogleAnalyticsWebInterceptor + * + * @author dotCMS + */ +@RunWith(PowerMockRunner.class) +@PrepareForTest({Config.class, WebAPILocator.class, PageMode.class}) +public class GoogleAnalyticsWebInterceptorTest extends UnitTestBase { + + private GoogleAnalyticsWebInterceptor interceptor; + private HttpServletRequest request; + private HttpServletResponse response; + private Host site; + private HostWebAPI hostWebAPI; + + @Before + public void setUp() { + interceptor = new GoogleAnalyticsWebInterceptor(); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + site = mock(Host.class); + hostWebAPI = mock(HostWebAPI.class); + + // Setup PowerMock statics + PowerMockito.mockStatic(Config.class); + PowerMockito.mockStatic(WebAPILocator.class); + PowerMockito.mockStatic(PageMode.class); + + // Default mocks + when(WebAPILocator.getHostWebAPI()).thenReturn(hostWebAPI); + when(hostWebAPI.getCurrentHostNoThrow(request)).thenReturn(site); + when(Config.getBooleanProperty("GOOGLE_ANALYTICS_AUTO_INJECT", true)).thenReturn(true); + } + + /** + * Test that interceptor returns NEXT when auto-injection is disabled + */ + @Test + public void test_intercept_whenDisabled_returnsNext() throws Exception { + // Given: Auto-injection is disabled + when(Config.getBooleanProperty("GOOGLE_ANALYTICS_AUTO_INJECT", true)).thenReturn(false); + + // When: Interceptor is called + final Result result = interceptor.intercept(request, response); + + // Then: Should return NEXT without wrapping + assertEquals(Result.NEXT, result); + } + + /** + * Test that interceptor skips injection in EDIT_MODE + */ + @Test + public void test_intercept_inEditMode_returnsNext() throws Exception { + // Given: Request is in EDIT_MODE + when(PageMode.get(request)).thenReturn(PageMode.EDIT_MODE); + + // When: Interceptor is called + final Result result = interceptor.intercept(request, response); + + // Then: Should return NEXT without wrapping + assertEquals(Result.NEXT, result); + } + + /** + * Test that interceptor skips injection in PREVIEW_MODE + */ + @Test + public void test_intercept_inPreviewMode_returnsNext() throws Exception { + // Given: Request is in PREVIEW_MODE + when(PageMode.get(request)).thenReturn(PageMode.PREVIEW_MODE); + + // When: Interceptor is called + final Result result = interceptor.intercept(request, response); + + // Then: Should return NEXT without wrapping + assertEquals(Result.NEXT, result); + } + + /** + * Test that interceptor skips injection when site is null + */ + @Test + public void test_intercept_whenNoSite_returnsNext() throws Exception { + // Given: No site is found + when(hostWebAPI.getCurrentHostNoThrow(request)).thenReturn(null); + when(PageMode.get(request)).thenReturn(PageMode.LIVE); + + // When: Interceptor is called + final Result result = interceptor.intercept(request, response); + + // Then: Should return NEXT without wrapping + assertEquals(Result.NEXT, result); + } + + /** + * Test that interceptor skips injection when GA tracking ID is not set + */ + @Test + public void test_intercept_whenNoTrackingId_returnsNext() throws Exception { + // Given: Site has no GA tracking ID + when(site.getStringProperty(SiteResource.GOOGLE_ANALYTICS)).thenReturn(null); + when(PageMode.get(request)).thenReturn(PageMode.LIVE); + + // When: Interceptor is called + final Result result = interceptor.intercept(request, response); + + // Then: Should return NEXT without wrapping + assertEquals(Result.NEXT, result); + } + + /** + * Test that interceptor wraps response when all conditions are met + */ + @Test + public void test_intercept_withValidConditions_wrapsResponse() throws Exception { + // Given: Valid conditions for injection + when(site.getStringProperty(SiteResource.GOOGLE_ANALYTICS)).thenReturn("G-XXXXXXXXXX"); + when(PageMode.get(request)).thenReturn(PageMode.LIVE); + + // When: Interceptor is called + final Result result = interceptor.intercept(request, response); + + // Then: Should wrap the response + assertNotNull(result); + assertNotNull(result.getResponse()); + } + + /** + * Test GA4 tracking script generation + */ + @Test + public void test_generateTrackingScript_ga4Format() { + // Given: GA4 tracking ID + final String trackingId = "G-XXXXXXXXXX"; + + // When: Generating script + final String script = GoogleAnalyticsWebInterceptor.generateTrackingScript(trackingId); + + // Then: Should contain GA4 script elements + assertTrue(script.contains("gtag.js?id=" + trackingId)); + assertTrue(script.contains("gtag('config', '" + trackingId + "')")); + assertTrue(script.contains("window.dataLayer")); + } + + /** + * Test Universal Analytics tracking script generation + */ + @Test + public void test_generateTrackingScript_uaFormat() { + // Given: UA tracking ID + final String trackingId = "UA-XXXXXXXXX-1"; + + // When: Generating script + final String script = GoogleAnalyticsWebInterceptor.generateTrackingScript(trackingId); + + // Then: Should contain UA script elements + assertTrue(script.contains("analytics.js")); + assertTrue(script.contains("ga('create', '" + trackingId + "'")); + assertTrue(script.contains("ga('send', 'pageview')")); + } + + /** + * Test HTML injection before closing body tag + */ + @Test + public void test_injectTrackingCode_injectsBeforeBodyClose() { + // Given: HTML with body tag and GA4 tracking ID + final String originalHtml = "