Skip to content

Commit

Permalink
Add initial support for Matomo data collection via Paella player (#1099)
Browse files Browse the repository at this point in the history
This add the ability to configure Paella to send statistical data to a
Matomo instance. Closes #1038

There is additional support planned, with Tobira sending events to
Matomo as well. But this is not part of this PR.

See the added docs and commit messages for more information.
  • Loading branch information
owi92 authored Feb 22, 2024
2 parents 2c8161e + 83ac404 commit 7458127
Show file tree
Hide file tree
Showing 16 changed files with 224 additions and 3 deletions.
58 changes: 58 additions & 0 deletions backend/src/config/matomo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
use hyper::Uri;

use crate::prelude::*;


#[derive(Debug, confique::Config)]
pub(crate) struct MatomoConfig {
/// URL of your Matomo server. Example: "https://matomo.myuni.edu/matomo/".
///
/// Note: Adding `matomo.js` to the URL configured here should result in a
/// URL to a publicly accessible JS file.
#[config(deserialize_with = deserialize_server)]
pub(crate) server: Option<Uri>,

/// Matomo site ID, e.g. `side_id = "1"`
pub(crate) site_id: Option<String>,
}

impl MatomoConfig {
/// Returns the JS code for initializing the Matomo tracking or `None` if
/// Matomo is not configured.
pub(crate) fn js_code(&self) -> Option<String> {
let (Some(server), Some(site_id)) = (&self.server, &self.site_id) else {
return None;
};

let out = format!(r#"
// Matomo tracking code
var _paq = window._paq = window._paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {{
var u="{server}";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', '{site_id}']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
}})();
"#);

// Fix indentation, super duper important.
Some(out.replace("\n ", "\n "))
}
}

fn deserialize_server<'de, D>(deserializer: D) -> Result<Uri, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::{Deserialize, de::Error};

let uri: Uri = String::deserialize(deserializer)?
.parse()
.map_err(|e| D::Error::custom(format!("invalid URL: {e}")))?;

Ok(uri)
}
11 changes: 11 additions & 0 deletions backend/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ mod color;
mod general;
mod theme;
mod translated_string;
mod matomo;
mod opencast;
mod player;
mod upload;

pub(crate) use self::{
translated_string::TranslatedString,
theme::ThemeConfig,
matomo::MatomoConfig,
opencast::OpencastConfig,
player::PlayerConfig,
upload::UploadConfig,
};

Expand Down Expand Up @@ -85,6 +89,13 @@ pub(crate) struct Config {

#[config(nested)]
pub(crate) upload: UploadConfig,

/// Matomo integration (optional). Currently only used by Paella if configured.
#[config(nested)]
pub(crate) matomo: MatomoConfig,

#[config(nested)]
pub(crate) player: PlayerConfig,
}

impl Config {
Expand Down
31 changes: 31 additions & 0 deletions backend/src/config/player.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use crate::prelude::*;

#[derive(Debug, confique::Config)]
pub(crate) struct PlayerConfig {
/// Additional Paella plugin configuration (JSON object). This is merged
/// into the `plugins` object in the Tobira-internal Paella config.
/// Warning: this could break Paella if used incorrectly. This is mostly
/// intended to configure user tracking, e.g.:
///
/// ```
/// paella_plugin_config = """{
/// "es.upv.paella.userEventTracker": { ... },
/// "es.upv.paella.matomo.userTrackingDataPlugin": { ... }
/// }"""
/// ```
#[config(default = "{}", deserialize_with = deserialize_paella_plugin_config)]
pub paella_plugin_config: serde_json::Map<String, serde_json::Value>,
}

fn deserialize_paella_plugin_config<'de, D>(
deserializer: D,
) -> Result<serde_json::Map<String, serde_json::Value>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::{Deserialize, de::Error};

