Skip to content

PostHog Analytics

Loquent uses PostHog for product analytics. The integration tracks page views, identifies users without PII, and captures custom events for key user actions — all through a Rust-to-JS interop layer built on Dioxus’s document::eval().

Admin sets API key in System Config
→ Client fetches config via GET /api/analytics/config
→ AnalyticsProvider initializes PostHog SDK
→ User identified (member_id, org_id, role — no PII)
→ Page views tracked on every route change
→ Custom events fired from feature components
→ Logout calls posthog.reset()

Analytics is disabled by default. If no API key is configured in core_conf, the config endpoint returns None and no JS loads.

PostHog credentials are stored in the core_conf database table, managed through the admin System Config panel.

FieldColumnValidation
API Keyposthog_api_keyMust start with phc_
API Hostposthog_api_hostPostHog instance URL

The PosthogCoreConf type implements the CoreConf trait to read these values:

src/shared/analytics/get_posthog_config_api.rs
pub struct PosthogCoreConf {
pub posthog_api_key: String,
pub posthog_api_host: String,
}
impl CoreConf for PosthogCoreConf {
fn from_row(row: core_conf::Model) -> Self {
Self {
posthog_api_key: row.posthog_api_key,
posthog_api_host: row.posthog_api_host,
}
}
}

GET /api/analytics/config — returns the PostHog config for client-side SDK initialization.

ResponseMeaning
Some(PosthogConfig { api_key, api_host })Analytics enabled — initialize SDK
NoneAnalytics disabled — no JS loaded

AnalyticsProvider lives in src/shared/analytics/analytics_provider.rs and mounts inside SessionContext in PrivateLayout. It handles three responsibilities:

  1. Initialize — fetches config, loads the PostHog JS snippet, calls posthog.init() with capture_pageview: false (manual tracking)
  2. Identify — calls posthog.identify() with member_id, org_id, and role (owner/admin/member)
  3. Track page views — uses use_effect subscribed to use_route::<Route>() to fire $pageview on every SPA navigation
// Simplified — src/shared/analytics/analytics_provider.rs
#[component]
pub fn AnalyticsProvider(children: Element) -> Element {
let config_resource = use_resource(|| async { get_posthog_config_api().await });
let session = use_context::<ClientSession>();
let current_route = use_route::<Route>();
let mut initialized = use_signal(|| false);
use_effect(move || {
if let Some(Ok(Some(config))) = &*config_resource.read() {
if !*initialized.peek() {
init_posthog(&config.api_key, &config.api_host);
identify_user(&session.member_id, &session.organization_id, role);
initialized.set(true);
}
track_pageview(&format!("{}", current_route));
}
});
children
}

All functions in src/shared/analytics/posthog.rs are fire-and-forget — they use document::eval() to execute JS. The PostHog stub queues calls made before the library loads.

FunctionPurpose
init_posthog(api_key, api_host)Loads PostHog snippet and initializes with config
identify_user(member_id, org_id, role)Associates events with a user (no PII)
reset_analytics()Clears identity on logout
track_event(name, properties)Captures a custom event with JSON properties
track_pageview(path)Captures a $pageview event for SPA navigation

String values are escaped with escape_js_string (from src/shared/utils/) or serialized via serde_json::to_string to prevent injection.

These events are instrumented across the app:

EventComponentProperties
contact_createdcreate_contact_view
message_sentmessage_compose_componentchannel (type)
call_initiatedoutbound_call_context
agent_configuredcreate_agent_view
task_createdcreate_task_view, contact_tasks_tabsource
knowledge_uploadedcreate_knowledge_view
report_viewedreport_details_view
assistant_openedassistant sidebar
assistant_message_sentassistant chat
assistant_session_createdassistant chat

Use track_event from any component:

use crate::shared::analytics::posthog::track_event;
use serde_json::json;
track_event("feature_used", &json!({ "feature": "bulk_import" }));

Naming convention: snake_case, verb in past tense (e.g., contact_created, report_viewed). Never include PII (email, name, phone) in properties — use IDs only.

The integration follows a strict no-PII policy:

  • Sent: member_id, organization_id, role, behavioral data (page views, feature usage)
  • Never sent: email, name, phone number, message content
  • posthog.reset() runs on logout to clear the identity
  • persistence: "localStorage" keeps the anonymous ID across page reloads