@logg/signals
v0.3.0
Published
Universal event tracking SDK for Logg Signals, with an embeddable on-device recommender
Maintainers
Readme
@logg/signals
Universal event tracking SDK for Logg Signals. Track events from web, React Native, and Node.js applications.
Also ships an embeddable on-device recommender as a separate entry point — see On-Device Recommender.
Version 0.3.0
Features
✅ Universal - Works in browsers, React Native, and Node.js
✅ Type-safe - Full TypeScript support
✅ Automatic batching - Efficient event batching with configurable thresholds
✅ Persistent storage - Uses localStorage, AsyncStorage, or memory as fallback
✅ Retry logic - Exponential backoff for failed requests
✅ Auto metadata - Automatically collects browser/device information
✅ Small bundle - <5KB gzipped (tracking entry; recommender is a separate opt-in entry)
Installation
npm install @logg/signalsFor React Native, also install AsyncStorage:
npm install @react-native-async-storage/async-storageQuick Start
Web (Browser)
import { Signals } from '@logg/signals';
const signals = new Signals({
apiKey: 'your-api-key',
endpoint: 'https://signals.yourdomain.com/events',
});
// Track events
signals.event({
type: 'page_view',
page: '/dashboard',
userId: '12345',
});
signals.event({
type: 'button_click',
element: 'signup_cta',
userId: '12345',
});React Native
import { Signals } from '@logg/signals';
import AsyncStorage from '@react-native-async-storage/async-storage';
const signals = new Signals({
apiKey: 'your-api-key',
endpoint: 'https://signals.yourdomain.com/events',
// AsyncStorage is auto-detected, but you can pass it explicitly
});
// Track events
signals.event({
type: 'screen_view',
screen: 'HomeScreen',
userId: user.id,
});
signals.event({
type: 'purchase',
productId: 'premium-plan',
amount: 29.99,
userId: user.id,
});Node.js
import { Signals } from '@logg/signals';
const signals = new Signals({
apiKey: 'your-api-key',
endpoint: 'https://signals.yourdomain.com/events',
});
// Track server-side events
await signals.event({
type: 'api_call',
endpoint: '/api/users',
method: 'POST',
userId: req.user.id,
});
// Make sure to flush before process exit
process.on('beforeExit', async () => {
await signals.flush();
});Configuration
const signals = new Signals({
// Required
apiKey: 'your-api-key',
endpoint: 'https://signals.yourdomain.com/events',
// Optional
batchSize: 10, // Send after 10 events (default: 10)
batchInterval: 5000, // Or every 5 seconds (default: 5000)
maxRetries: 3, // Retry failed requests 3 times (default: 3)
retryDelay: 1000, // Initial retry delay in ms (default: 1000)
debug: false, // Enable debug logging (default: false)
sessionId: 'custom-id', // Custom session ID (auto-generated by default)
storage: customAdapter, // Custom storage adapter (auto-detected by default)
});API Reference
signals.event(eventData)
Track an event. Events are automatically batched and sent based on batchSize and batchInterval config.
await signals.event({
type: 'event_type', // Required: event type
userId: 'user-123', // Optional: user ID
// ... any other properties
});Auto-added fields:
event_id- Unique event identifier (UUID v4)timestamp- ISO 8601 timestampsession_id- Session identifierclient- Client metadata (type, version, user_agent, screen, locale, timezone)
signals.flush()
Manually flush all pending events immediately.
await signals.flush();signals.getSessionId()
Get the current session ID.
const sessionId = signals.getSessionId();signals.getQueueSize()
Get the number of events in the queue.
const queueSize = signals.getQueueSize();signals.destroy()
Destroy the client. Drains the entire queue (flushes batches until empty
or sends start stalling) before shutting down. Always await this on
process exit / app teardown — anything left in the in-memory queue after
the Node process exits is lost.
await signals.destroy();signals.flushAll()
Drain the queue without destroying the client. Useful for backfill scripts that want a checkpoint before moving on. Returns the number of events that could not be sent (zero on full success).
const stranded = await signals.flushAll();
if (stranded > 0) {
console.warn(`${stranded} events failed to deliver`);
}signals.on('error', listener) / signals.off('error', listener)
Subscribe to delivery errors. signals.event() and signals.flush() never
throw on backend failures, so this is how you observe them. The listener
receives a SignalsErrorEvent:
const off = signals.on('error', (e) => {
// e.type: 'send_failed' | 'send_retry' | 'destroy_pending'
// e.message: human-readable description
// e.error: the underlying Error
// e.batchId: uuid of the failing/retrying batch (if applicable)
// e.eventCount: number of events in the affected batch
// e.pendingCount: number of events still queued after the failure
// e.attempt: 1-indexed retry attempt (for 'send_retry' only)
console.error('[signals]', e.type, e.message, e.pendingCount, 'queued');
});
// later
off();If no error listener is attached, the SDK falls back to console.warn
so failures are at least visible during development.
Event Batching
Events are automatically batched to reduce network requests:
- Batch by size: Sends when
batchSizeevents are queued (default: 10) - Batch by time: Sends every
batchIntervalmilliseconds (default: 5000) - Manual flush: Call
signals.flush()to send immediately
Batch format sent to backend:
{
"api_key": "your-api-key",
"batch_id": "batch-uuid",
"timestamp": "2025-12-02T10:30:00.000Z",
"metadata": {
"type": "web",
"version": "0.1.0",
"user_agent": "Mozilla/5.0...",
"screen": { "width": 1920, "height": 1080 },
"locale": "en-US",
"timezone": "America/New_York"
},
"events": [
{
"event_id": "uuid-1",
"timestamp": "2025-12-02T10:30:00.000Z",
"session_id": "session-uuid",
"type": "page_view",
"userId": "12345",
"page": "/dashboard"
}
]
}Storage Adapters
The SDK automatically detects the best storage adapter:
- Web:
LocalStorageAdapter(useslocalStorage) - React Native:
AsyncStorageAdapter(uses@react-native-async-storage/async-storage) - Node.js:
MemoryStorageAdapter(in-memory, no persistence)
Custom Storage Adapter
You can provide a custom storage adapter:
import { Signals, StorageAdapter } from '@logg/signals';
class CustomStorageAdapter implements StorageAdapter {
async getItem(key: string): Promise<string | null> {
// Your implementation
}
async setItem(key: string, value: string): Promise<void> {
// Your implementation
}
async removeItem(key: string): Promise<void> {
// Your implementation
}
}
const signals = new Signals({
apiKey: 'your-api-key',
endpoint: 'https://signals.yourdomain.com/events',
storage: new CustomStorageAdapter(),
});Error Handling
The SDK includes automatic retry logic with exponential backoff:
- Failed requests are retried up to
maxRetriestimes (default: 3) - Retry delay doubles after each attempt (exponential backoff)
- Events are persisted in storage and retried on next batch
const signals = new Signals({
apiKey: 'your-api-key',
endpoint: 'https://signals.yourdomain.com/events',
maxRetries: 5, // Retry up to 5 times
retryDelay: 2000, // Start with 2 second delay
debug: true, // Log retry attempts
});React Integration
Track Page Views
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function App() {
const location = useLocation();
useEffect(() => {
signals.event({
type: 'page_view',
page: location.pathname,
title: document.title,
});
}, [location]);
return <div>...</div>;
}Track User Actions
function SignupButton() {
const handleClick = () => {
signals.event({
type: 'button_click',
element: 'signup_cta',
page: '/landing',
});
// Navigate to signup...
};
return <button onClick={handleClick}>Sign Up</button>;
}React Native Integration
import { Signals } from '@logg/signals';
import { useEffect } from 'react';
import { useNavigation } from '@react-navigation/native';
const signals = new Signals({
apiKey: 'your-api-key',
endpoint: 'https://signals.yourdomain.com/events',
});
function HomeScreen() {
const navigation = useNavigation();
useEffect(() => {
// Track screen view
signals.event({
type: 'screen_view',
screen: 'HomeScreen',
});
}, []);
return (
<Button
title="Buy Premium"
onPress={() => {
signals.event({
type: 'button_press',
button: 'buy_premium',
screen: 'HomeScreen',
});
navigation.navigate('Checkout');
}}
/>
);
}Best Practices
1. Initialize Once
Create a single instance and reuse it:
// lib/signals.ts
import { Signals } from '@logg/signals';
export const signals = new Signals({
apiKey: process.env.SIGNALS_API_KEY!,
endpoint: process.env.SIGNALS_ENDPOINT!,
});
// app.tsx
import { signals } from './lib/signals';
signals.event({ type: 'app_opened' });2. Flush on Exit
Always flush events before the app closes:
// React Native
useEffect(() => {
return () => {
signals.flush();
};
}, []);
// Node.js
process.on('beforeExit', async () => {
await signals.flush();
});3. Type-safe Events
Define your event types for better DX:
type AppEvent =
| { type: 'page_view'; page: string; title: string }
| { type: 'button_click'; element: string }
| { type: 'purchase'; productId: string; amount: number };
const signals = new Signals({...});
function trackEvent(event: AppEvent) {
signals.event(event);
}
// Now fully type-safe!
trackEvent({ type: 'page_view', page: '/home', title: 'Home' });4. User Identification
Include user ID in all events:
function trackUserEvent(event: Omit<EventData, 'userId'>) {
const userId = getCurrentUserId();
signals.event({ ...event, userId });
}On-Device Recommender (@logg/signals/reco)
A pure-TypeScript, zero-dependency recommendation engine that runs entirely on the client — ship it inside a React Native bundle, a web app, or a Node process. The backend hands the app a flat catalog (a few thousand items × a few feature columns); the device ranks it live as the user scrolls, dwells, taps, and favourites.
It lives at its own entry point so tracking-only consumers don't pay for it:
import { Recommender, DualBucketRecommender, SIGNALS } from '@logg/signals/reco';
import type { BaseItem, Schema } from '@logg/signals/reco';Two engines ship behind the same surface — pick at construction time:
| Engine | Class | Best for |
|---|---|---|
| v1 | Recommender | Probabilistic exploration, smoother defaults |
| v2 | DualBucketRecommender | Explicit liked/disliked separation, tighter exploit |
The library is domain-agnostic and generic over your item shape. You bring
an item type extending BaseItem (only id is required) and a Schema<T>
that extracts categorical feature values from each item — the engine has no
built-in vocabulary of brands, prices, or categories.
import { Recommender, SIGNALS, logDecadeBucket, type BaseItem, type Schema } from '@logg/signals/reco';
interface Listing extends BaseItem {
brand: string | null;
category: string | null;
price_cents: number | null;
popularity: number;
}
const schema = {
brand: { extract: (i: Listing) => i.brand, capacity: 4, weight: 0.4 },
category: { extract: (i: Listing) => i.category, capacity: 2, weight: 0.2 },
price_band: {
extract: (i: Listing) => (i.price_cents != null ? logDecadeBucket(i.price_cents / 100) : null),
weight: 0.4,
},
} satisfies Schema<Listing>;
const reco = new Recommender(catalog, {
schema,
popularity: (i) => i.popularity, // optional cold-start prior
});
reco.prime(usersCollection); // pre-warm from a known collection
reco.setOwned(['id-1', 'id-2']); // excluded from results
reco.engage(item, SIGNALS.view); // saw and scrolled past
reco.engage(item, SIGNALS.collect); // added to collection
reco.engage(item, -3); // strong negative — caller picks any magnitude
const { items, scores, diagnostics } = reco.recommend(20);Methods shared by both engines: prime(), engage(), recommend(),
setOwned(), clearSeen(), reset(), seenCount(). The dual engine adds
setSampleSize(k) to tune explore vs. exploit.
Advanced primitives (slot tables, interest state, bucket admission, the
weighted sampler, and seededRng for deterministic tests) are exported from
the same entry for callers building custom pipelines or CLIs on top.
License
MIT
