diff --git a/src/main/java/com/brsanthu/googleanalytics/GoogleAnalyticsConfig.java b/src/main/java/com/brsanthu/googleanalytics/GoogleAnalyticsConfig.java index 479e337..30d34c8 100644 --- a/src/main/java/com/brsanthu/googleanalytics/GoogleAnalyticsConfig.java +++ b/src/main/java/com/brsanthu/googleanalytics/GoogleAnalyticsConfig.java @@ -53,6 +53,7 @@ public class GoogleAnalyticsConfig { private boolean gatherStats = false; private RequestParameterDiscoverer requestParameterDiscoverer = new DefaultRequestParameterDiscoverer(); private GoogleAnalyticsExceptionHandler exceptionHandler; + private boolean autoQueueTimeEnabled = true; public RequestParameterDiscoverer getRequestParameterDiscoverer() { return requestParameterDiscoverer; @@ -418,4 +419,19 @@ public GoogleAnalyticsConfig setExceptionHandler(GoogleAnalyticsExceptionHandler return this; } + public boolean isAutoQueueTimeEnabled() { + return autoQueueTimeEnabled; + } + + /** + * If enabled, library will calculate the queue time (qt) at the time request is being posted to GA based on when + * hit request was created and when it is posted to GA. Defaults to true. + * + * @since 2.1 + */ + public GoogleAnalyticsConfig setAutoQueueTimeEnabled(boolean autoQueueTimeEnabled) { + this.autoQueueTimeEnabled = autoQueueTimeEnabled; + return this; + } + } diff --git a/src/main/java/com/brsanthu/googleanalytics/httpclient/HttpRequest.java b/src/main/java/com/brsanthu/googleanalytics/httpclient/HttpRequest.java index 75f25f6..1997e6e 100644 --- a/src/main/java/com/brsanthu/googleanalytics/httpclient/HttpRequest.java +++ b/src/main/java/com/brsanthu/googleanalytics/httpclient/HttpRequest.java @@ -3,14 +3,17 @@ import java.util.HashMap; import java.util.Map; +import com.brsanthu.googleanalytics.request.GoogleAnalyticsRequest; + public class HttpRequest { private String contentType; private String method; private String url; private Map bodyParams = new HashMap<>(); + private GoogleAnalyticsRequest googleAnalyticsRequest; public HttpRequest(String url) { - this.setUrl(url); + setUrl(url); } public HttpRequest post() { @@ -53,4 +56,13 @@ public HttpRequest setUrl(String url) { this.url = url; return this; } + + public GoogleAnalyticsRequest getGoogleAnalyticsRequest() { + return googleAnalyticsRequest; + } + + public HttpRequest setGoogleAnalyticsRequest(GoogleAnalyticsRequest googleAnalyticsRequest) { + this.googleAnalyticsRequest = googleAnalyticsRequest; + return this; + } } diff --git a/src/main/java/com/brsanthu/googleanalytics/internal/GoogleAnalyticsImpl.java b/src/main/java/com/brsanthu/googleanalytics/internal/GoogleAnalyticsImpl.java index 7672fc0..2edc383 100644 --- a/src/main/java/com/brsanthu/googleanalytics/internal/GoogleAnalyticsImpl.java +++ b/src/main/java/com/brsanthu/googleanalytics/internal/GoogleAnalyticsImpl.java @@ -11,7 +11,10 @@ package com.brsanthu.googleanalytics.internal; import static com.brsanthu.googleanalytics.internal.GaUtils.isEmpty; +import static com.brsanthu.googleanalytics.request.GoogleAnalyticsParameter.QUEUE_TIME; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -94,6 +97,8 @@ public Future postAsync(GoogleAnalyticsRequest reque @Override public GoogleAnalyticsResponse post(GoogleAnalyticsRequest gaReq) { GoogleAnalyticsResponse response = new GoogleAnalyticsResponse(); + response.setGoogleAnalyticsRequest(gaReq); + if (!config.isEnabled()) { return response; } @@ -118,6 +123,8 @@ public GoogleAnalyticsResponse post(GoogleAnalyticsRequest gaReq) { protected GoogleAnalyticsResponse postBatch(GoogleAnalyticsRequest gaReq) { GoogleAnalyticsResponse resp = new GoogleAnalyticsResponse(); + resp.setGoogleAnalyticsRequest(gaReq); + HttpRequest httpReq = createHttpRequest(gaReq); resp.setRequestParams(httpReq.getBodyParams()); @@ -151,6 +158,8 @@ private void submitBatch(boolean force) { // others will not post it even if multiple threads were to wait at sync block at same time // https://en.wikipedia.org/wiki/Double-checked_locking if (isSubmitBatch(force)) { + processAutoQueueTime(currentBatch); + logger.debug("Submitting a batch of " + currentBatch.size() + " requests to GA"); httpClient.postBatch(new HttpBatchRequest().setUrl(config.getBatchUrl()).setRequests(currentBatch)); currentBatch.clear(); @@ -159,13 +168,52 @@ private void submitBatch(boolean force) { } } + protected HttpRequest processAutoQueueTime(HttpRequest request) { + if (!config.isAutoQueueTimeEnabled()) { + return request; + } + + List requests = new ArrayList<>(); + requests.add(request); + + processAutoQueueTime(requests); + + return request; + } + + protected void processAutoQueueTime(List requests) { + if (!config.isAutoQueueTimeEnabled()) { + return; + } + + // If there is no queue time specified, then set the queue time to time since event occurred to current time + // (time at which event being posted). This is helpful for batched requests as request may be sitting in queue + // for a while and we need to calculate the time. + for (HttpRequest req : requests) { + if (req.getGoogleAnalyticsRequest() == null || req.getGoogleAnalyticsRequest().occurredAt() == null) { + continue; + } + + String qtParamName = QUEUE_TIME.getParameterName(); + + Map params = req.getBodyParams(); + + int millis = (int) ChronoUnit.MILLIS.between(req.getGoogleAnalyticsRequest().occurredAt(), ZonedDateTime.now()); + int qtMillis = params.containsKey(qtParamName) ? millis + Integer.parseInt(params.get(qtParamName)) : millis; + + params.put(qtParamName, String.valueOf(qtMillis)); + + req.getGoogleAnalyticsRequest().queueTime(qtMillis); + } + } + private boolean isSubmitBatch(boolean force) { return force || currentBatch.size() >= config.getBatchSize(); } protected GoogleAnalyticsResponse postSingle(GoogleAnalyticsRequest gaReq) { - HttpRequest httpReq = createHttpRequest(gaReq); + HttpRequest httpReq = processAutoQueueTime(createHttpRequest(gaReq)); HttpResponse httpResp = httpClient.post(httpReq); GoogleAnalyticsResponse response = new GoogleAnalyticsResponse(); @@ -182,13 +230,12 @@ protected GoogleAnalyticsResponse postSingle(GoogleAnalyticsRequest gaReq) { private HttpRequest createHttpRequest(GoogleAnalyticsRequest gaReq) { HttpRequest httpReq = new HttpRequest(config.getUrl()); - // Process the parameters + httpReq.setGoogleAnalyticsRequest(gaReq); + processParameters(gaReq, httpReq); - // Process custom dimensions processCustomDimensionParameters(gaReq, httpReq); - // Process custom metrics processCustomMetricParameters(gaReq, httpReq); return httpReq; diff --git a/src/main/java/com/brsanthu/googleanalytics/request/GoogleAnalyticsRequest.java b/src/main/java/com/brsanthu/googleanalytics/request/GoogleAnalyticsRequest.java index 66281de..e87c0b7 100644 --- a/src/main/java/com/brsanthu/googleanalytics/request/GoogleAnalyticsRequest.java +++ b/src/main/java/com/brsanthu/googleanalytics/request/GoogleAnalyticsRequest.java @@ -1,15 +1,12 @@ /* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. */ package com.brsanthu.googleanalytics.request; @@ -52,6 +49,7 @@ import static com.brsanthu.googleanalytics.request.GoogleAnalyticsParameter.USER_LANGUAGE; import static com.brsanthu.googleanalytics.request.GoogleAnalyticsParameter.VIEWPORT_SIZE; +import java.time.ZonedDateTime; import java.util.HashMap; import java.util.Map; import java.util.concurrent.Future; @@ -74,11 +72,11 @@ @SuppressWarnings("unchecked") public class GoogleAnalyticsRequest { - protected Map parms = new HashMap(); - protected Map customDimensions = new HashMap(); - protected Map customMetrics = new HashMap(); - + protected Map parms = new HashMap<>(); + protected Map customDimensions = new HashMap<>(); + protected Map customMetrics = new HashMap<>(); protected GoogleAnalyticsExecutor delegateExecutor = null; + private ZonedDateTime occurredAt = ZonedDateTime.now(); public GoogleAnalyticsRequest() { this(null, null, null, null); @@ -644,11 +642,11 @@ public String clientId() { * * * - * + * *
Example value: as8eknlll
* Example usage: uid=as8eknlll
- * - * + * + * * * * @param value @@ -1741,7 +1739,7 @@ public String applicationId() { *
*

* Optional. - * + * *

*

* This parameter specifies that this visitor has been exposed to an experiment with the given ID. It should be sent @@ -1765,8 +1763,8 @@ public String applicationId() { * * * - * - * + * + * *

Example value: Qp0gahJ3RAO3DJ18b0XoUQ
* Example usage: xid=Qp0gahJ3RAO3DJ18b0XoUQ
*/ @@ -1783,7 +1781,7 @@ public String experimentId() { *
*

* Optional. - * + * *

*

* This parameter specifies that this visitor has been exposed to a particular variation of an experiment. It should @@ -1880,4 +1878,17 @@ public GoogleAnalyticsRequest setExecutor(GoogleAnalyticsExecutor delegateExe this.delegateExecutor = delegateExecutor; return this; } + + /** + * Indicates the datetime at which this event occurred. This is used to report the qt parameter, if one + * is not set. The occurredAt defaults to datetime when this request was instantiated. + */ + public T occurredAt(ZonedDateTime value) { + this.occurredAt = value; + return (T) this; + } + + public ZonedDateTime occurredAt() { + return occurredAt; + } } diff --git a/src/main/java/com/brsanthu/googleanalytics/request/GoogleAnalyticsResponse.java b/src/main/java/com/brsanthu/googleanalytics/request/GoogleAnalyticsResponse.java index eba245f..b4152a1 100644 --- a/src/main/java/com/brsanthu/googleanalytics/request/GoogleAnalyticsResponse.java +++ b/src/main/java/com/brsanthu/googleanalytics/request/GoogleAnalyticsResponse.java @@ -1,15 +1,12 @@ /* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. */ package com.brsanthu.googleanalytics.request; @@ -22,6 +19,7 @@ */ public class GoogleAnalyticsResponse { private int statusCode = 200; + private GoogleAnalyticsRequest googleAnalyticsRequest; private Map requestParams = null; public Map getRequestParams() { @@ -29,7 +27,7 @@ public Map getRequestParams() { } public void setRequestParams(Map postedParms) { - this.requestParams = postedParms; + requestParams = postedParms; } public void setStatusCode(int statusCode) { @@ -48,4 +46,13 @@ public String toString() { builder.append("]"); return builder.toString(); } + + public GoogleAnalyticsRequest getGoogleAnalyticsRequest() { + return googleAnalyticsRequest; + } + + public GoogleAnalyticsResponse setGoogleAnalyticsRequest(GoogleAnalyticsRequest googleAnalyticsRequest) { + this.googleAnalyticsRequest = googleAnalyticsRequest; + return this; + } } diff --git a/src/test/java/com/brsanthu/googleanalytics/GoogleAnalyticsTest.java b/src/test/java/com/brsanthu/googleanalytics/GoogleAnalyticsTest.java index 77d1ab1..408b39a 100644 --- a/src/test/java/com/brsanthu/googleanalytics/GoogleAnalyticsTest.java +++ b/src/test/java/com/brsanthu/googleanalytics/GoogleAnalyticsTest.java @@ -1,18 +1,25 @@ package com.brsanthu.googleanalytics; import static com.brsanthu.googleanalytics.internal.Constants.TEST_TRACKING_ID; +import static com.brsanthu.googleanalytics.request.GoogleAnalyticsParameter.QUEUE_TIME; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.time.ZonedDateTime; + import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.mockito.ArgumentMatchers; import com.brsanthu.googleanalytics.httpclient.HttpClient; +import com.brsanthu.googleanalytics.httpclient.HttpResponse; import com.brsanthu.googleanalytics.request.DefaultRequest; +import com.brsanthu.googleanalytics.request.GoogleAnalyticsParameter; import com.brsanthu.googleanalytics.request.GoogleAnalyticsResponse; public class GoogleAnalyticsTest { @@ -150,4 +157,60 @@ void testExceptionHandler() throws Exception { assertThatThrownBy(() -> propagatingGa.screenView().sendAsync().get()).hasMessageContaining("Testing Exception"); } + + @Test + void testAutoQueueTime() throws Exception { + + HttpClient client = mock(HttpClient.class); + when(client.post(ArgumentMatchers.any())).thenReturn(new HttpResponse().setStatusCode(200)); + + // By default queue time is added based on when hit was created and posted. In this test case, it should be + // close to 0 + GoogleAnalytics gaAutoTimeEnabled = GoogleAnalytics.builder().withHttpClient(client).withConfig(new GoogleAnalyticsConfig()).build(); + GoogleAnalyticsResponse respEnabled = gaAutoTimeEnabled.screenView().send(); + assertThat(Integer.parseInt(respEnabled.getRequestParams().get(GoogleAnalyticsParameter.QUEUE_TIME.getParameterName()))).isCloseTo(0, + within(100)); + + // We can set occurred at to a past value and if so, it will be used to calcualte queue time. + GoogleAnalytics gaAutoTimeEnabledOccurredAt = GoogleAnalytics.builder().withHttpClient(client).withConfig( + new GoogleAnalyticsConfig()).build(); + GoogleAnalyticsResponse respEnabledOccurredAt = gaAutoTimeEnabledOccurredAt.screenView().occurredAt( + ZonedDateTime.now().minusSeconds(5)).send(); + assertThat(Integer.parseInt(respEnabledOccurredAt.getRequestParams().get(GoogleAnalyticsParameter.QUEUE_TIME.getParameterName()))).isCloseTo( + 5000, within(100)); + + // We can set both occurredAt and queue time, then time based on occurred at will be added to set queue time. + GoogleAnalyticsResponse respEnabledWithSetQueueTime = gaAutoTimeEnabled.screenView().occurredAt( + ZonedDateTime.now().minusSeconds(5)).queueTime(1000).send(); + assertThat(Integer.parseInt( + respEnabledWithSetQueueTime.getRequestParams().get(GoogleAnalyticsParameter.QUEUE_TIME.getParameterName()))).isCloseTo(6000, + within(100)); + + // If we disable auto queue time, then queue time is not calculated + GoogleAnalytics gaAutoTimeDisabled = GoogleAnalytics.builder().withHttpClient(client).withConfig( + new GoogleAnalyticsConfig().setAutoQueueTimeEnabled(false)).build(); + GoogleAnalyticsResponse respDisabled = gaAutoTimeDisabled.screenView().occurredAt(ZonedDateTime.now().minusSeconds(5)).send(); + assertThat(respDisabled.getRequestParams().get(GoogleAnalyticsParameter.QUEUE_TIME.getParameterName())).isNull(); + } + + @Test + void testAutoQueueTimeBatch() throws Exception { + HttpClient client = mock(HttpClient.class); + when(client.post(ArgumentMatchers.any())).thenReturn(new HttpResponse().setStatusCode(200)); + + GoogleAnalytics gaAutoTimeEnabled = GoogleAnalytics.builder().withHttpClient(client).withConfig( + new GoogleAnalyticsConfig().setBatchingEnabled(true).setBatchSize(2)).build(); + + // First request will add to batch. + GoogleAnalyticsResponse resp1 = gaAutoTimeEnabled.screenView().send(); + + Thread.sleep(500); + + GoogleAnalyticsResponse resp2 = gaAutoTimeEnabled.screenView().send(); + + assertThat(Integer.parseInt(resp1.getRequestParams().get(QUEUE_TIME.getParameterName()))).isCloseTo(500, within(100)); + + assertThat(Integer.parseInt(resp2.getRequestParams().get(QUEUE_TIME.getParameterName()))).isCloseTo(0, within(100)); + } + }