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),
}
}