diff --git a/roles/podman_cleaninsights/defaults/main.yml b/roles/podman_cleaninsights/defaults/main.yml index 322e06a..eeff222 100644 --- a/roles/podman_cleaninsights/defaults/main.yml +++ b/roles/podman_cleaninsights/defaults/main.yml @@ -5,3 +5,4 @@ podman_cleaninsights_mysql_database: matomo # podman_cleaninsights_mysql_root_password: podman_cleaninsights_php_memory_limit: 2048M podman_cleaninsights_mysql_user: matomo +podman_cleaninsights_matomo_token: "" \ No newline at end of file diff --git a/roles/podman_cleaninsights/handlers/main.yml b/roles/podman_cleaninsights/handlers/main.yml index 667381f..e0735da 100644 --- a/roles/podman_cleaninsights/handlers/main.yml +++ b/roles/podman_cleaninsights/handlers/main.yml @@ -18,7 +18,7 @@ - name: restart redis ansible.builtin.systemd_service: - name: "{{ podman_cleaninsights_systemd_service_slice }}" + name: redis state: restarted scope: user daemon_reload: true diff --git a/roles/podman_cleaninsights/tasks/main.yml b/roles/podman_cleaninsights/tasks/main.yml index 965624a..296d7e0 100644 --- a/roles/podman_cleaninsights/tasks/main.yml +++ b/roles/podman_cleaninsights/tasks/main.yml @@ -11,6 +11,25 @@ - mysql - matomo + +- name: install cleaninsights script + ansible.builtin.copy: + src: templates/cleaninsights.php + dest: "/home/{{ podman_cleaninsights_podman_rootless_user }}/matomo/cleaninsights.php" + owner: "{{ podman_cleaninsights_podman_rootless_user }}" + group: "{{ podman_cleaninsights_podman_rootless_user }}" + mode: "0755" + become: true + +- name: install cleaninsights configuration file + ansible.builtin.template: + src: "cleaninsights.ini.j2" + dest: "/home/{{ podman_cleaninsights_podman_rootless_user }}/matomo/cleaninsights.ini" + owner: "{{ podman_cleaninsights_podman_rootless_user }}" + group: "{{ podman_cleaninsights_podman_rootless_user }}" + mode: "0755" + become: true + - name: install podman quadlet for rootless podman user ansible.builtin.template: src: "{{ item }}" diff --git a/roles/podman_cleaninsights/templates/cleaninsights.ini.j2 b/roles/podman_cleaninsights/templates/cleaninsights.ini.j2 new file mode 100644 index 0000000..01ea4b4 --- /dev/null +++ b/roles/podman_cleaninsights/templates/cleaninsights.ini.j2 @@ -0,0 +1,22 @@ +; A Matomo token for authentication to enable retrograde setting of timestamps. +; Needs to be set for CIMP to work. See https://matomo.org/faq/general/faq_114/ +token_auth = '{{ podman_cleaninsights_matomo_token }}' + +; Allowed CORS scheme/domain/port tuples. Defaults to none allowed. +; cors[] = 'https://example.com:3000' + +; The base URL of your Matomo instance. Only needs to be set if CIMP is +; not installed inside the Matomo directory.ca +matomo_base_url = 'http://nginx' + +; Number of created tracking requests to send to Matomo at once. + chunk_size = 1000 + +; Number of seconds to wait before retry, if Matomo responded with an error. +delay_after_failure = 2 + +; Number of maximum retries on error. +max_attempts = 3 + +; Set to true to have a debug output in 'ciapi.log' in the same directory. +debug = true \ No newline at end of file diff --git a/roles/podman_cleaninsights/templates/cleaninsights.php b/roles/podman_cleaninsights/templates/cleaninsights.php new file mode 100644 index 0000000..d72a67a --- /dev/null +++ b/roles/podman_cleaninsights/templates/cleaninsights.php @@ -0,0 +1,341 @@ +idsite < 1) { + debug('Malformed body. Last JSON error:' . json_last_error() . ' ' . json_last_error_msg()); + http_response_code(400); + exit(1); +} + + +// Build output for Matomo Tracking API and send in chunks. + +$output = []; + +$reporting_start = time(); +$reporting_end = 0; +$count = 0; +$result = false; + +if (property_exists($data, 'visits') && gettype($data->visits) === 'array') { + foreach ($data->visits as $visit) { + $count += renderVisit($visit); + } +} + +if (property_exists($data, 'events') && gettype($data->events) === 'array') { + foreach ($data->events as $event) { + $count += renderEvent($event); + } +} + +// Send the rest. +send(false); + +debug('result = ' . ($result ? 'success': 'failure') . ", count = $count"); + +if ($result === false) { + debug('There were no events and visits or the Matomo Tracking API refused all requests.'); + http_response_code(400); + exit(1); +} + + +// Instruct Matomo to re-analyse the complete period of this request +// on the next auto-archiving run. + +$start = new DateTime(); +$start->setTimestamp($reporting_start); + +$end = new DateTime(); +$end->setTimestamp($reporting_end); + +$query = http_build_query([ + 'module' => 'API', + 'method' => 'CoreAdminHome.invalidateArchivedReports', + 'idSites' => $data->idsite, + 'period' => 'range', + // Need to use `dates[]` instead of `dates`. Otherwise, some automagic happens in Matomo and breaks the call. + 'dates[]' => $start->format('Y-m-d') . ',' . $end->format('Y-m-d'), + 'token_auth' => $TOKEN_AUTH, +]); + +$result = callMatomo(null, $query); + +if ($result === false) { + debug('Matomo could not be triggered to reprocess data!'); +} +else { + debug('Matomo was triggered to reprocess data!'); +} + +http_response_code(204); + +exit(); + + +// Helper functions. + +function renderVisit(stdClass $visit): int { + $action_name = $visit->action_name; + + return render($visit->period_start, $visit->period_end, $visit->times, + function() use ($action_name) { + return ['action_name' => $action_name]; + }); +} + +function renderEvent(stdClass $event): int { + return render($event->period_start, $event->period_end, $event->times, + function() use ($event) { + $e = ['e_c' => $event->category, 'e_a' => $event->action]; + + if (property_exists($event, 'name') && $event->name) { + $e['e_n'] = $event->name; + } + + if (property_exists($event, 'value') && $event->value) { + $e['e_v'] = $event->value; + } + + return $e; + }); +} + +function render(int $period_start, int $period_end, int $times, callable $callback): int { + global $reporting_start, $reporting_end, $output; + + $reporting_start = min($reporting_start, $period_start); + $reporting_end = max($reporting_end, $period_end); + + $interval = ($period_end - $period_start) / $times; + + // Offset the first record by half an interval, so records are neatly "centered" in the period. + $period_start += $interval / 2; + + for ($i = 0; $i < $times; $i++) { + $request = $callback(); + + $request['cdt'] = $period_start + $interval * $i; + + $output[] = buildRequest($request); + + send(); + } + + return $times; +} + +function buildRequest(array &$request): string { + global $data; + + $request['idsite'] = $data->idsite; + $request['rec'] = 1; + if (property_exists($data, 'lang') && $data->lang) $request['lang'] = $data->lang; + if (property_exists($data, 'ua') && $data->ua) $request['ua'] = $data->ua; + + return '?' . http_build_query($request); +} + +function callMatomo(string $path = null, string $query = null, string $body = null): string { + global $MATOMO_BASE_URL, $MAX_ATTEMPTS, $DELAY_AFTER_FAILURE; + + $options = [ + 'method' => 'POST', + 'header' => 'Content-Type: application/json; charset=UTF-8']; + + if (isset($body)) { + $options['content'] = $body; + } + + $context = stream_context_create(['http' => $options]); + + $url = "$MATOMO_BASE_URL/"; + + if (isset($path)) { + $url .= $path; + } + + if (isset($query)) { + $url .= "?$query"; + } + + $counter = 1; + + debug("Call Matomo at $url"); + + do { + $result = file_get_contents($url, false, $context); + + $counter++; + + if ($result !== false || $counter > $MAX_ATTEMPTS) { + break; + } + + debug("Request error. Sleep $DELAY_AFTER_FAILURE seconds and retry!"); + + sleep($DELAY_AFTER_FAILURE); + + } while (true); + + return $result; +} + +function sendToMatomo(array &$output): bool { + global $TOKEN_AUTH; + + $body = json_encode([ + 'token_auth' => $TOKEN_AUTH, + 'requests' => $output]); + + if ($body === false) { + return false; + } + + $result = callMatomo("matomo.php", null, $body); + + debug("Called tracking API " . (!$result ? 'un' : '') . "successfully with " + . count($output) . " lines of data: " . substr($body, 0, 256)); + + return !!$result; +} + +function send(bool $onlyIfChunkSizeReached = true) { + global $output, $CHUNK_SIZE, $result; + + if (!$onlyIfChunkSizeReached || count($output) >= $CHUNK_SIZE) { + if (sendToMatomo($output)) { + // If at least one request succeeded, we consider this good. + // Otherwise, the client would re-send already processed data. + $result = true; + } + + // Remove already sent requests from memory again. + $output = []; + } +} + +function debug($message) { + global $DEBUG; + if (!$DEBUG) return; + + error_log(print_r($message, true)); +} \ No newline at end of file diff --git a/roles/podman_cleaninsights/templates/matomo.container b/roles/podman_cleaninsights/templates/matomo.container index c5e9c70..fd9d168 100644 --- a/roles/podman_cleaninsights/templates/matomo.container +++ b/roles/podman_cleaninsights/templates/matomo.container @@ -13,8 +13,8 @@ Environment=MATOMO_DATABASE_PASSWORD={{ podman_cleaninsights_mysql_password }} Environment=MATOMO_DATABASE_USERNAME={{ podman_cleaninsights_mysql_user }} Image=docker.io/matomo:5-fpm Volume=/home/{{ podman_cleaninsights_podman_rootless_user }}/matomo:/var/www/html -#Volume=/home/{{ podman_cleaninsights_podman_rootless_user }}/cleaninsights.php:/var/www/html/cleaninsights.php:ro -#Volume=/home/{{ podman_cleaninsights_podman_rootless_user }}/cleaninsights.ini:/var/www/html/cleaninsights.ini:ro +Volume=/home/{{ podman_cleaninsights_podman_rootless_user }}/matomo/cleaninsights.php:/var/www/html/cleaninsights.php:ro +Volume=/home/{{ podman_cleaninsights_podman_rootless_user }}/matomo/cleaninsights.ini:/var/www/html/cleaninsights.ini:ro Network=cleaninsights.network Network=frontend.network