Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Telemetry & Privacy Reference

Teksilo's telemetry is consent-gated by construction and privacy-mode-switchable at runtime. There is no path through which an event can reach a server while the user's ConsentState is Unknown or Denied — the gate lives in the dispatch tap, before any adapter sees the event. Apps that ship without telemetry pay nothing; apps that ship with it inherit a working RGPD-compliant shape (Art. 13 notice + per-scope toggles + Art. 15/17/20 buttons) out of the box.

Mental model in one line:

TelemetryBundle → OpenedTelemetry → app_state registry → dispatch tap → consent gate → adapter

The two modes are an architectural choice, not a tunable knob:

Modeinstall_idDefault scopes availableWhat the user can do
AnonymousNoneanonymous_metrics_only()Withdraw consent
PseudonymousUUID, 13-month rotationConsentScope::all()Withdraw + Get my data + Erase

Apps configure one or both adapters; users (or the framework's mode-switch UI) flip between them.

Three persistence shapes come with the crate:

ShapeTypeUse for
Consent stateConsentStoreSignal<ConsentState>The user's grant/deny/scope decision, atop SettingsFile<ConsentFile>
Pseudonymous identityInstallIdPer-install UUID with 13-month rotation, atop SettingsFile<InstallIdFile>
Event queueInMemoryEventQueue / PersistentEventQueueOutbound buffering with retry; redb-backed for cross-restart durability

Two reference adapters ship in tree:

AdapterCrateMode(s)Backend
StubReporterteksilo-telemetryAnonymous + PseudonymousIn-memory Vec (testing only)
PlausibleAdapterteksilo-analytics-plausibleAnonymousPlausible Cloud or self-hosted
TeksiloAdapterteksilo-analytics-nativeAnonymous + PseudonymousSelf-hosted teksilo-collector gRPC service

1. Quick start

Wire telemetry through TeksiloAppBuilder alongside settings(...) — both go through the same builder-time validation pattern:

use teksilo::prelude::*;
use teksilo::app::TeksiloAppBuilder;
use teksilo::settings::SettingsBundle;
use teksilo_analytics_native::TeksiloAdapter;
use teksilo_telemetry::{TelemetryBundle, TelemetryMode, UsageReporter};
use std::rc::Rc;

const EVENT_SCHEMA_VERSION: u32 = 1;

fn main() {
    let adapter = Rc::new(
        TeksiloAdapter::builder()
            .endpoint("https://collector.example.com:50051")
            .product_id("my.app")
            .bearer_token(std::env::var("TEKSILO_TOKEN").unwrap())
            .build(),
    ) as Rc<dyn UsageReporter>;

    let telemetry = TelemetryBundle::new(EVENT_SCHEMA_VERSION)
        .with_anonymous(adapter)
        .with_default_mode(TelemetryMode::Anonymous)
        .with_data_processor_name("MyCo SAS");

    TeksiloAppBuilder::new()
        .application("eu", "MyCo", "my-app")
        .settings(SettingsBundle::new())
        .telemetry(telemetry)        // <— telemetry wires in here
        .initial_window(/* ... */)
        .run();
}

Three things happen on .run():

  1. The TelemetryBundle opens — ConsentStore, InstallId (when in pseudonymous mode), the recent-log ring buffer, and the DynamicReporter are constructed.
  2. The resulting OpenedTelemetry is registered into the app_state registry — accessible from any widget via TelemetryExt.
  3. The dispatch tap in teksilo-core starts forwarding every dispatched intent through DynamicReporter::record, but the consent gate silently drops everything until the user grants — the app stays functional in the Unknown state, no events leave.

Drop the PrivacySettings widget anywhere in the tree (typically a settings tab or first-run modal) and the user's grant flow + every Art. 13/15/17/20 obligation is wired.


2. The pieces

2.1 TelemetryBundle — declarative configuration

Mirror of SettingsBundle. Builder-time validation; opens at TeksiloAppBuilder::run() time once AppPaths and the SettingsStore are available.

#![allow(unused)]
fn main() {
TelemetryBundle::new(event_schema_version)
    .with_anonymous(adapter)              // Rc<dyn UsageReporter>
    .with_pseudonymous(other_adapter)     // optional second adapter
    .with_default_mode(TelemetryMode::Anonymous)
    .with_data_processor_name("MyCo SAS")
    .with_data_residency_region(DataResidencyRegion::EU)
    .with_recent_log_capacity(200)        // ring buffer for "Inspect data sent"
    .with_debounce(Duration::from_millis(500))
}

Required: at least one of with_anonymous(...) or with_pseudonymous(...). Both let the user flip between modes via the widget.

2.2 OpenedTelemetry — runtime handle

The opened-bundle handle is Clone-cheap (every field is Rc/Arc). Surfaced through TelemetryExt:

#![allow(unused)]
fn main() {
use teksilo_telemetry::TelemetryExt;

fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
    if let Some(t) = ctx.try_telemetry() {
        // Public fields:
        let _: ConsentStore = t.consent.clone();
        let _: Option<InstallId> = t.install_id.clone();
        let _: Rc<DynamicReporter> = t.reporter.clone();
        let _: Arc<InMemoryEventQueue> = t.recent_log.clone();
        let _: PrivacyPolicy = t.policy.clone();
        let _: u32 = t.event_schema_version;
    }
    // ...
}
}

