Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,19 @@ jobs:
run: npm run build

- name: Check Rust
run: cargo check
run: cargo check

- name: Test Rust
run: cargo test

# The rustls backends exist so apps can build for Linux and Android without OpenSSL (#34).
- name: Check rustls builds don't pull in OpenSSL
run: |
for target in x86_64-unknown-linux-gnu aarch64-linux-android; do
for features in "" "--no-default-features --features rustls-tls"; do
if cargo tree -e normal --target "$target" $features --prefix none | grep -E '^(openssl-sys|native-tls) '; then
echo "::error::OpenSSL in the dependency tree for $target ${features:-(default features)}"
exit 1
fi
done
done
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- Use the system proxy settings on macOS and Windows again. Turning off reqwest's default features to add the TLS feature flags (#34) also turned off its `system-proxy`, `http2` and `charset` features; they're back on, and the TLS backend is still chosen with `default-tls`, `rustls-tls` or `native-tls`
- `rust-version` is 1.85.0: reqwest 0.13 and rand 0.10 don't build on 1.81

### Added

- Tests for event delivery (batches of 25, what's kept for the next flush and what's dropped, the event format, polling) and for App Key validation, run in CI
- README sections on delivery and on the TLS backend features

## [1.2.0] - 2026-08-11

### Changed
Expand Down
8 changes: 6 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license = "MIT"
description = "Tauri Plugin for Aptabase: Open Source, Privacy-First and Simple Analytics for Mobile, Desktop and Web Apps"
authors = ["Guilherme Oenning"]
edition = "2021"
rust-version = "1.81.0"
rust-version = "1.85.0"
readme = "README.md"
repository = "https://github.com/aptabase/tauri-plugin-aptabase"
exclude = ["/examples", "/webview-dist", "/webview-src", "/node_modules"]
Expand All @@ -17,13 +17,17 @@ tokio = "1.53.1"
futures = "0.3.31"
serde = "1.0.229"
serde_json = "1.0.151"
reqwest = { version = "0.13.4", default-features = false, features = ["json"] }
# Only reqwest's TLS default is turned off (see the TLS features below); its other defaults stay on.
reqwest = { version = "0.13.4", default-features = false, features = ["json", "charset", "http2", "system-proxy"] }
time = { version = "0.3.41", features = ["formatting"] }
os_info = "3.12.0"
rand = "0.10.0"
log = "0.4.27"
sys-locale = "0.3.2"

[dev-dependencies]
time = { version = "0.3.41", features = ["parsing"] }

