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. That is what a "you opted in two years ago, still happy?" prompt needs.

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.