Skip to main content

Overview

Loom’s analytics system provides PostHog-style product analytics for tracking user behavior, running experiments, and analyzing conversion funnels. The system supports both anonymous and identified users with automatic identity resolution.
Analytics integrates with feature flags to automatically track flag exposures for experiment analysis.

Key Features

  • Person profiles for anonymous and identified users
  • Event tracking with flexible properties
  • Identity resolution linking anonymous sessions to authenticated users
  • Multi-tenant analytics scoped to organizations
  • API keys for client-side (write-only) and server-side (read/write) access

Core Concepts

Person

A person represents a user being tracked, identified by one or more distinct_id values:
pub struct Person {
    pub id: PersonId,
    pub org_id: OrgId,
    pub properties: serde_json::Value,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

Event

Events track user actions with an event name, distinct ID, and optional properties:
pub struct Event {
    pub id: EventId,
    pub org_id: OrgId,
    pub person_id: Option<PersonId>,
    pub distinct_id: String,
    pub event_name: String,
    pub properties: serde_json::Value,
    pub timestamp: DateTime<Utc>,
    pub ip_address: Option<SecretString>,
    pub user_agent: Option<String>,
}
Event naming conventions:
  • Lowercase alphanumeric with _, $, or .
  • Must start with lowercase letter or $ (for system events)
  • Maximum 200 characters
  • Examples: button_clicked, $pageview, checkout.completed

Identity Resolution

Loom uses PostHog’s identity model:
1

Anonymous user arrives

SDK generates UUIDv7, stored in localStorage/cookie as distinct_id
2

Events captured

All events tagged with this anonymous distinct_id
3

User identifies

SDK calls identify(anonymous_id, user_id) with real identifier
4

Merge occurs

Both distinct_ids linked to same Person, properties merged
5

Future events

Can use either distinct_id, both resolve to same Person

Rust SDK

Install the SDK:
[dependencies]
loom-analytics = { version = "0.1" }

Basic Usage

use loom_analytics::AnalyticsClient;

// Initialize client
let client = AnalyticsClient::builder()
    .api_key("loom_analytics_write_xxx")
    .base_url("https://loom.example.com")
    .flush_interval(Duration::from_secs(10))
    .build()?;

// Capture event
client.capture(
    "button_clicked", 
    "user_123",
    Properties::new()
        .insert("button_name", "checkout")
        .insert("page", "/cart")
).await?;

// Identify user (link anonymous to authenticated)
client.identify(
    "anon_abc123",
    "[email protected]",
    Properties::new()
        .insert("plan", "pro")
        .insert("company", "Acme Inc")
).await?;

// Set person properties
client.set(
    "[email protected]",
    Properties::new()
        .insert("last_login", Utc::now().to_rfc3339())
).await?;

// Shutdown (flushes pending events)
client.shutdown().await?;

TypeScript SDK

Install the SDK:
npm install @loom/analytics

Browser Usage

import { AnalyticsClient } from '@loom/analytics';

// Initialize (browser)
const analytics = new AnalyticsClient({
  apiKey: 'loom_analytics_write_xxx',
  baseUrl: 'https://loom.example.com',
  persistence: 'localStorage+cookie',
  autocapture: true, // Auto-capture $pageview
});

// Capture event
analytics.capture('button_clicked', {
  button_name: 'checkout',
  page: '/cart',
});

// Identify user
analytics.identify('[email protected]', {
  plan: 'pro',
  company: 'Acme Inc',
});

// Get current distinct_id
const distinctId = analytics.getDistinctId();

// Reset on logout (generates new distinct_id)
analytics.reset();

Special Events

System events use the $ prefix:
EventDescription
$pageviewPage view (auto-captured by web SDK)
$pageleavePage leave
$identifyLogged when identify() called
$feature_flag_calledFeature flag evaluated

Person Properties vs Event Properties

Person Properties

Persistent attributes attached to the PersonExamples: email, plan, companySet via identify() or set()

Event Properties

Ephemeral data for a single eventExamples: button_name, page_url, order_valueSet via capture() properties parameter

API Keys

Use Write-only keys for client-side code (safe to expose). Use Read/Write keys only server-side.
TypePrefixCapabilitiesUse Case
Writeloom_analytics_write_Capture, identify, aliasClient-side (browser, mobile)
Read/Writeloom_analytics_rw_All write + query/exportServer-side only

Creating API Keys

curl -X POST https://loom.example.com/api/analytics/api-keys \
  -H "Authorization: Bearer <user_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production Web SDK",
    "key_type": "write"
  }'