[build-dependencies]
tauri-plugin = { version = "2.6.3", features = ["build"] }

Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,34 @@ pub fn run() {
}
```

## Delivery

Events are queued in memory and sent in the background: every 60 seconds in release builds and every 2 seconds in debug builds (set `flush_interval` in `InitOptions` to change it), and whenever you call `flush_events_blocking()`.

- Each request carries up to 25 events.
- If a request doesn't reach the server (no network, a timeout) or the server answers with a 5xx status, its events stay queued for the next flush.
- If the server answers with any other unsuccessful status, such as a 4xx, its events are dropped.
- The queue isn't saved to disk.

Requests use the system proxy settings on macOS and Windows, and the `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` and `NO_PROXY` environment variables on every platform.

## TLS backends

The plugin sends events with [reqwest](https://crates.io/crates/reqwest). You choose its TLS backend with one of these Cargo features:

| Feature | Backend |
| --- | --- |
| `default-tls` (default) | reqwest's default backend, currently [rustls](https://crates.io/crates/rustls) |
| `rustls-tls` | rustls |
| `native-tls` | The platform's TLS library: OpenSSL on Linux and Android, Security.framework on Apple platforms, SChannel on Windows |

To switch backend, turn off the default features:

```toml
[dependencies]
tauri-plugin-aptabase = { version = "1.2", default-features = false, features = ["native-tls"] }
```

Neither `default-tls` nor `rustls-tls` needs OpenSSL, so you can cross-compile for Android and Linux without it.

For AI/LLM integration instructions, see [llms.txt](./llms.txt)
12 changes: 11 additions & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,21 @@ tauri::Builder::default()
- `host` — required only for self-hosted Aptabase instances (app keys starting with `A-SH-*`)
- `flush_interval` — defaults to 60s in release, 2s in debug mode
- Events are batched and flushed periodically; `flush_events_blocking()` forces an immediate flush
- Up to 25 events per request; network failures and 5xx responses keep the events queued for the next flush, other unsuccessful responses drop them
- Requests use the system proxy settings (macOS, Windows) and the `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` environment variables

## TLS Backend

Cargo features choose the TLS backend: `default-tls` (default, rustls), `rustls-tls`, or `native-tls` (OpenSSL on Linux and Android). To switch, turn off the default features:

```toml
tauri-plugin-aptabase = { version = "1.2", default-features = false, features = ["native-tls"] }
```

## Platform Notes

- Requires Tauri v2 (2.x)
- Rust edition 2021, MSRV 1.70
- Rust edition 2021, MSRV 1.85
- The `EventTracker` trait is implemented for `App`, `AppHandle`, and `Window`
- The JS `trackEvent` is async but you don't need to await it
- A custom panic hook can be set via `Builder::with_panic_hook()` to track crashes
Expand Down
39 changes: 39 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,42 @@ impl Default for Config {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

fn options(host: Option<&str>) -> InitOptions {
InitOptions {
host: host.map(Into::into),
flush_interval: None,
}
}

#[test]
fn a_self_hosted_app_key_sends_to_its_host() {
let config = Config::new(
"A-SH-0000000000".into(),
options(Some("http://127.0.0.1:8000")),
);
assert_eq!(config.app_key, "A-SH-0000000000");
assert_eq!(
config.ingest_api_url.as_str(),
"http://127.0.0.1:8000/api/v0/events"
);
}

// An empty App Key is what turns tracking off: the client drops every event it is given.
#[test]
fn an_invalid_app_key_turns_tracking_off() {
let host = Some("http://127.0.0.1:8000");
for (app_key, host) in [
("A-XX-0000000000", host),
("not-an-app-key", host),
("A-SH-0000000000", None),
] {
let config = Config::new(app_key.into(), options(host));
assert_eq!(config.app_key, "", "{app_key} with host {host:?}");
}
}
}
173 changes: 173 additions & 0 deletions src/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,176 @@ impl EventDispatcher {
self.enqueue_many(failed_items);
}
}

// Delivery end to end: events tracked on a client and flushed to a local server.
#[cfg(test)]
mod tests {
use std::{
net::TcpListener,
time::{Duration, Instant},
};

use serde_json::{json, Value};
use tauri::async_runtime::block_on;
use time::{format_description::well_known::Rfc3339, OffsetDateTime};

use crate::{
client::AptabaseClient,
config::Config,
test_server::{server, server_on, Server},
InitOptions,
};

const APP_KEY: &str = "A-SH-0000000000";

fn client(base: &str) -> AptabaseClient {
let options = InitOptions {
host: Some(base.into()),
flush_interval: None,
};
AptabaseClient::new(&Config::new(APP_KEY.into(), options), "1.4.0".into())
}

/// The event names in each request the server got.
fn batches(server: &Server) -> Vec<Vec<String>> {
let requests = server.requests.lock().unwrap();
requests
.iter()
.map(|(_, body)| {
let events = body.as_array().unwrap();
events
.iter()
.map(|event| event["eventName"].as_str().unwrap().to_string())
.collect()
})
.collect()
}

fn keys(value: &Value) -> Vec<&str> {
let mut keys: Vec<&str> = value
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort();
keys
}

#[test]
fn events_go_out_in_batches_of_25() {
let server = server(&[200, 200, 200]);
let client = client(&server.base);
for i in 0..60 {
client.track_event(&format!("event_{i}"), None).unwrap();
}
block_on(client.flush());
let sizes: Vec<usize> = batches(&server).iter().map(Vec::len).collect();
assert_eq!(sizes, [25, 25, 10]);
}

#[test]
fn a_server_error_keeps_the_events_for_the_next_flush() {
let server = server(&[503, 200]);
let client = client(&server.base);
client.track_event("app_started", None).unwrap();
client.track_event("app_exited", None).unwrap();
block_on(client.flush());
block_on(client.flush());
assert_eq!(
batches(&server),
[["app_started", "app_exited"], ["app_started", "app_exited"]]
);
}

#[test]
fn a_network_failure_keeps_the_events_for_the_next_flush() {
let addr = TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap();
let client = client(&format!("http://{addr}"));
client.track_event("app_started", None).unwrap();
// Nothing listens on the port yet: the connection is refused.
block_on(client.flush());
let server = server_on(TcpListener::bind(addr).unwrap(), &[200]);
block_on(client.flush());
assert_eq!(batches(&server), [["app_started"]]);
}

#[test]
fn a_client_error_drops_the_events() {
let server = server(&[400, 200]);
let client = client(&server.base);
client.track_event("app_started", None).unwrap();
block_on(client.flush());
block_on(client.flush());
assert_eq!(batches(&server), [["app_started"]]);
}

#[test]
fn events_are_sent_in_the_aptabase_format() {
let server = server(&[200]);
let client = client(&server.base);
let props = json!({ "plan": "pro", "seats": 3 });
client
.track_event("app_started", Some(props.clone()))
.unwrap();
block_on(client.flush());

let requests = server.requests.lock().unwrap();
let (app_key, body) = &requests[0];
assert_eq!(app_key, APP_KEY);
let event = &body[0];
assert_eq!(
keys(event),
[
"eventName",
"props",
"sessionId",
"systemProps",
"timestamp"
]
);
assert_eq!(event["eventName"], "app_started");
assert_eq!(event["props"], props);
assert!(!event["sessionId"].as_str().unwrap().is_empty());
OffsetDateTime::parse(event["timestamp"].as_str().unwrap(), &Rfc3339).unwrap();

let system = &event["systemProps"];
assert_eq!(
keys(system),
[
"appVersion",
"engineName",
"engineVersion",
"isDebug",
"locale",
"osName",
"osVersion",
"sdkVersion"
]
);
assert_eq!(system["appVersion"], "1.4.0");
assert_eq!(
system["sdkVersion"],
concat!("tauri-plugin-aptabase@", env!("CARGO_PKG_VERSION"))
);
assert_eq!(system["isDebug"], cfg!(debug_assertions));
}

#[test]
fn polling_flushes_without_a_manual_flush() {
let server = server(&[200]);
let client = client(&server.base);
client.start_polling(Duration::from_millis(50));
client.track_event("app_started", None).unwrap();
let started = Instant::now();
while server.requests.lock().unwrap().is_empty()
&& started.elapsed() < Duration::from_secs(5)
{
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(batches(&server), [["app_started"]]);
}
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ mod commands;
mod config;
mod dispatcher;
mod sys;
#[cfg(test)]
mod test_server;

use std::{panic::PanicHookInfo, sync::Arc, time::Duration};

Expand Down
Loading
Loading