Skip to content

Persist a user's consent decision

The crate stores the decision; you decide where the file lives, when to ask, and what the prompt says.

Pick a path

Every consent function takes a &Path. The toolkit convention is <config_dir>/<tool>/consent.toml — on Linux that is typically ~/.config/<tool>/consent.toml. Use whatever crate you already have for platform config directories; this one does not pick for you.

Keep consent in its own file rather than inside your main config. It stays unambiguously tool-managed, "forget my decision" becomes a file deletion rather than a config rewrite, and the two schemas can change independently.

Record the decision

use rtb_telemetry::consent::{self, Consent};

// User said yes.
consent::write(&path, &Consent::enabled_now())?;

// User said no.
consent::write(&path, &Consent::disabled_now())?;

// User asked you to forget they were ever asked.
consent::reset(&path)?;   // idempotent — Ok(()) when already absent

Both *_now constructors stamp decided_at with the current UTC time, so you can tell later how old the decision is — for a "you opted in two years ago, still happy?" prompt, for example.

Parent directories are created for you. The write is not atomic; if a torn file would be a problem for your tool, write to a temporary file and rename it over the top.

Turn the stored decision into a policy at startup

use rtb_telemetry::{CollectionPolicy, TelemetryContext};

let policy = match consent::read(&path)? {
    Some(record) => CollectionPolicy::from(record.state),
    None => CollectionPolicy::Disabled,   // never asked
};

let telemetry = TelemetryContext::builder()
    .tool(env!("CARGO_PKG_NAME"))
    .tool_version(env!("CARGO_PKG_VERSION"))
    .salt(concat!(env!("CARGO_PKG_NAME"), ".telemetry.v1"))
    .sink(sink)
    .policy(policy)
    .build();

ConsentState::Unset maps to Disabled too, so a file present with state = "unset" behaves the same as no file at all for collection purposes — while still letting you distinguish the two when deciding whether to prompt.

Tell the difference between "said no" and "never asked"

use rtb_telemetry::consent::ConsentState;

match consent::read(&path)? {
    None => prompt_for_consent()?,                       // never asked
    Some(c) if c.state == ConsentState::Unset => prompt_for_consent()?,
    Some(_) => {}                                        // already decided — don't ask again
}

Re-prompting someone who has already declined is the fastest way to lose the argument about whether your opt-in is genuine.

read returns TelemetryError::Serde for malformed TOML and for a schema version other than 1 — a file written by a future version of your tool, for instance. It does not fall back to a default, on purpose: guessing at an unreadable consent record means guessing about consent.

Treat it as "not opted in", and say something:

let policy = match consent::read(&path) {
    Ok(Some(record)) => CollectionPolicy::from(record.state),
    Ok(None) => CollectionPolicy::Disabled,
    Err(err) => {
        tracing::warn!(%err, "unreadable consent file; telemetry stays off");
        CollectionPolicy::Disabled
    }
};

If you build on rtb-cli

You do not need any of the above. The CLI runtime resolves the policy and ships telemetry status / enable / disable / reset backed by this module — see rtb-cli.