teksilo-telemetry 0.9.0

Privacy-respecting, consent-gated product analytics for Teksilo applications.
Documentation
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! Hand-written framework-event emitters.
//!
//! This module is the stand-in for what will eventually be
//! generated by the `include_telemetry_schema!` proc macro from a
//! YAML manifest. The event names, categories, and property keys
//! match the documented event schema.
//!
//! Apps that want to emit *their own* events build their own
//! `Event<'_>` directly.

use std::time::SystemTime;

use teksilo_core::telemetry::{Event, EventCategory, IntentSource, Prop, PropValue, UsageReporter};

/// The current event-schema version. Bumped when the framework adds,
/// removes, or changes the shape of any event. The bump triggers a
/// consent re-prompt via `ConsentStore`.
pub const EVENT_SCHEMA_VERSION: u32 = 1;

/// Emit `intent.dispatched`. Called from
/// `WidgetTree::dispatch_intent`.
pub fn emit_intent_dispatched(
    reporter: &dyn UsageReporter,
    install_id: Option<&str>,
    session_id: &str,
    name: &'static str,
    source: IntentSource,
) {
    let props = [
        Prop {
            key: "name",
            value: PropValue::StaticStr(name),
        },
        Prop {
            key: "source",
            value: PropValue::Enum {
                variant: source.as_str(),
            },
        },
    ];
    let event = Event {
        name: "intent.dispatched",
        category: EventCategory::Intent,
        timestamp: SystemTime::now(),
        install_id,
        session_id,
        schema_version: EVENT_SCHEMA_VERSION,
        props: &props,
    };
    reporter.record(&event);
}

/// Emit `lifecycle.app_started`. Called once at boot, after the first
/// window opens, by `TeksiloAppBuilder::install_telemetry`.
#[allow(clippy::too_many_arguments)]
pub fn emit_lifecycle_app_started(
    reporter: &dyn UsageReporter,
    install_id: Option<&str>,
    session_id: &str,
    app_version: &str,
    teksilo_version: &str,
    os: AppOs,
    arch: AppArch,
    locale: &str,
    theme_kind: ThemeKind,
) {
    let props = [
        Prop {
            key: "app_version",
            value: PropValue::BoundedStr(app_version),
        },
        Prop {
            key: "teksilo_version",
            value: PropValue::BoundedStr(teksilo_version),
        },
        Prop {
            key: "os",
            value: PropValue::Enum {
                variant: os.as_str(),
            },
        },
        Prop {
            key: "arch",
            value: PropValue::Enum {
                variant: arch.as_str(),
            },
        },
        Prop {
            key: "locale",
            value: PropValue::BoundedStr(locale),
        },
        Prop {
            key: "theme_kind",
            value: PropValue::Enum {
                variant: theme_kind.as_str(),
            },
        },
    ];
    let event = Event {
        name: "lifecycle.app_started",
        category: EventCategory::Lifecycle,
        timestamp: SystemTime::now(),
        install_id,
        session_id,
        schema_version: EVENT_SCHEMA_VERSION,
        props: &props,
    };
    reporter.record(&event);
}

/// Emit `lifecycle.app_exited`. Called from the `TeksiloAppBuilder` graceful
/// exit hook with the bucketed session duration.
pub fn emit_lifecycle_app_exited(
    reporter: &dyn UsageReporter,
    install_id: Option<&str>,
    session_id: &str,
    duration: SessionDurationBucket,
) {
    let props = [Prop {
        key: "session_duration_bucket",
        value: PropValue::Enum {
            variant: duration.as_str(),
        },
    }];
    let event = Event {
        name: "lifecycle.app_exited",
        category: EventCategory::Lifecycle,
        timestamp: SystemTime::now(),
        install_id,
        session_id,
        schema_version: EVENT_SCHEMA_VERSION,
        props: &props,
    };
    reporter.record(&event);
}

