AkurAI Build
Menu

Public repository / master

AkurAI-Notes / crates/notes-api/src/telemetry.rs

Latest change f3a035b12c8f9e52dab037eccf159be9d932d6dc - AKURAI-NOTES-7 fix cargo fmt formatting by Ólafur Búi Ólafsson

//! Notes-specific lifecycle facts using the Framework telemetry contract.
//!
//! All facts are privacy-safe: no title, body, query text, tag text, or
//! attachment content is placed into attributes. Canonical organization and
//! workspace IDs come from the session/configuration.
//!
//! Telemetry emission is best-effort and non-blocking: a failure in the
//! receiver or buffer never affects note CRUD or search.

pub use akurai_telemetry::EmitOutcome;
use akurai_telemetry::{TelemetryContext, TelemetryEnvelope, TelemetrySender};

/// Stable Notes-specific event names.
pub const FIRST_NOTE_EVENT: &str = "notes.first_note";
pub const NOTE_COUNT_EVENT: &str = "notes.note_count";
pub const FIRST_SEARCH_EVENT: &str = "notes.first_search";
pub const ACTIVE_DAY_EVENT: &str = "notes.active_day";

/// Bounded attribute keys (privacy-safe, no content).
pub const ATTR_NOTE_COUNT: &str = "note_count";
pub const ATTR_APP_ID: &str = "notes";

/// A thin producer handle bound to one Notes deployment context.
#[derive(Clone)]
pub struct NotesTelemetry {
    sender: TelemetrySender,
    context: TelemetryContext,
}

impl NotesTelemetry {
    pub fn new(sender: TelemetrySender, org_id: &str, workspace_id: &str) -> Self {
        let context = TelemetryContext::new(org_id, workspace_id, workspace_id, ATTR_APP_ID);
        Self { sender, context }
    }

    /// Emit the first-note-created activation fact.
    ///
    /// Privacy: only the note count (1) is included; no title/body/tags.
    pub fn emit_first_note(&self, now_secs: i64) -> EmitOutcome {
        let event_id = format!("first_note:{}:{}", self.context.organization_id, now_secs);
        let envelope = TelemetryEnvelope::new(
            event_id,
            &self.context,
            FIRST_NOTE_EVENT,
            now_secs,
            akurai_telemetry::PrivacyClassification::Operational,
            vec![(ATTR_NOTE_COUNT.into(), "1".into())],
        );
        match envelope {
            Ok(e) => self.sender.emit(e),
            Err(_) => EmitOutcome::Rejected,
        }
    }

    /// Emit an active-day fact (search or note creation).
    ///
    /// Privacy: only the note count is included, not query text or note content.
    pub fn emit_active_use(&self, now_secs: i64, note_count: i64) -> EmitOutcome {
        let event_id = format!("active:{}:{}", self.context.organization_id, now_secs);
        let envelope = TelemetryEnvelope::new(
            event_id,
            &self.context,
            ACTIVE_DAY_EVENT,
            now_secs,
            akurai_telemetry::PrivacyClassification::Operational,
            vec![(ATTR_NOTE_COUNT.into(), note_count.to_string())],
        );
        match envelope {
            Ok(e) => self.sender.emit(e),
            Err(_) => EmitOutcome::Rejected,
        }
    }

    /// Emit a storage-count fact after note mutation.
    ///
    /// Privacy: the total note count is included; no content or titles.
    pub fn emit_note_count(&self, now_secs: i64, total_notes: i64) -> EmitOutcome {
        let event_id = format!("count:{}:{}", self.context.organization_id, now_secs);
        let envelope = TelemetryEnvelope::new(
            event_id,
            &self.context,
            NOTE_COUNT_EVENT,
            now_secs,
            akurai_telemetry::PrivacyClassification::Operational,
            vec![(ATTR_NOTE_COUNT.into(), total_notes.to_string())],
        );
        match envelope {
            Ok(e) => self.sender.emit(e),
            Err(_) => EmitOutcome::Rejected,
        }
    }

    /// Emit a first-search activation fact.
    ///
    /// Privacy: only existence; no query text or result snippets.
    pub fn emit_first_search(&self, now_secs: i64) -> EmitOutcome {
        let event_id = format!("first_search:{}:{}", self.context.organization_id, now_secs);
        let envelope = TelemetryEnvelope::new(
            event_id,
            &self.context,
            FIRST_SEARCH_EVENT,
            now_secs,
            akurai_telemetry::PrivacyClassification::Operational,
            Vec::new(),
        );
        match envelope {
            Ok(e) => self.sender.emit(e),
            Err(_) => EmitOutcome::Rejected,
        }
    }
}

/// A receiver that writes telemetry to stderr for operational visibility.
///
/// In production this can be swapped for a durable receiver (e.g. Drive CRM).
/// Receiver errors never reach the request path.
pub struct StderrReceiver;

impl akurai_telemetry::TelemetryReceiver for StderrReceiver {
    fn receive(&self, event: TelemetryEnvelope) -> Result<(), akurai_telemetry::ReceiverError> {
        eprintln!(
            "[telemetry] event={} org={} ws={} privacy={:?} attrs={:?}",
            event.event_name,
            event.organization_id,
            event.workspace_id,
            event.privacy,
            event.attributes,
        );
        Ok(())
    }
}

pub use akurai_telemetry::TelemetryDelivery;

/// Initialize telemetry from environment variables.
///
/// Reads `AKURAI_NOTES_ORG_ID` and `AKURAI_NOTES_WS_ID` to set up the
/// context. Returns `(None, None)` when either var is unset or empty
/// (telemetry is opt-in per deployment).
pub fn init_from_env() -> (Option<TelemetryDelivery>, Option<NotesTelemetry>) {
    let org_id = match std::env::var("AKURAI_NOTES_ORG_ID") {
        Ok(id) if !id.trim().is_empty() => id,
        _ => return (None, None),
    };
    let ws_id = match std::env::var("AKURAI_NOTES_WS_ID") {
        Ok(id) if !id.trim().is_empty() => id,
        _ => return (None, None),
    };

    match TelemetryDelivery::new(StderrReceiver, 128) {
        Ok(delivery) => {
            let sender = delivery.sender();
            let notes_telemetry = NotesTelemetry::new(sender, &org_id, &ws_id);
            (Some(delivery), Some(notes_telemetry))
        }
        Err(_) => (None, None),
    }
}