let s = String::deserialize(deserializer)?;
serde_json::from_str(&s)
.map_err(|e| D::Error::custom(format!("invalid JSON object: {e}")))
}
3 changes: 3 additions & 0 deletions backend/src/http/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ impl Assets {
variables.insert("upload".into(), json!({
"requireSeries": config.upload.require_series,
}).to_string());
variables.insert("paella-plugin-config".into(),
serde_json::Value::from(config.player.paella_plugin_config.clone()).to_string());
variables.insert("matomo-code".into(), config.matomo.js_code().unwrap_or_default());

// Note the mismatch between presentation and sync node;
// these might not be the same forever!
Expand Down
10 changes: 9 additions & 1 deletion backend/src/http/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,14 @@ impl CommonHeadersExt for hyper::http::response::Builder {
"'none'".into()
};

// TODO: when this is fixed, use `format_args!` to avoid the useless
// space in the None case below.
// https://github.com/rust-lang/rust/issues/92698
let matomo_url = match &config.matomo.server {
Some(server) => server as &dyn std::fmt::Display,
None => &"",
};

// Some comments about all relaxations:
//
// - `img` and `media` are loaded from Opencast. We know one URL host,
Expand Down Expand Up @@ -510,7 +518,7 @@ impl CommonHeadersExt for hyper::http::response::Builder {
img-src *; \
media-src * blob:; \
font-src *; \
script-src 'self' 'nonce-{nonce}'; \
script-src 'self' 'nonce-{nonce}' {matomo_url}; \
style-src 'self' 'unsafe-inline'; \
connect-src *; \
worker-src blob: 'self'; \
Expand Down
27 changes: 27 additions & 0 deletions docs/docs/setup/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -519,3 +519,30 @@
#
# Default value: false
#require_series = false


# Matomo integration (optional). Currently only used by Paella if configured.
[matomo]
# URL of your Matomo server. This URL + `matomo.js` should be a publicly
# accessible JS file. Example: "https://matomo.myuni.edu/matomo/".
#server =

# Matomo site ID, e.g. `side_id = "1"`
#site_id =


[player]
# Additional Paella plugin configuration (JSON object). This is merged
# into the `plugins` object in the Tobira-internal Paella config.
# Warning: this could break Paella if used incorrectly. This is mostly
# intended to configure user tracking, e.g.:
#
# ```
# paella_plugin_config = """{
# "es.upv.paella.userEventTracker": { ... },
# "es.upv.paella.matomo.userTrackingDataPlugin": { ... }
# }"""
# ```
#
# Default value: "{}"
#paella_plugin_config = "{}"
57 changes: 57 additions & 0 deletions docs/docs/setup/matomo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
sidebar_position: 11
---

# Matomo

To collect statistical data about Tobira usage, you can use [Matomo](https://matomo.org/).
Tobira's Matomo integration is still very basic and currently only allows you to configure the Paella player to send events.
Tobira itself does not yet send any data.

## Privacy, GDPR and consent

Before collecting any data, you have to understand the legal situation of doing so.
There are two resources on this topic by Matomo:
- [Lawful basis for processing personal data under GDPR with Matomo](https://matomo.org/blog/2018/04/lawful-basis-for-processing-personal-data-under-gdpr-with-matomo/)
- [How to not process any personally identifiable information (PII) with Matomo, and what it means for you](https://matomo.org/blog/2018/04/how-to-not-process-any-personal-data-with-matomo-and-what-it-means-for-you/)

The best way to comply with the law is to make sure the data you collect is no "personal data"/"personally identifiable information".
If you must, you can instead comply with the law by asking for the user's consent.
To do that, check the `general.initial_consent` value in configuration file.


## Configuration

First, you have to tell Tobira about your Matomo server so that the correct tracking code can be loaded.

```toml
[matomo]
server = "https://matomo.test.tobira.ethz.ch/matomo/"
site_id = "1"
```

This alone won't make anything interesting happen though, as Tobira itself does not yet send any events to Matomo itself.
In order to get anything out of this, you have to configure Paella to do so.

```toml
[player]
paella_plugin_config = """{
"es.upv.paella.userEventTracker": {
"enabled": true,
"context": "userTracking"
},
"es.upv.paella.matomo.userTrackingDataPlugin": {
"enabled": true,
"context": ["userTracking"],
"matomoGlobalLoaded": true,
"events": {
"category": "PaellaPlayer",
"action": "${event}",
"name": "${videoId}"
}
}
}"""
```

See [the Paella docs](https://github.com/polimediaupv/paella-user-tracking?tab=readme-ov-file#matomo-user-tracking-data-plugin) for more information on configuring this.
Note though that `matomoGlobalLoaded` should be `true`.
9 changes: 9 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"paella-basic-plugins": "1.44.4",
"paella-core": "1.46.6",
"paella-skins": "1.32.4",
"paella-user-tracking": "1.42.0",
"paella-zoom-plugin": "1.41.1",
"qrcode.react": "^3.1.0",
"raw-loader": "^4.0.2",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type Config = {
logo: LogoConfig;
plyr: PlyrConfig;
upload: UploadConfig;
paellaPluginConfig: object;
};

type FooterLink = "about" | "graphiql" | {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"usersSearchable": {{: var:users-searchable :}},
"footerLinks": {{: var:footer-links :}},
"metadataLabels": {{: var:metadata-labels :}},
"paellaPluginConfig": {{: var:paella-plugin-config :}},
"opencast": {
"presentationNode": "{{: var:presentation-node :}}",
"uploadNode": "{{: var:upload-node :}}",
Expand Down Expand Up @@ -54,6 +55,7 @@
document.documentElement.dataset.colorScheme = (scheme === "dark" || scheme === "light")
? scheme
: (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
{{: var:matomo-code :}}
</script>
</head>
<body>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/routes/Embed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const query = graphql`
title
created
isLive
opencastId
syncedData {
updated
startTime
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/typings/paella-user-tracking.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare module "paella-user-tracking" {
export default function (): __WebpackModuleApi.RequireContext;
}
1 change: 1 addition & 0 deletions frontend/src/ui/Blocks/Video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const VideoBlock: React.FC<Props> = ({ fragRef, basePath }) => {
id
title
isLive
opencastId
created
syncedData {
duration
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/ui/player/Paella.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { Config, Manifest, Paella, Source, Stream } from "paella-core";
import getBasicPluginsContext from "paella-basic-plugins";
import getZoomPluginContext from "paella-zoom-plugin";
import getUserTrackingPluginsContext from "paella-user-tracking";
import { Global } from "@emotion/react";
import { useTranslation } from "react-i18next";

Expand All @@ -10,9 +11,11 @@ import { SPEEDS } from "./consts";
import { timeStringToSeconds } from "../../util";
import { usePlayerContext } from "./PlayerContext";
import { usePlayerGroupContext } from "./PlayerGroupContext";
import CONFIG from "../../config";


type PaellaPlayerProps = {
opencastId: string;
title: string;
duration: number;
tracks: readonly Track[];
Expand All @@ -29,7 +32,7 @@ export type PaellaState = {
};

const PaellaPlayer: React.FC<PaellaPlayerProps> = ({
tracks, title, duration, isLive, captions, startTime, endTime, previewImage,
opencastId, tracks, title, duration, isLive, captions, startTime, endTime, previewImage,
}) => {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -108,13 +111,14 @@ const PaellaPlayer: React.FC<PaellaPlayerProps> = ({
// override all functions (which Paella luckily allows) to do
// nothing except immediately return the data.
loadConfig: async () => PAELLA_CONFIG as Config,
getVideoId: async () => "dummy-id",
getVideoId: async () => opencastId,
getManifestUrl: async () => "dummy-url",
getManifestFileUrl: async () => "dummy-file-url",
loadVideoManifest: async () => manifest,
customPluginContext: [
getBasicPluginsContext(),
getZoomPluginContext(),
getUserTrackingPluginsContext(),
],
});

Expand Down Expand Up @@ -421,6 +425,9 @@ const PAELLA_CONFIG = {
"order": 0,
"context": ["default", "trimming"],
},

// Let admin provided config add and override entries.
...CONFIG.paellaPluginConfig,
},
};

Expand Down
1 change: 1 addition & 0 deletions frontend/src/ui/player/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type PlayerEvent = {
title: string;
created: string;
isLive: boolean;
opencastId: string;
syncedData: {
updated: string;
startTime: string | null;
Expand Down

0 comments on commit 7458127

Please sign in to comment.