/// Emit `widget.census` — a histogram of widget
/// concrete-type names + the active widget total. Sized down to a
/// single event by encoding the histogram as a `HistogramStrU32`
/// prop value, so the wire footprint stays bounded by the number
/// of distinct widget types in the app (typically <100).
///
/// Triggering policy is operator-side: typical pattern is once per
/// hour or once on graceful shutdown, whichever fires first.
/// Adapters with a flush-on-shutdown drain (Plausible, Teksilo) get
/// the census on the way out for free; the periodic firing requires
/// an app-side timer.
///
/// Caller pulls the histogram from
/// [`teksilo_core::widget_tree::WidgetTree::widget_type_histogram`].
pub fn emit_widget_census(
    reporter: &dyn UsageReporter,
    install_id: Option<&str>,
    session_id: &str,
    histogram: &[(&'static str, u32)],
    total: u32,
) {
    let props = [
        Prop {
            key: "widget_count_by_type",
            value: PropValue::HistogramStrU32(histogram),
        },
        Prop {
            key: "total_widgets",
            value: PropValue::U32(total),
        },
    ];
    let event = Event {
        name: "widget.census",
        category: EventCategory::Census,
        timestamp: SystemTime::now(),
        install_id,
        session_id,
        schema_version: EVENT_SCHEMA_VERSION,
        props: &props,
    };
    reporter.record(&event);
}

/// Emit `window.opened` / `window.closed`.
pub fn emit_window_lifecycle(
    reporter: &dyn UsageReporter,
    install_id: Option<&str>,
    session_id: &str,
    opened: bool,
    kind: WindowKind,
) {
    let props = [Prop {
        key: "kind",
        value: PropValue::Enum {
            variant: kind.as_str(),
        },
    }];
    let name = if opened {
        "window.opened"
    } else {
        "window.closed"
    };
    let event = Event {
        name,
        category: EventCategory::Lifecycle,
        timestamp: SystemTime::now(),
        install_id,
        session_id,
        schema_version: EVENT_SCHEMA_VERSION,
        props: &props,
    };
    reporter.record(&event);
}

// ----- supporting enums -----

#[derive(Copy, Clone, Debug)]
pub enum AppOs {
    Linux,
    Macos,
    Windows,
    Freebsd,
    Other,
}
impl AppOs {
    pub fn detect() -> Self {
        match std::env::consts::OS {
            "linux" => Self::Linux,
            "macos" => Self::Macos,
            "windows" => Self::Windows,
            "freebsd" => Self::Freebsd,
            _ => Self::Other,
        }
    }
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Linux => "linux",
            Self::Macos => "macos",
            Self::Windows => "windows",
            Self::Freebsd => "freebsd",
            Self::Other => "other",
        }
    }
}

#[derive(Copy, Clone, Debug)]
pub enum AppArch {
    X86_64,
    Aarch64,
    Other,
}
impl AppArch {
    pub fn detect() -> Self {
        match std::env::consts::ARCH {
            "x86_64" => Self::X86_64,
            "aarch64" => Self::Aarch64,
            _ => Self::Other,
        }
    }
    pub fn as_str(self) -> &'static str {
        match self {
            Self::X86_64 => "x86_64",
            Self::Aarch64 => "aarch64",
            Self::Other => "other",
        }
    }
}

#[derive(Copy, Clone, Debug)]
pub enum ThemeKind {
    Light,
    Dark,
    Custom,
}
impl ThemeKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Light => "light",
            Self::Dark => "dark",
            Self::Custom => "custom",
        }
    }
}

#[derive(Copy, Clone, Debug)]
pub enum SessionDurationBucket {
    Under1m,
    M1to5,
    M5to30,
    M30to2h,
    H2to8,
    Over8h,
}
impl SessionDurationBucket {
    pub fn from_seconds(s: u64) -> Self {
        match s {
            0..=59 => Self::Under1m,
            60..=299 => Self::M1to5,
            300..=1799 => Self::M5to30,
            1800..=7199 => Self::M30to2h,
            7200..=28799 => Self::H2to8,
            _ => Self::Over8h,
        }
    }
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Under1m => "under_1m",
            Self::M1to5 => "m1_5",
            Self::M5to30 => "m5_30",
            Self::M30to2h => "m30_2h",
            Self::H2to8 => "h2_8",
            Self::Over8h => "over_8h",
        }
    }
}

#[derive(Copy, Clone, Debug)]
pub enum WindowKind {
    Main,
    Dialog,
    Popover,
    Secondary,
}
impl WindowKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Main => "main",
            Self::Dialog => "dialog",
            Self::Popover => "popover",
            Self::Secondary => "secondary",
        }
    }
}

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

    #[test]
    fn session_duration_buckets_cover_canonical_ranges() {
        assert!(matches!(
            SessionDurationBucket::from_seconds(30),
            SessionDurationBucket::Under1m
        ));
        assert!(matches!(
            SessionDurationBucket::from_seconds(60),
            SessionDurationBucket::M1to5
        ));
        assert!(matches!(
            SessionDurationBucket::from_seconds(7200),
            SessionDurationBucket::H2to8
        ));
        assert!(matches!(
            SessionDurationBucket::from_seconds(1_000_000),
            SessionDurationBucket::Over8h
        ));
    }
}