API Endpoints

Capture Events

POST /api/analytics/capture
endpoint
Capture a single eventRequest:
{
  "distinct_id": "user_123",
  "event": "button_clicked",
  "properties": {
    "button_name": "checkout"
  },
  "timestamp": "2026-01-18T12:00:00Z"
}
POST /api/analytics/batch
endpoint
Capture multiple events in one requestRequest:
{
  "batch": [
    {
      "distinct_id": "user_123",
      "event": "page_viewed",
      "properties": { "page": "/dashboard" }
    },
    {
      "distinct_id": "user_123",
      "event": "button_clicked",
      "properties": { "button": "export" }
    }
  ]
}

Identity Operations

POST /api/analytics/identify
endpoint
Link anonymous distinct_id to authenticated userRequest:
{
  "distinct_id": "anon_abc123",
  "user_id": "[email protected]",
  "properties": {
    "plan": "pro",
    "email": "[email protected]"
  }
}
POST /api/analytics/alias
endpoint
Link two distinct_ids togetherRequest:
{
  "distinct_id": "[email protected]",
  "alias": "user_legacy_id_456"
}
POST /api/analytics/set
endpoint
Update person propertiesRequest:
{
  "distinct_id": "[email protected]",
  "properties": {
    "last_login": "2026-01-18T12:00:00Z",
    "login_count": 42
  }
}

Query APIs

Query endpoints require a Read/Write API key.
GET /api/analytics/persons
endpoint
List persons for organizationQuery Parameters:
  • limit - Max results (default: 100)
  • offset - Pagination offset
GET /api/analytics/persons/by-distinct-id/:distinct_id
endpoint
Get person by distinct_idResponse:
{
  "person": {
    "id": "per_xxx",
    "properties": {
      "email": "[email protected]",
      "plan": "pro"
    }
  },
  "identities": [
    {
      "distinct_id": "anon_abc123",
      "identity_type": "anonymous"
    },
    {
      "distinct_id": "[email protected]",
      "identity_type": "identified"
    }
  ]
}
GET /api/analytics/events
endpoint
Query events with filtersQuery Parameters:
  • event_name - Filter by event name
  • distinct_id - Filter by distinct_id
  • start_date - Start timestamp (ISO 8601)
  • end_date - End timestamp (ISO 8601)
  • limit - Max results

Experiment Integration

Analytics automatically tracks feature flag exposures for A/B testing:
// When a flag is evaluated, this event is auto-captured:
{
  event: "$feature_flag_called",
  properties: {
    "$feature_flag": "checkout.new_flow",
    "$feature_flag_response": "treatment_a"
  }
}

// Track conversion metric
analytics.capture('checkout_completed', {
  order_value: 99.00,
  currency: 'USD'
});
Query experiment results by joining flag exposures with conversion events:
SELECT
  el.variant,
  COUNT(DISTINCT ae.person_id) as conversions,
  COUNT(DISTINCT el.user_id) as exposures,
  CAST(COUNT(DISTINCT ae.person_id) AS REAL) / 
    COUNT(DISTINCT el.user_id) as conversion_rate
FROM exposure_logs el
LEFT JOIN analytics_person_identities api 
  ON api.distinct_id = el.user_id
LEFT JOIN analytics_events ae 
  ON ae.person_id = api.person_id
  AND ae.event_name = 'checkout_completed'
  AND ae.timestamp > el.timestamp
WHERE el.flag_key = 'checkout.new_flow'
GROUP BY el.variant;

Validation & Limits

Events that exceed validation limits will be rejected.
FieldValidation
Event nameMax 200 chars, lowercase alphanumeric + _.$
PropertiesMax 1 MB JSON
Distinct IDMax 200 chars
IP AddressAutomatically redacted in logs (stored as SecretString)

Best Practices

Use Descriptive Names

Event names should be clear and consistent:checkout_completed, video_playedaction_1, event, click

Property Consistency

Use consistent property names across events:✅ Always use page_url for URLs❌ Mix url, page, page_url

Reset on Logout

Call analytics.reset() when users log out to start fresh session

Client-Side Keys

Only use Write API keys in client-side code (safe to expose publicly)

See Also

Crash Tracking

Link crashes to person profiles

Sessions

Track user engagement periods

Build docs developers (and LLMs) love