- Rust 99.2%
- Scheme 0.8%
| .github/workflows | ||
| examples | ||
| src | ||
| tests | ||
| .envrc | ||
| .gitignore | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| manifest.scm | ||
| README.md | ||
matomo-rs
An async Rust client for the Matomo Reporting API, built primarily for data export and migration — pull your visits, actions, and referrers out of Matomo and into whatever you're moving to.
Matomo's API isn't a typical REST surface: it's a single dispatch endpoint (POST /index.php with module=API&method=Module.action plus a shared parameter envelope). This crate wraps that in typed methods for the core reporting modules, with a generic escape hatch for everything else.
Status
Early but working — verified end-to-end against a live Matomo 5.8 instance. It covers the methods that matter for migration:
Live.getLastVisitsDetails— the raw, per-visit clickstream (the migration workhorse), with resumable paging.VisitsSummary.get— aggregate metrics that can't be recomputed from raw data.Actions.*— page URLs, page titles, downloads, outlinks.Referrers.*— referrer types, full referrer list.API.*— version, report metadata, bulk requests.
Anything not yet typed is reachable through call / call_typed / call_raw.
It's also transport-agnostic: the core (Client, Endpoint, Query) carries no HTTP client. A reqwest-backed transport ships behind a feature flag, or you bring your own by implementing Client.
Requirements
- Matomo 5.x.
- An API auth token. Default auth sends it as
token_authin the POST body, which works on all 5.x and keeps the token out of the URL.Authorization: Beareris also supported, but only useful on Matomo 5.4+ and where the web server forwards theAuthorizationheader to PHP — so the body token is the safer default.
Bring your own async runtime — the crate pulls in no runtime directly. It's tested against tokio.
Install
No HTTP client is pulled in by default. For the ready-made reqwest transport, enable a feature with a TLS backend:
[dependencies]
matomo-rs = { version = "0.1", features = ["reqwest", "reqwest-rustls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
futures = "0.3"
Or cargo add matomo-rs --features reqwest,reqwest-rustls. Use reqwest-native-tls instead if you want the OS-native TLS stack.
The library name is matomo, so you use matomo::.... The reqwest-backed client lives at matomo::reqwest::MatomoClient.
Features
reqwest— the bundledMatomoClienttransport. No TLS backend on its own; pair it with one below.reqwest-rustls—reqwestwithrustls.reqwest-native-tls—reqwestwith the OS-native TLS stack.chrono/time—Fromconversions intoDate.
All features are off by default.
Quick start
use matomo::reqwest::MatomoClient;
use matomo::{Auth, Period, Date};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = MatomoClient::builder()
.base_url("https://stats.example.com/") // sub-paths like /matomo/ are fine
.auth(Auth::token(std::env::var("MATOMO_TOKEN")?))
.build()?;
let summary = client
.visits_summary()
.get(1u32, Period::Month(Date::Today), None)
.await?;
println!("{} visits, {} unique", summary.nb_visits, summary.nb_uniq_visitors);
Ok(())
}
Exporting raw visits (streaming)
Live.getLastVisitsDetails is paged. The stream auto-pages until the data runs out (an empty page is the authoritative terminator — a short page is not), and it's 'static + Send, so you can tokio::spawn it.
use futures::StreamExt;
use matomo::reqwest::MatomoClient;
use matomo::{Auth, DateRange, Limit, Period};
# async fn run(client: matomo::reqwest::MatomoClient) -> Result<(), Box<dyn std::error::Error>> {
let period = Period::Range(DateRange::ymd((2024, 1, 1), (2024, 12, 31)));
let page_size = Limit::count(500).unwrap();
let mut visits = client.live().stream(1, period, page_size, None)?;
while let Some(visit) = visits.next().await {
let visit = visit?;
println!("{} {:?} {:?}", visit.id_visit, visit.visitor_type, visit.country);
}
# Ok(())
# }
Resumable paging
For long exports that may crash or restart, page manually with a Cursor (it's Serialize/Deserialize, so you can checkpoint it):
# async fn run(client: matomo::reqwest::MatomoClient) -> Result<(), Box<dyn std::error::Error>> {
use matomo::{Cursor, Date, Limit, Period};
let mut cursor = Some(Cursor::new(
1,
Period::Day(Date::Yesterday),
Limit::count(1000).unwrap(),
None,
)?);
while let Some(c) = cursor {
let (visits, next) = client.live().next_page(&c).await?;
// ... persist `visits` and the `next` cursor ...
cursor = next; // `None` once an empty page is reached
}
# Ok(())
# }
Date ranges
Matomo is picky about range grammar, so the types only let you express what it accepts: a single rolling keyword, or an absolute start with an absolute/today/yesterday end. A keyword start like last7,today simply isn't representable.
use matomo::{DateRange, RangeEnd};
DateRange::LastN(7); // last7
DateRange::PreviousN(30); // previous30
DateRange::ymd((2024, 1, 1), (2024, 6, 30)); // 2024-01-01,2024-06-30
DateRange::Between { from: (2024, 1, 1), to: RangeEnd::Today };
The escape hatch
Not every method is typed yet. For anything else, call it directly and deserialize into your own type (or just a serde_json::Value):
# async fn run(client: matomo::reqwest::MatomoClient) -> Result<(), Box<dyn std::error::Error>> {
use matomo::Params;
let params = Params::new().id_site(1u32).set("period", "day").set("date", "today");
let value: serde_json::Value = client.call("Goals.get", ¶ms).await?;
# Ok(())
# }
call_raw returns the response bytes untouched if you'd rather stream-parse a large payload yourself.
Preflight
On the first call, the client checks the instance version and confirms the reports it depends on are actually exposed, failing early with a clear error instead of a confusing decode failure later. Opt out with MatomoClient::builder().skip_preflight().
Error handling
Matomo signals failures as HTTP 200 with a {"result":"error",...} body, which the client turns into a structured Error::Api — classified into an ApiErrorKind (best-effort, from the message text) and tagged with the method that failed, so a mid-export error is debuggable:
use matomo::{ApiErrorKind, Date, Error, Period};
# async fn run(client: matomo::reqwest::MatomoClient) {
match client.visits_summary().get(1u32, Period::Day(Date::Today), None).await {
Ok(summary) => println!("{} visits", summary.nb_visits),
Err(Error::Api { kind: ApiErrorKind::Auth, message, .. }) => eprintln!("bad token: {message}"),
Err(Error::Api { kind: ApiErrorKind::RateLimited, .. }) => { /* back off and retry */ }
Err(Error::Api { method, message, .. }) => eprintln!("{method} failed: {message}"),
Err(Error::NonJsonBody { body, .. }) => eprintln!("server returned non-JSON: {body}"),
Err(e) => eprintln!("{e}"),
}
# }
Bring your own HTTP client
The reqwest transport is optional. If you'd rather not pull it in — to reuse an existing client, or run on a different stack — implement the Client trait over neutral http/bytes types and drive endpoints through Query:
use bytes::Bytes;
use http::{Request, Response};
use matomo::{Client, Endpoint, Query};
struct MyTransport {
base_url: String, // e.g. "https://stats.example.com/"
token: String,
http: reqwest::Client, // any version, or any HTTP client
}
#[derive(Debug, thiserror::Error)]
enum MyError {
#[error(transparent)]
Http(#[from] http::Error),
#[error(transparent)]
Request(#[from] reqwest::Error),
}
impl Client for MyTransport {
type Error = MyError;
async fn execute(&self, req: Request<Bytes>) -> Result<Response<Bytes>, Self::Error> {
// The core builds a POST to `/index.php` with the form body
// (module/method/format/params). You resolve the base URL and apply auth.
let rsp = self
.http
.post(format!("{}index.php", self.base_url))
.bearer_auth(&self.token) // or append `token_auth=<token>` to the body
.header(http::header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(req.into_body())
.send()
.await?
.error_for_status()?;
let mut out = Response::builder().status(rsp.status());
for (k, v) in rsp.headers() {
out = out.header(k, v.clone());
}
Ok(out.body(rsp.bytes().await?)?)
}
}
// Then drive any endpoint through the trait:
# async fn run(client: MyTransport) {
let version = matomo::endpoints::ApiGetMatomoVersion.execute(&client).await;
# }
To reuse a pre-configured reqwest::Client (custom TLS, proxy, timeouts) without writing a transport, pass it in: MatomoClient::with_reqwest_client(base_url, auth, http).
A few caveats
- Matomo's JSON is loose — numbers arrive as strings, empty results flip between
[]and{}, fields come and go between releases. The models handle the known cases and deliberately don't reject unknown fields, but if you hit a shape that doesn't deserialize, the escape hatch is your friend (and a bug report is welcome). - The
Visitmodel keeps the data-bearing fields and drops Matomo's presentation fields (*Pretty, icons, flags). Usecall_rawif you need the raw lot. - Aggregate metrics like unique visitors can't be derived from raw visits — use
VisitsSummary.getfor those.
Testing
Unit tests run against mocked HTTP — no network, no credentials:
cargo test
The integration test hits a real Matomo instance and is #[ignore]d by default. Point it at an instance with a read-only token:
MATOMO_URL=https://stats.example.com/ \
MATOMO_TOKEN=your-token \
MATOMO_IDSITE=1 \
cargo test --test live_integration -- --ignored
License
MIT OR Apache-2.0.