001/* 002 * Copyright 2023 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.plugins.webanalytics.matomo; 017 018import java.time.Duration; 019import java.time.LocalDate; 020import java.util.List; 021import java.util.Map; 022import java.util.Optional; 023 024import org.apache.avalon.framework.activity.Disposable; 025import org.apache.avalon.framework.activity.Initializable; 026import org.apache.avalon.framework.component.Component; 027import org.apache.avalon.framework.service.ServiceException; 028import org.apache.avalon.framework.service.ServiceManager; 029import org.apache.avalon.framework.service.Serviceable; 030import org.apache.commons.lang3.StringUtils; 031import org.apache.hc.client5.http.classic.methods.HttpPost; 032import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; 033import org.apache.hc.core5.http.io.entity.EntityUtils; 034import org.apache.hc.core5.io.CloseMode; 035 036import org.ametys.core.cache.AbstractCacheManager; 037import org.ametys.core.cache.Cache; 038import org.ametys.core.util.HttpUtils; 039import org.ametys.core.util.JSONUtils; 040import org.ametys.core.util.LambdaUtils; 041import org.ametys.core.util.LambdaUtils.LambdaException; 042import org.ametys.core.util.URIUtils; 043import org.ametys.plugins.core.impl.cache.AbstractCacheKey; 044import org.ametys.runtime.config.Config; 045import org.ametys.runtime.i18n.I18nizableText; 046import org.ametys.runtime.plugin.component.AbstractLogEnabled; 047import org.ametys.web.repository.site.Site; 048 049/** 050 * The matomo data helper 051 */ 052public class MatomoDataHelper extends AbstractLogEnabled implements Component, Initializable, Serviceable, Disposable 053{ 054 /** The component role. */ 055 public static final String ROLE = MatomoDataHelper.class.getName(); 056 057 /** The Matomo URL config parameter name */ 058 public static final String MATOMO_URL_CONFIG = "piwik-url"; 059 060 /** The Matomo token config parameter name */ 061 public static final String MATOMO_TOKEN_CONFIG = "matomo-token"; 062 063 /** The Matomo site id config parameter name */ 064 public static final String MATOMO_SITE_ID_SITE_CONFIG = "piwik-id"; 065 066 private static final String __MATOMO_CACHE_LIFETIME_CONFIG = "matomo-cache-lifetime"; 067 private static final String __MATOMO_SITE_LIVE_STATS_CONFIG = "matomo-live-stats"; 068 private static final String __MATOMO_LAST_DAYS_SITE_CONFIG = "matomo-last-days"; 069 private static final String __MATOMO_BEGIN_YEAR_SITE_CONFIG = "matomo-begin-year"; 070 071 private static final String __CACHE_COMPUTED_VISITS = "computed-visits"; 072 private static final String __CACHE_LIVE_VISITS = "live-visits"; 073 074 /** The cache manager */ 075 protected AbstractCacheManager _cacheManager; 076 077 /** JSON Utils */ 078 protected JSONUtils _jsonUtils; 079 080 private CloseableHttpClient _httpClient; 081 private String _matomoServerURL; 082 private String _matomoToken; 083 084 /** 085 * The computed matomo request type. 086 */ 087 private enum RequestType 088 { 089 /** Computed visits from begin year */ 090 BEGIN_YEAR, 091 /** Computed visits of the current day */ 092 CURRENT_DAY, 093 /** Computed visits of from the last days */ 094 LAST_DAYS; 095 } 096 097 098 public void service(ServiceManager manager) throws ServiceException 099 { 100 _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE); 101 _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE); 102 } 103 104 @Override 105 public void initialize() throws Exception 106 { 107 // The duration of 6 hours is an arbitrary value because computed data in Matomo are re-computed every 6 hours. But the cache need to be recalculate only once every day. 108 // But if the value is put in the cache at 23pm, we don't want the cache to be recalculate at 23pm the next day ... 6 hours seems to be fine ... 109 _cacheManager.createMemoryCache(__CACHE_COMPUTED_VISITS, 110 new I18nizableText("plugin.web-analytics", "PLUGINS_WEB_ANALTICS_CACHE_MATOMO_COMPUTED_VISITS_LABEL"), 111 new I18nizableText("plugin.web-analytics", "PLUGINS_WEB_ANALTICS_CACHE_MATOMO_COMPUTED_VISITS_DESC"), 112 true, 113 Duration.ofHours(6)); 114 115 // Live stats for pages will be computed every n minutes (n is a config parameter) 116 Long cacheLifeTime = Config.getInstance().getValue(__MATOMO_CACHE_LIFETIME_CONFIG, true, 5L); 117 _cacheManager.createMemoryCache(__CACHE_LIVE_VISITS, 118 new I18nizableText("plugin.web-analytics", "PLUGINS_WEB_ANALTICS_CACHE_MATOMO_LIVE_VISITS_LABEL"), 119 new I18nizableText("plugin.web-analytics", "PLUGINS_WEB_ANALTICS_CACHE_MATOMO_LIVE_VISITS_DESC"), 120 true, 121 Duration.ofMinutes(cacheLifeTime)); 122 123 _httpClient = HttpUtils.createHttpClient(5, 5); // Max 5 attempts simultany and 5 sec of timeout 124 _matomoServerURL = Config.getInstance().getValue(MATOMO_URL_CONFIG); 125 _matomoToken = Config.getInstance().getValue(MATOMO_TOKEN_CONFIG); 126 } 127 128 private void _checkMatomoConfig(Site site) throws MatomoException 129 { 130 if (!site.getValue(__MATOMO_SITE_LIVE_STATS_CONFIG, true, false)) 131 { 132 throw new MatomoException("Can't request the matomo stats because the site does not enable it."); 133 } 134 135 String siteId = site.getValue(MATOMO_SITE_ID_SITE_CONFIG); 136 if (StringUtils.isBlank(_matomoServerURL) || StringUtils.isBlank(_matomoToken) || StringUtils.isBlank(siteId)) 137 { 138 throw new MatomoException("One or more of these following parameters are empty: the matamo server URL, the matomo token or the matomo site id. Can't access to matomo data without these parameters"); 139 } 140 } 141 142 /** 143 * Get the number of days from now to computed the number of visits 144 * @param site the site 145 * @return the number of days 146 */ 147 public long getMatomoSiteNbLastDays(Site site) 148 { 149 return site.getValue(__MATOMO_LAST_DAYS_SITE_CONFIG, true, 30L); 150 } 151 152 /** 153 * Get the number of visits since the begin year for the given page and site 154 * @param site the site 155 * @param pageUrl the page URL 156 * @return the number of visits 157 * @throws MatomoException if an error with Matomo occurred 158 */ 159 public int getNbTotalVisits(Site site, String pageUrl) throws MatomoException 160 { 161 _checkMatomoConfig(site); 162 163 String siteId = site.getValue(MATOMO_SITE_ID_SITE_CONFIG); 164 String beginYear = site.getValue(__MATOMO_BEGIN_YEAR_SITE_CONFIG); 165 166 // Number of visits of the current day (Live from Matomo) 167 Integer liveVisitsOfDay = _getLiveVisitsOfDay(siteId, pageUrl); 168 169 // Number of visits of the current day (Computed from Matomo) 170 Integer nbComputedVisitsToday = _getComputedVisitsOfCurrentDay(siteId, pageUrl); 171 172 // Number of visits from the begin years but with the computed current day 173 Integer nbComputedVisitsFromLastYears = _getComputedVisitsFromBeginYear(siteId, pageUrl, beginYear); 174 175 return nbComputedVisitsFromLastYears - nbComputedVisitsToday + liveVisitsOfDay; 176 } 177 178 /** 179 * Get the number of visits from the last days for the given page and site 180 * @param site the site 181 * @param pageUrl the page URL 182 * @return the number of visits 183 * @throws MatomoException if an error with Matomo occurred 184 */ 185 public int getNbVisitsFromLastDays(Site site, String pageUrl) throws MatomoException 186 { 187 _checkMatomoConfig(site); 188 189 String siteId = site.getValue(MATOMO_SITE_ID_SITE_CONFIG); 190 long lastDays = getMatomoSiteNbLastDays(site); 191 192 // Number of visits of the current day (Live from Matomo) 193 Integer liveVisitsOfDay = _getLiveVisitsOfDay(siteId, pageUrl); 194 195 // Visits from the last n days (starting from yesterday) 196 Integer computedVisitsFromLastDays = _getComputedVisitsFromLastDays(siteId, pageUrl, lastDays); 197 198 return computedVisitsFromLastDays + liveVisitsOfDay; 199 } 200 201 /** 202 * Get the live number of visits of today for the given page and site 203 * @param siteId the site identifier in Matomo 204 * @param pageUrl the page URL 205 * @return the number of visits 206 * @throws MatomoException if an error with Matomo occurred 207 */ 208 protected int _getLiveVisitsOfDay(String siteId, String pageUrl) throws MatomoException 209 { 210 StringBuilder url = new StringBuilder(_matomoServerURL); 211 url.append("/?module=API&method=Live.getLastVisitsDetails"); 212 url.append("&format=JSON"); 213 url.append("&period=day"); 214 url.append("&date=today"); 215 url.append("&idSite="); 216 url.append(siteId); 217 url.append("&token_auth="); 218 url.append(_matomoToken); 219 220 try 221 { 222 List<Map<String, Object>> liveVisits = _getLiveVisitsCache().get(siteId, LambdaUtils.wrap(k -> _requestLiveVisitsOfDay(url.toString()))); 223 return _getTodayNbVisits(liveVisits, pageUrl); 224 } 225 catch (LambdaException e) 226 { 227 if (e.getCause() instanceof RuntimeException re) 228 { 229 throw re; 230 } 231 else if (e.getCause() instanceof MatomoException m) 232 { 233 throw m; 234 } 235 else 236 { 237 throw new RuntimeException(e.getCause()); 238 } 239 } 240 } 241 242 @SuppressWarnings("unchecked") 243 private List<Map<String, Object>> _requestLiveVisitsOfDay(String url) throws MatomoException 244 { 245 return (List<Map<String, Object>>) _executeMatomoRequest(url, "list"); 246 } 247 248 /** 249 * Get the computed number of visits from the last years starting from the begin year for the given page and site 250 * @param siteId the site identifier in matomo 251 * @param pageUrl the page URL 252 * @param beginYear the begin year 253 * @return the number of visits 254 * @throws MatomoException if an error with Matomo occurred 255 */ 256 protected int _getComputedVisitsFromBeginYear(String siteId, String pageUrl, String beginYear) throws MatomoException 257 { 258 String truncateURL = _truncateURL(pageUrl); 259 260 LocalDate beginDate = LocalDate.of(Integer.valueOf(beginYear), 1, 1); 261 LocalDate now = LocalDate.now(); 262 263 int nbYear = now.getYear() - beginDate.getYear() + 1; 264 265 StringBuilder url = new StringBuilder(_matomoServerURL); 266 url.append("/?module=API&method=Actions.getPageUrl"); 267 url.append("&format=JSON"); 268 url.append("&period=month"); 269 url.append("&date=last"); 270 url.append(nbYear * 12); 271 url.append("&idSite="); 272 url.append(siteId); 273 url.append("&pageUrl="); 274 url.append(URIUtils.encodeParameter(truncateURL)); 275 url.append("&token_auth="); 276 url.append(_matomoToken); 277 278 try 279 { 280 MatomoComputedCacheKey key = MatomoComputedCacheKey.of(RequestType.BEGIN_YEAR, truncateURL); 281 return _getComputedVisitsCache().get(key, LambdaUtils.wrap(k -> _requestComputedVisitsFromBeginYear(url.toString()))); 282 } 283 catch (LambdaException e) 284 { 285 if (e.getCause() instanceof RuntimeException re) 286 { 287 throw re; 288 } 289 else if (e.getCause() instanceof MatomoException m) 290 { 291 throw m; 292 } 293 else 294 { 295 throw new RuntimeException(e.getCause()); 296 } 297 } 298 } 299 300 private Integer _requestComputedVisitsFromBeginYear(String url) throws MatomoException 301 { 302 @SuppressWarnings("unchecked") 303 Map<String, Object> json = (Map<String, Object>) _executeMatomoRequest(url, "map"); 304 return _getNbVisitsFromMap(json); 305 } 306 307 /** 308 * Get the computed number of visits of today for the given page and site 309 * @param siteId the site identifier in matomo 310 * @param pageUrl the page URL 311 * @return the number of visits 312 * @throws MatomoException if an error with Matomo occurred 313 */ 314 protected int _getComputedVisitsOfCurrentDay(String siteId, String pageUrl) throws MatomoException 315 { 316 String truncateURL = _truncateURL(pageUrl); 317 318 StringBuilder url = new StringBuilder(_matomoServerURL); 319 url.append("/?module=API&method=Actions.getPageUrl"); 320 url.append("&format=JSON"); 321 url.append("&period=day"); 322 url.append("&date=today"); 323 url.append("&idSite="); 324 url.append(siteId); 325 url.append("&pageUrl="); 326 url.append(URIUtils.encodeParameter(truncateURL)); 327 url.append("&token_auth="); 328 url.append(_matomoToken); 329 330 try 331 { 332 MatomoComputedCacheKey key = MatomoComputedCacheKey.of(RequestType.CURRENT_DAY, truncateURL); 333 return _getComputedVisitsCache().get(key, LambdaUtils.wrap(k -> _requestComputedVisitsOfCurrentDay(url.toString()))); 334 } 335 catch (LambdaException e) 336 { 337 if (e.getCause() instanceof RuntimeException re) 338 { 339 throw re; 340 } 341 else if (e.getCause() instanceof MatomoException m) 342 { 343 throw m; 344 } 345 else 346 { 347 throw new RuntimeException(e.getCause()); 348 } 349 } 350 } 351 352 private Integer _requestComputedVisitsOfCurrentDay(String url) throws MatomoException 353 { 354 @SuppressWarnings("unchecked") 355 List<Map<String, Object>> json = (List<Map<String, Object>>) _executeMatomoRequest(url, "list"); 356 return _getNbVisitsFromList(json); 357 } 358 359 /** 360 * Get the computed number of visits from the last n days (starting from yesterday) for the given page and site 361 * @param siteId the site identifier in matomo 362 * @param pageUrl the page URL 363 * @param nbDay the number of day 364 * @return the number of visits 365 * @throws MatomoException if an error with Matomo occurred 366 */ 367 protected int _getComputedVisitsFromLastDays(String siteId, String pageUrl, long nbDay) throws MatomoException 368 { 369 String truncateURL = _truncateURL(pageUrl); 370 371 StringBuilder url = new StringBuilder(_matomoServerURL); 372 url.append("/?module=API&method=Actions.getPageUrl"); 373 url.append("&format=JSON"); 374 url.append("&period=day"); 375 url.append("&date=previous"); 376 url.append(nbDay); 377 url.append("&idSite="); 378 url.append(siteId); 379 url.append("&pageUrl="); 380 url.append(URIUtils.encodeParameter(truncateURL)); 381 url.append("&token_auth="); 382 url.append(_matomoToken); 383 384 try 385 { 386 MatomoComputedCacheKey key = MatomoComputedCacheKey.of(RequestType.LAST_DAYS, truncateURL); 387 return _getComputedVisitsCache().get(key, LambdaUtils.wrap(k -> _requestComputedVisitsFromLastDays(url.toString()))); 388 } 389 catch (LambdaException e) 390 { 391 if (e.getCause() instanceof RuntimeException re) 392 { 393 throw re; 394 } 395 else if (e.getCause() instanceof MatomoException m) 396 { 397 throw m; 398 } 399 else 400 { 401 throw new RuntimeException(e.getCause()); 402 } 403 } 404 } 405 406 private Integer _requestComputedVisitsFromLastDays(String url) throws MatomoException 407 { 408 @SuppressWarnings("unchecked") 409 Map<String, Object> json = (Map<String, Object>) _executeMatomoRequest(url, "map"); 410 return _getNbVisitsFromMap(json); 411 } 412 413 private Object _executeMatomoRequest(String matomoUrl, String returnType) throws MatomoException 414 { 415 try 416 { 417 if (getLogger().isInfoEnabled()) 418 { 419 getLogger().info("A request is send to Matomo with url '" + matomoUrl + "'"); 420 } 421 422 HttpPost post = new HttpPost(matomoUrl); 423 return _httpClient.execute(post, httpResponse -> { 424 if (httpResponse.getCode() != 200) 425 { 426 throw new IllegalStateException("Could not join matomo for URL " + matomoUrl + ". Error code " + httpResponse.getCode()); 427 } 428 429 if ("map".equals(returnType)) 430 { 431 return _jsonUtils.convertJsonToMap(EntityUtils.toString(httpResponse.getEntity())); 432 } 433 else if ("list".equals(returnType)) 434 { 435 return _jsonUtils.convertJsonToList(EntityUtils.toString(httpResponse.getEntity())); 436 } 437 438 throw new IllegalArgumentException("Invalid return type for request with url '" + matomoUrl + "'"); 439 }); 440 } 441 catch (Exception e) 442 { 443 throw new MatomoException("An error occurred requesting Matomo for url '" + matomoUrl + "'", e); 444 } 445 } 446 447 @SuppressWarnings("unchecked") 448 private int _getNbVisitsFromMap(Map<String, Object> jsonAsMap) 449 { 450 int nbVisits = 0; 451 for (String key : jsonAsMap.keySet()) 452 { 453 nbVisits += _getNbVisitsFromList((List<Map<String, Object>>) jsonAsMap.get(key)); 454 } 455 456 return nbVisits; 457 } 458 459 private int _getNbVisitsFromList(List<Map<String, Object>> data) 460 { 461 if (!data.isEmpty()) 462 { 463 return (Integer) data.get(0).getOrDefault("nb_visits", 0); 464 } 465 466 return 0; 467 } 468 469 private int _getTodayNbVisits(List<Map<String, Object>> jsonAsMap, String pageUrl) 470 { 471 int nbVisits = 0; 472 if (!jsonAsMap.isEmpty()) 473 { 474 String truncateURL = _truncateURL(pageUrl); 475 476 LocalDate today = LocalDate.now(); 477 for (Map<String, Object> visitInfo : jsonAsMap) 478 { 479 String visitDateAsString = (String) visitInfo.get("serverDate"); 480 LocalDate visitDate = LocalDate.parse(visitDateAsString); 481 if (visitDate.equals(today)) 482 { 483 @SuppressWarnings("unchecked") 484 List<Map<String, Object>> actions = (List<Map<String, Object>>) visitInfo.get("actionDetails"); 485 Optional<String> pageVisits = actions.stream() 486 .map(a -> (String) a.get("url")) 487 .map(this::_truncateURL) 488 .filter(url -> truncateURL.equals(url)) 489 .findAny(); 490 491 if (pageVisits.isPresent()) 492 { 493 nbVisits++; 494 } 495 } 496 } 497 } 498 return nbVisits; 499 } 500 501 /** 502 * Truncate parameters and fragments of the URL 503 * @param pageUrl the page URL 504 * @return the page URL without parameters and fragments 505 */ 506 protected String _truncateURL(String pageUrl) 507 { 508 return StringUtils.substringBefore(StringUtils.substringBefore(pageUrl, "?"), "#"); 509 } 510 511 private Cache<MatomoComputedCacheKey, Integer> _getComputedVisitsCache() 512 { 513 return _cacheManager.get(__CACHE_COMPUTED_VISITS); 514 } 515 516 private Cache<String, List<Map<String, Object>>> _getLiveVisitsCache() 517 { 518 return _cacheManager.get(__CACHE_LIVE_VISITS); 519 } 520 521 private static final class MatomoComputedCacheKey extends AbstractCacheKey 522 { 523 public MatomoComputedCacheKey(RequestType type, String pageUrl) 524 { 525 super(type, pageUrl); 526 } 527 528 public static MatomoComputedCacheKey of(RequestType type, String pageUrl) 529 { 530 return new MatomoComputedCacheKey(type, pageUrl); 531 } 532 } 533 534 public void dispose() 535 { 536 _httpClient.close(CloseMode.GRACEFUL); 537 } 538}