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