recent_log is a ring buffer DynamicReporter::record tees every consent-gated event into. Independent of the adapter's outbound queue — events stay in the recent log even after the adapter has flushed them, until evicted by the ring buffer's capacity (default 200, configurable via with_recent_log_capacity). Read by the "Inspect data sent" accordion in the widget.

2.3 UsageReporter trait — the adapter surface

#![allow(unused)]
fn main() {
pub trait UsageReporter {
    fn record(&self, event: &Event<'_>);
    fn flush(&self) -> Result<(), TelemetryError>;
    fn discard_pending(&self) -> Result<(), TelemetryError>;
    fn supported_scopes(&self) -> ConsentScope;
    fn install_id(&self) -> Option<&str>;
    fn endpoint(&self) -> &str;
    fn adapter_name(&self) -> &'static str;
    fn fetch_remote_data(&self) -> Result<RemoteDataExport, TelemetryError>;
    fn erase_remote_data(&self) -> Result<(), TelemetryError>;
}
}

The trait is single-threaded — adapters are Rc-shared, called only from the UI-thread dispatch tap. Adapters that need I/O behind a worker thread (Plausible, Teksilo) own that thread internally and communicate via mpsc channels; the UsageReporter impl is just the sync surface.

fetch_remote_data and erase_remote_data return TelemetryError::FetchUnsupported / ErasureUnsupported for anonymous-mode adapters; the widget hides the corresponding buttons in that case.

2.4 ConsentStore — the gate

Wraps SettingsFile<ConsentFile> with a Signal<ConsentState> for widget reactivity.

#![allow(unused)]
fn main() {
pub enum ConsentState {
    Unknown,                     // first run; no events emitted
    Granted(ConsentScope),       // events flow per the scope
    Denied,                      // explicit no; events dropped
}

pub struct ConsentScope {
    pub anonymous_metrics: bool,
    pub crash_reports: bool,
    pub feature_flags: bool,
    pub session_recording: bool,    // reserved — not implemented yet (PII risk)
}
}

API:

#![allow(unused)]
fn main() {
consent.state_signal();                    // Signal<ConsentState>
consent.is_granted();                      // bool — what the dispatch tap checks
consent.grant(scope, endpoint);            // Granted with full scope
consent.deny();                            // Denied
consent.withdraw();                        // shortcut for deny()
consent.set_scope(|s| s.crash_reports = false);
consent.set_or_grant_scope(endpoint, |s| s.anonymous_metrics = true);
                                            // Unknown→Granted-with-one-scope; no-op when Denied
consent.reset();                           // back to Unknown (used by mode switch)
consent.with_settings_mirror(settings_store);
                                            // optional one-way mirror to per-scope
                                            // SettingsKey<bool> values
}

Re-prompt rules (consent goes back to Unknown in any of):

  • event_schema_version bumps from one the user previously consented to.
  • The endpoint string changes (recipient-change rule).
  • The user runs consent.reset() (typically from the mode-switch flow).

with_settings_mirror(SettingsStore) writes the per-scope booleans into scopes::TELEMETRY_ANONYMOUS_METRICS / TELEMETRY_CRASH_REPORTS / TELEMETRY_FEATURE_FLAGS keys so power users editing general.toml directly see the live state. One-way (consent → settings, not the reverse — the consent file is authoritative).

2.5 InstallId — pseudonymous identity

Generated lazily on first pseudonymous-mode use; rotated every 13 months to align with the CNIL cookie-consent SLA. Stored in SettingsFile<InstallIdFile> under AppPaths::config_dir().

#![allow(unused)]
fn main() {
let install_id: Option<InstallId> = telemetry.install_id.clone();
if let Some(id) = &install_id {
    let uuid: String = id.get();
    id.clear();    // user clicked "Erase my data"
}
}

reporter.install_id() is the value that ends up on every emitted Event::install_id field — adapters override anything the event itself carried, so the per-install identity is consistent with what the server sees.

2.6 Event queues

TypeUse caseBackend
InMemoryEventQueueTests, the recent-log ring buffer, simple deploymentsMutex<VecDeque<OwnedEvent>>
PersistentEventQueueAdapter outbound buffering across process restartsredb (pure Rust, no C deps)

Both implement the EventQueue trait (push, drain_batch, len, peek_recent, discard_all). Adapters typically hold one as their outbound queue; the OpenedTelemetry::recent_log is always an InMemoryEventQueue.

PersistentEventQueue opens a redb file at a configured path with capacity + age caps:

#![allow(unused)]
fn main() {
let queue = PersistentEventQueue::open_with(
    &path,
    10_000,                              // capacity (oldest evicted past this)
    Duration::from_secs(60 * 60 * 24 * 7), // max age — events past this drop
)?;
}

The Plausible and Teksilo adapters expose a .persistent_queue_path(path) builder method to opt into durability. Without it, they fall back to an InMemoryEventQueue (events lost on hard exit).

OtlpAdapter is the exception: it deliberately has no persistent queue. The OTel deployment model assumes a collector sits between the app and the backend, and that collector (file_storage extension on otelcol-contrib, or its built-in queue) owns durability. See §3.4 for the full reasoning.

2.7 The dispatch tap

teksilo-core's event_dispatch_impl.rs taps every dispatched intent through:

intent.fired
  → ctx.try_telemetry_context()
  → ConsentStore::is_granted()? if not → return
  → DynamicReporter::record(&Event)
       ├── recent_log.push(event.to_owned())          // user-visible
       ├── recent_log_revision.set(version + 1)       // signals widget rebuild
       └── active_adapter.record(event)               // outbound

The recent_log_revision signal lives on DynamicReporter and is the binding the PrivacySettings widget watches at BindingLevel::Rebuild so its "Inspect data sent" accordion stays in sync without polling.


3. Adapters

3.1 StubReporter (testing)

In-memory Vec<OwnedEvent> collector. The last_recorded_name() helper makes integration tests trivial:

#![allow(unused)]
fn main() {
let stub = Rc::new(StubReporter::anonymous());
let bundle = TelemetryBundle::new(1).with_anonymous(stub.clone());
// ...
assert_eq!(
    stub.last_recorded_name().as_deref(),
    Some("intent.dispatched"),
);
}

Both anonymous() and pseudonymous("uuid") constructors exist.

3.2 PlausibleAdapter (anonymous mode → Plausible)

Wire-format: {name, url, domain, props} POSTed to <endpoint>/api/event. Synthetic app://<domain>/<event-name> URL since Plausible expects a URL and we don't have one.

#![allow(unused)]
fn main() {
let adapter = PlausibleAdapter::builder()
    .endpoint("https://plausible.io/api/event")    // or self-hosted
    .domain("my.app")
    .max_batch_size(50)
    .flush_interval(Duration::from_secs(60))
    .persistent_queue_path(paths.data_dir().join("plausible-queue.redb"))
    .endpoint_override(
        settings.signal_for(&scopes::TELEMETRY_ENDPOINT_OVERRIDE).get(),
    )                                              // no-op when empty
    .build();
}

Anonymous-by-design: no install_id ever, fetch_remote_data / erase_remote_data always return Unsupported. CNIL audience- measurement-exemption posture by default.

See crates/teksilo-analytics-plausible/ and examples/telemetry_plausible/.

3.3 TeksiloAdapter (anonymous + pseudonymous → teksilo-collector)

Home-grown gRPC adapter for the Teksilo-operated teksilo-collector backend. Single adapter covers both modes; flip via .install_id(uuid) on the builder.

#![allow(unused)]
fn main() {
let adapter = TeksiloAdapter::builder()
    .endpoint("https://collector.example.com:50051")
    .product_id("my.app")
    .bearer_token("fct_id_secret")                  // from `teksilo-collector token mint`
    .tls(TlsClientConfig {                          // optional — server may run plain
        ca_pem: Some(std::fs::read("/etc/ssl/ca.pem")?),
        client_cert_pem: None,                      // optional mTLS
        client_key_pem: None,
        domain_name: Some("collector.example.com".into()),
    })
    .install_id("UUID-STRING")                      // pseudonymous mode; omit for anonymous
    .max_batch_size(50)
    .flush_interval(Duration::from_secs(60))
    .persistent_queue_path(paths.data_dir().join("teksilo-queue.redb"))
    .build();
}

In pseudonymous mode (install_id set):

  • supported_scopes() returns ConsentScope::all().
  • Every batch is tagged mode = Pseudonymous.
  • fetch_remote_data() calls Telemetry.Fetch and rebuilds a RemoteDataExport.
  • erase_remote_data() calls Telemetry.Erase.

Multiple instances of the same Teksilo app, each with its own install_id, hit the same teksilo-collector endpoint with the same bearer token; per-product scope is enforced server-side.

See crates/teksilo-analytics-native/ and examples/telemetry_teksilo/.

3.4 OtlpAdapter (anonymous + pseudonymous → OTLP/HTTP logs)

Speaks OTLP/HTTP logs over JSON. Works with any OTel-compatible collector — otelcol-contrib, Honeycomb, self-hosted Tempo+Loki via the OTel collector's HTTP receiver.

#![allow(unused)]
fn main() {
let adapter = OtlpAdapter::builder()
    .endpoint("http://127.0.0.1:4318/v1/logs")
    .service_name("my.app")
    .service_version(env!("CARGO_PKG_VERSION"))
    .header("x-honeycomb-team", api_key)
    .max_batch_size(50)
    .flush_interval(Duration::from_secs(60))
    .build();
}

The mapping is:

Teksilo Event             OTLP LogRecord
──────────────────────── ────────────────────────────────────
event.name               body.stringValue
event.category           attributes["teksilo.category"]
event.timestamp          timeUnixNano (string, OTLP/JSON)
event.install_id         resource.service.instance.id (when set)
event.session_id         attributes["teksilo.session_id"]
event.props.<key>        attributes["teksilo.<key>"]

Anonymous-mode batches (no install_id) omit service.instance.id; the OTel collector treats them as aggregate logs.

Queue durability — intentional asymmetry. Unlike the Plausible and Teksilo adapters, OtlpAdapter has no .persistent_queue_path(...) method: pending events live in an in-memory VecDeque and are lost on hard exit. The OTel deployment model expects a collector (sidecar, system service, or localhost:4318) to own the durability layer via its file_storage extension or built-in queue. Layering redb inside the desktop adapter would duplicate work the collector already does. Apps that need client-side durability against hard exits should run a local collector with file_storage.

Fetch + erase. OTLP has no read or delete RPC, so fetch_remote_data / erase_remote_data return FetchUnsupportedByBackend / ErasureUnsupportedByBackend. The PrivacySettings widget hides the "Get my data" / "Erase my data" controls when these come back.

See crates/teksilo-analytics-otlp/.

3.5 Retry semantics — comparison

The three adapters share the same outline (drain → send → on failure, exponential backoff with jitter) but differ in how a failed batch interacts with the queue. The differences are visible in operations and in stats counters.

BehaviorPlausibleOTLPTeksilo
Send unitPer event (one HTTP POST per event)Per batch (one OTLP request per drain)Per batch (one gRPC call per drain)
First failure inside a drainRe-enqueue failed event at the tail, mark hit_retry, re-enqueue remaining events without tryingPush the whole batch back to the front of the buffer in reverse orderRe-enqueue every event, reset channel = None to force re-dial
Subsequent events in the same drainSkipped — re-enqueued unsentn/a (batched)n/a (batched)
Order preservation across retriesFIFO only among events that succeeded; failed events drift to the tailStrict — failed batch retries before any newer events drainStrict — failed batch is the first thing the next attempt sends
Effect on flush latency for poison eventsBounded — newer events still ship; bad event keeps cycling at the tailHead-of-line blocking — buffer stuck behind the bad batchHead-of-line blocking — drain breaks, retried on next opportunity
Backoff resetReset on first AcceptedReset on AcceptedReset implicitly by re-dial

When the differences matter:

  • Plausible's tail-requeue is the right call for analytics ordering tolerance — losing a few events to the tail beats blocking the queue behind a poison event.
  • OTLP's head-requeue preserves strict log order, which OTel consumers (Tempo, Honeycomb) sometimes assume.
  • Teksilo's batch-requeue + redial matches the gRPC stream model: a transient stream error is treated as fatal to the current channel; subsequent batches start from a fresh dial.

Apps that need strict ordering across all events should prefer OTLP or Teksilo. Apps that prioritize availability under transient server flakiness should prefer Plausible.


4. The PrivacySettings widget

Drop-in widget that surfaces every consent + RGPD obligation. Lives in teksilo-widgets:

#![allow(unused)]
fn main() {
use teksilo::widgets::PrivacySettings;

let widget = PrivacySettings::new()
    .data_processor_name("MyCo SAS")
    .privacy_policy_url("https://example.com/privacy")
    .compact(false)              // first-run modal mode
    .show_inspect(true)          // "Inspect data sent" accordion
    .show_mode_switch(true)      // anonymous ↔ pseudonymous (when both adapters configured)
    .show_identity_row(true)     // install_id + Get my data + Erase my data
    .inspect_event_count(50);
}

Layout (in order, top-to-bottom):

PrivacySettings
├── Heading
├── Plain-language Art. 13 notice
│     (controller, processor, purposes, lawful basis,
│      retention, withdrawal right, optional policy URL)
├── Per-scope toggles
│     (anonymous_metrics / crash_reports / feature_flags,
│      intersected with reporter.supported_scopes() — toggles
│      for unsupported scopes are HIDDEN, not just disabled)
├── Reject all  ←→  Accept all     (CNIL parity, GDPR Art. 7)
├── Identity row                   (pseudonymous mode only:
│     install_id display
│     Get my data         → opens a save-as-JSON file dialog
│     Erase my data       → confirm → server delete + local discard + withdraw)
├── Inspect data sent              (accordion; lists last N events)
├── Privacy mode switch            (when both adapters configured:
│     confirm → wipe install_id + queue + reset consent + flip mode)
└── Withdraw consent               (footer, equal prominence to Accept)

Confirmation dialogs (MessageBox::question + OkCancel) gate the destructive actions: erase, withdraw, mode switch. Misclicks survive a confirm step.

The "Inspect data sent" accordion auto-refreshes as events land — the recent_log_revision signal triggers a widget rebuild whenever DynamicReporter::record or discard_pending fires.

i18n: 42 keys under the privacy-* namespace in crates/teksilo-widgets/locales/en-US.ftl and fr-FR.ftl. Apps install the framework bundle via I18nConfig::framework_locales(teksilo_widgets::framework_locales()).


5. Configuration layering

Three places where telemetry behavior is set, in order of precedence:

ConcernWhere setMechanism
Adapter type / wire format / API tokenBuild-time in the binaryAdapter builder calls in main.rs
Default mode, retention policy, processor nameApp-builder timeTelemetryBundle::with_*
User's per-scope togglesRuntime, per-userConsentStore + per-scope SettingsKey<bool> mirror
User's endpoint overrideRuntime, per-deploymentscopes::TELEMETRY_ENDPOINT_OVERRIDE — apps feed this into adapter builder via .endpoint_override(...)
Active modeRuntimeDynamicReporter::active, mutated by the widget
Install IDRuntime, automaticSettingsFile<InstallIdFile>, 13-month rotation
Consent decisionRuntime, persistentSettingsFile<ConsentFile>
Pending eventsRuntime, persistent (Plausible + Teksilo adapters)redb at AppPaths::data_dir().join("<adapter>-queue.redb")
Pending eventsRuntime, in-memory only (OTLP adapter)VecDeque<OwnedEvent> — durability deferred to the OTel collector

Endpoint override

Set the telemetry.endpoint_override settings key in general.toml to redirect all adapters at a different server without rebuilding:

[telemetry]
endpoint_override = "https://my-other-collector.example.com:50051"

Apps wire it through:

#![allow(unused)]
fn main() {
let override_url = settings
    .signal_for(&teksilo_telemetry::scopes::TELEMETRY_ENDPOINT_OVERRIDE)
    .get();
let adapter = TeksiloAdapter::builder()
    .endpoint("https://default-collector.example.com:50051")
    .endpoint_override(override_url)         // applies iff non-empty
    .product_id("my.app")
    .build();
}

Triggers the recipient-change re-prompt rule in ConsentStore: when the endpoint stored at consent grant time differs from the endpoint at app start, consent flips back to Unknown and the widget re-asks. RGPD Art. 13 transparency.


6. RGPD / GDPR compliance summary

The framework provides the SDK plumbing; the app developer is the data controller and remains responsible for the legal artifacts (Art. 13 controller notice, privacy policy, DPA with processors, etc.). What Teksilo does automate:

ArticleWhat Teksilo does
Art. 6(1)(a) consentConsentStore. No event flows in Unknown or Denied.
Art. 6(1)(f) legitimate interest (anonymous mode)Anonymous-mode adapters set supported_scopes() = anonymous_metrics_only() and report install_id() = None. CNIL audience-measurement-exemption posture by default.
Art. 7(3) right to withdrawWithdraw button in the widget, equal prominence to Accept.
Art. 13 transparencyPlain-language notice block in the widget — controller, processor, purposes, lawful basis, retention, recipients.
Art. 15 right of access"Get my data" button → fetch_remote_data() → JSON export with file-save dialog.
Art. 17 right to erasure"Erase my data" button → confirm → erase_remote_data() → local queue wipe + consent withdrawal.
Art. 20 portabilityRemoteDataExport is JSON-serializable, self-describing (includes schema_version, endpoint, adapter).

Anonymous mode never collects per-user data, so Art. 15 / 17 / 20 buttons hide automatically — there's nothing to fetch or erase.


7. Code references

FilePurpose
crates/teksilo-core/src/telemetry/event.rsEvent, OwnedEvent, Prop, RemoteDataExport, serde derives
crates/teksilo-core/src/telemetry/reporter.rsUsageReporter trait, TelemetryError
crates/teksilo-telemetry/src/bundle.rsTelemetryBundle, OpenedTelemetry, PrivacyPolicy
crates/teksilo-telemetry/src/consent.rsConsentStore, ConsentFile, settings-mirror integration
crates/teksilo-telemetry/src/install_id.rsInstallId with 13-month rotation
crates/teksilo-telemetry/src/dynamic_reporter.rsDynamicReporter, recent-log tee, revision signal
crates/teksilo-telemetry/src/queue.rs + queue/EventQueue trait (queue.rs), InMemoryEventQueue (queue/mem.rs), PersistentEventQueue (queue/persistent.rs)
crates/teksilo-telemetry/src/scopes.rsSettingsKey<bool> constants for per-scope mirror, TELEMETRY_ENDPOINT_OVERRIDE, TELEMETRY_REGION_OVERRIDE
crates/teksilo-telemetry/src/ext.rsTelemetryExt accessors on BuildContext / EventContext
crates/teksilo-widgets/src/privacy_settings.rsThe widget
crates/teksilo-widgets/locales/en-US.ftli18n keys (privacy-*)
crates/teksilo-analytics-plausible/Plausible adapter
crates/teksilo-analytics-native/Home-grown gRPC adapter

For the home-grown server backend, see the teksilo-collector sibling repo.


8. Worked examples

  • examples/telemetry_plausible/ — anonymous mode against Plausible. Three intent buttons + the PrivacySettings widget. Default endpoint http://127.0.0.1:8000/api/event; override via PLAUSIBLE_ENDPOINT env var.

  • examples/telemetry_teksilo/ — anonymous OR pseudonymous against a self-hosted teksilo-collector. Env vars: TEKSILO_ENDPOINT, TEKSILO_PRODUCT_ID, TEKSILO_TOKEN, TEKSILO_INSTALL_ID (set to flip to pseudonymous), TEKSILO_TLS_CA, TEKSILO_TLS_DOMAIN. See the example's docstring for the complete run procedure including spinning up the sibling teksilo-collector.