@cherrydotfun/collector-sdk
v0.3.0
Published
TypeScript SDK for Cherry Collector — event tracking, logging, and metrics
Readme
@cherrydotfun/collector-sdk
TypeScript SDK for Cherry Collector — event tracking, logging, and metrics.
Collect errors, logs, and analytics events from your application and stream them to the Collector backend. Zero dependencies, works on browser, React Native, and Node.js.
Installation
npm install @cherrydotfun/collector-sdkyarn add @cherrydotfun/collector-sdkbun add @cherrydotfun/collector-sdkQuick Start
Initialize the client, send events, and flush before exit:
import { CollectorClient, SolanaTokens } from '@cherrydotfun/collector-sdk';
const collector = new CollectorClient({
baseUrl: 'https://analytics.cherry.fun',
apiKey: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
platform: 'ios',
version: '2.1.0',
});
// Initialize: fetch filter config and start refresh timer
await collector.init();
// Set context (attached to all subsequent events)
collector.setUser('wallet_address_or_user_id');
collector.setSession('session_uuid');
collector.setModule('messaging');
collector.setDevice('device_id');
// Send events
collector.error('WebSocket connection failed', { url: '/ws' });
collector.info('User logged in', { userId: 'wallet123' });
collector.debug('Cache hit', { key: 'messages_dm_123' });
collector.analytics('screen_view', 'ChatScreen', { fromScreen: 'RoomList' });
collector.analytics('button_click', 'Send message', { hasAttachment: true });
// Record revenue (immediate, not batched — await it). amount is human-readable.
await collector.revenue({ source: 'swap', amount: 0.005, token: SolanaTokens.WSOL });
// Set metrics
await collector.metric('ws_connections', 42);
await collector.metricBatch([
{ key: 'ws_connections', value: 42 },
{ key: 'active_rooms', value: 15 },
]);
// Flush remaining events and stop timers
await collector.flush();
collector.destroy();Configuration
Pass a CollectorConfig object to the constructor:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| baseUrl | string | required | Base URL of the Collector backend (e.g. https://analytics.cherry.fun). |
| apiKey | string | required | Project API key from the admin panel. |
| platform | string | undefined | Client platform (e.g. ios, android, web, server). Attached to all events. |
| version | string | undefined | Application version string (e.g. 2.1.0). Attached to all events. |
| batchSize | number | 10 | Number of events per batch before auto-flush. |
| flushIntervalMs | number | 5000 | Milliseconds between automatic batch flushes. |
| configRefreshSec | number | 300 | Seconds between config refreshes from server. |
| debug | boolean | false | Enable debug logging to console.debug. |
| onError | function | undefined | Called when network or API errors occur. Receives (error, context). |
| onSuppressed | function | undefined | Called when an event is dropped by a filter rule (client-side or server-side). Receives (event, pattern, origin) where origin is 'client' or 'server'. Applies to all event types. |
API Reference
Lifecycle
init(): Promise<void>
Initialize the client. Fetches filter configuration from the server and starts the periodic config refresh timer. Must be called before sending events.
await collector.init();flush(): Promise<void>
Flush all queued events to the server immediately. Returns when the send completes (or fails silently).
await collector.flush();destroy(): void
Stop all timers and flush remaining events. Call before application exit or when the client is no longer needed.
collector.destroy();Context
These setters attach context to all subsequent events:
setUser(userId: string): void
Set the user ID.
collector.setUser('wallet_address_or_user_id');setSession(sessionId: string): void
Set the session ID.
collector.setSession('session_uuid');setModule(module: string): void
Set the default module name.
collector.setModule('messaging');setDevice(deviceId: string): void
Set the device ID.
collector.setDevice('device_uuid_or_id');Errors
error(message: string, params?: Record<string, unknown> & { stackTrace?: string }): void
Send an error event. The special stackTrace key is extracted and sent as the structured stackTrace.raw field.
try {
await fetchData();
} catch (e) {
collector.error('Failed to fetch data', {
url: '/api/data',
stackTrace: e instanceof Error ? e.stack : undefined,
});
}Logging
log(level: LogLevel, message: string, params?: Record<string, unknown>): void
Send a log event with an explicit level (fatal, error, warn, info, or debug).
collector.log('warn', 'Slow query detected', { duration: 2500 });fatal(message: string, params?: Record<string, unknown>): void
Shorthand for log('fatal', message, params).
collector.fatal('Critical system failure');error(message: string, params?: Record<string, unknown>): void
Shorthand for log('error', message, params). Note: this is different from the error() method above, which has special stackTrace handling.
warn(message: string, params?: Record<string, unknown>): void
Shorthand for log('warn', message, params).
collector.warn('High memory usage', { mb: 512 });info(message: string, params?: Record<string, unknown>): void
Shorthand for log('info', message, params).
collector.info('Server started', { port: 3000 });debug(message: string, params?: Record<string, unknown>): void
Shorthand for log('debug', message, params).
collector.debug('Cache miss', { key: 'user_settings_123' });Analytics
analytics(event: string, message: string, params?: Record<string, unknown>): void
Send an analytics event. The event parameter is an event name (e.g. screen_view, button_click), and message is a human-readable description.
collector.analytics('screen_view', 'ChatScreen', {
fromScreen: 'RoomList',
roomType: 'dm',
});
collector.analytics('button_click', 'Send message', {
hasAttachment: true,
messageLength: 42,
});Revenue
Revenue is the SDK's single money primitive. Provide a human-readable amount (signed number) — positive = inflow, negative = cost. Negative amounts require a reason qualifier so every cost is categorized. Revenue calls are sent immediately (not queued through flush()) — await them and avoid calling from hot loops without throttling.
For BigInt-precision use cases (whale amounts > 9007 SOL, on-chain reconciliation, future 18-decimal tokens), use rawAmount: string (smallest unit) instead of amount. Exactly one of the two is required.
See docs/decisions/002-revenue-data-model.md, docs/decisions/003-sdk-revenue-api.md, and docs/decisions/004-backend-decimal-registry.md for the full design.
revenue(input: RevenueInput): Promise<void>
Record a single revenue or cost event. Identity (userId, sessionId, deviceId, platform, version) is inherited from the client's context — no need to re-pass.
The canonical positive case — a SOL fee skim from a swap:
import { SolanaTokens } from '@cherrydotfun/collector-sdk';
await collector.revenue({
source: 'swap',
amount: 0.005, // 0.005 SOL — human-readable
token: SolanaTokens.WSOL,
usdValue: 1.25,
recipientWallet: 'Fee9oABC...DEF',
txHash: '5xY7zZ...QQ',
params: { pair: 'SOL/USDC' },
});Recording a cost
A cost is a revenue event with a negative amount and a required reason. Example: pre-funding a daily prize pool that bet revenue is expected to cover.
await collector.revenue({
source: 'bet',
amount: -1000, // -1000 USDC (signed number)
reason: 'jackpot_seeding', // REQUIRED when amount < 0
token: SolanaTokens.USDC,
usdValue: -1000.00,
recipientWallet: 'PrizePool111...PublicKey',
txHash: 'abc...',
params: { campaign: 'daily-bets', expectedCoveragePeriod: '24h' },
});Precision path — rawAmount for whales / on-chain reconciliation
Use rawAmount instead of amount when you need exact smallest-unit precision. The string carries an integer count of lamports / base units, BigInt-safe (no float rounding). Mutually exclusive with amount.
await collector.revenue({
source: 'whale_swap',
rawAmount: '1500000000000', // exactly 1500 SOL in lamports
token: SolanaTokens.WSOL,
usdValue: 375_000,
txHash: 'whaleTx...QQ',
});Suggested reason vocabulary: jackpot_seeding, marketing, integration_partner, infra, correction, refund. Free-form — the project owns the list.
For costs, recipientWallet flips semantics: it's the external wallet you paid, not your own.
Fiat / partnership revenue
Off-chain money flows use network: 'fiat', address: null, and an ISO-4217 symbol. Example: a partnership deal paid via wire transfer.
await collector.revenue({
source: 'partnership',
amount: 25000, // $25,000.00 (human-readable)
token: {
symbol: 'USD',
address: null,
network: 'fiat',
decimals: 2, // fiat tokens aren't in the registry — caller supplies
},
usdValue: 25000.00,
message: 'Q2 integration partnership: AcmeCorp',
params: {
partner: 'AcmeCorp',
deal: 'Q2-2026-integration',
invoiceId: 'INV-2026-0042',
paymentMethod: 'wire',
},
});Multi-token revenue from one transaction
When a single on-chain action produces revenue in multiple tokens (e.g. a swap collecting a SOL base fee AND a USDC referral skim), emit one event per token and share the same txHash. This keeps every dashboard GROUP BY query a one-liner — no $unwind step needed to aggregate by token or by source.
const txHash = '5xY7zZ...QQ';
await collector.revenueBatch([
{
source: 'swap',
amount: 0.005, // 0.005 SOL
token: SolanaTokens.WSOL,
usdValue: 1.25,
txHash,
},
{
source: 'swap',
amount: 0.001, // 0.001 USDC
token: SolanaTokens.USDC,
usdValue: 0.25,
txHash,
},
]);revenueBatch(inputs: RevenueInput[]): Promise<void>
Record multiple revenue/cost events in one request via /api/collect/batch. Sent immediately. Use for high-throughput callers (e.g. swap aggregators batching per-block). Backend accepts up to 100 events per call.
await collector.revenueBatch([
{ source: 'swap', amount: 0.005, token: SolanaTokens.WSOL, usdValue: 1.25 },
{ source: 'swap', amount: 0.0075, token: SolanaTokens.WSOL, usdValue: 1.88 },
{ source: 'vault', amount: 2.5, token: SolanaTokens.USDC, usdValue: 2.50 },
]);Token helpers
The SolanaTokens constant exposes pre-built TokenRef entries for common Solana tokens. Initial set: WSOL, USDC, USDT, USDG.
import { SolanaTokens } from '@cherrydotfun/collector-sdk';
SolanaTokens.WSOL; // { symbol: 'SOL', address: 'So11...112', network: 'solana', decimals: 9 }
SolanaTokens.USDC; // { symbol: 'USDC', address: 'EPjF...Dt1v', network: 'solana', decimals: 6 }
SolanaTokens.USDT; // { symbol: 'USDT', address: 'Es9v...wNYB', network: 'solana', decimals: 6 }
SolanaTokens.USDG; // { symbol: 'USDG', address: '2u1t...jGWH', network: 'solana', decimals: 6 }For tokens not in the helper, build a TokenRef literal. decimals is now optional — backend fills from its registry when the mint is known:
await collector.revenue({
source: 'swap',
amount: 1.5,
token: {
symbol: 'JUP',
address: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN',
network: 'solana',
// decimals omitted — backend fills if registry knows this mint;
// otherwise rejects with a 400 (you'd then either supply decimals
// or ask an admin to add the mint to the registry).
},
});For tokens not in the helper, construct a TokenRef literal:
import type { TokenRef } from '@cherrydotfun/collector-sdk';
const BONK: TokenRef = {
symbol: 'BONK',
address: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263',
network: 'solana',
decimals: 5,
};
await collector.revenue({ source: 'swap', amount: '100000000', token: BONK });For native assets, use the wrapped-native mint as the canonical address (e.g. So11...112 for SOL). Cherry treats native and wrapped as one token family at the analytics layer.
Things to know
- Immediate, not batched.
revenue()andrevenueBatch()hit the network on each call and bypass theflush()queue. Alwaysawaitthem, and don't fire them inside hot loops without throttling. reasonis required whenamountis negative. The server enforces this; missing-reason cost events will be rejected.messageis auto-synthesized when omitted. Examples:"Revenue: 0.005 SOL","Cost (jackpot_seeding): 1000 USDC". Passmessageexplicitly when you want a custom summary.- Wire-protocol naming for raw-HTTP integrators. On the wire, the
sourcefield is sent asevent(the SDK mapssource → eventso the storage layer can reuse the existing column). SDK consumers get thesourcealias for free; only raw-HTTP integrators bypassing the SDK need to know this. - Silent suppression by filter rules. A filter rule like
revenue.swaporrevenue.*will drop matching events without raising an error. Pass anonSuppressedcallback (see the Configuration table) to detect these — client-side and server-side suppressions both fire it.
Metrics
Metrics are sent immediately (not batched).
metric(key: string, value: number): Promise<void>
Set a single metric value.
await collector.metric('ws_connections', 42);
await collector.metric('active_rooms', 15);metricBatch(ops: MetricOp[]): Promise<void>
Set multiple metric values in one request. The backend accepts up to 100 operations per call.
await collector.metricBatch([
{ key: 'ws_connections', value: 42 },
{ key: 'active_rooms', value: 15 },
{ key: 'cpu_usage', value: 34.5 },
]);Direct Send
send(event: Partial<CollectorEvent> & { type: CollectorEvent['type']; message: string }): Promise<void>
Send a single event immediately via POST /api/collect, bypassing the batch queue. Useful for critical events that must not be delayed.
The event is enriched with context (userId, sessionId, platform, etc.) and filtered against disabled patterns, same as queued events.
await collector.send({
type: 'error',
level: 'error',
message: 'Critical error',
params: { severity: 'high' },
});Configuration
getConfig(): ConfigResponse | null
Returns the last successfully fetched server configuration, or null if no config has been loaded yet.
const config = collector.getConfig();
if (config) {
console.log('Disabled patterns:', config.disabled);
}refreshConfig(): Promise<void>
Force-refresh the filter configuration from the server. Normally called automatically on the configured interval.
await collector.refreshConfig();Observability
getStats(): CollectorStats
Returns SDK telemetry counters: events sent, dropped (filtered), failed, and current queue size.
const stats = collector.getStats();
console.log(`Sent: ${stats.sent}, Dropped: ${stats.dropped}, Failed: ${stats.failed}`);isInitialized: boolean
Read-only getter. Whether init() has been called and completed successfully.
if (collector.isInitialized) {
console.log('Ready to send events');
}Error Handling
Pass an onError callback to handle network and API errors:
const collector = new CollectorClient({
baseUrl: 'https://analytics.cherry.fun',
apiKey: 'xxxxx',
onError: (error, context) => {
console.error(`Collector error in ${context}:`, error.message);
// Log to your error tracking service, retry, etc.
},
});
await collector.init();The context parameter indicates where the error occurred:
sendBatch- batch event sendsend- direct event sendmetric- metric setmetricBatch- batch metric setrefreshConfig- config refresh from server
Event Filtering
The SDK fetches a filter configuration from the server on init and periodically refreshes it. Events matching disabled patterns are dropped client-side and not sent.
Patterns are hierarchical:
log.debug— disable debug-level log eventslog.*— disable all log eventslog— disable all log events (alternate syntax)*— disable all events
When you call collector.debug(...), the SDK checks if log.debug is in the disabled set. If so, the event is dropped locally (not sent), and the dropped counter is incremented.
const stats = collector.getStats();
console.log(`Events dropped: ${stats.dropped}`);Platform Support
The SDK requires globalThis.fetch to be available:
- Browser: Native fetch, or polyfill
- React Native: Built-in fetch (works with
http://andhttps://) - Node.js 18+: Native fetch
For Node.js < 18, provide a fetch polyfill (e.g. node-fetch):
import fetch from 'node-fetch';
globalThis.fetch = fetch;
const collector = new CollectorClient({ /* ... */ });Types
All types are exported from @cherrydotfun/collector-sdk:
import {
CollectorClient,
CollectorConfig,
CollectorEvent,
CollectorStats,
ConfigResponse,
EventType,
LogLevel,
AnalyticsLevel,
MetricOp,
IngestResponse,
BatchIngestResponse,
} from '@cherrydotfun/collector-sdk';Type Reference
| Type | Description |
|------|-------------|
| CollectorClient | Main client class for sending events and metrics. |
| CollectorConfig | Configuration options for the client. |
| CollectorEvent | Event payload sent to the backend. |
| CollectorStats | SDK telemetry counters (sent, dropped, failed, queueSize). |
| ConfigResponse | Server response from GET /api/config with disabled patterns. |
| EventType | Union of 'error' \| 'log' \| 'analytics'. |
| LogLevel | Union of 'fatal' \| 'error' \| 'warn' \| 'info' \| 'debug'. |
| AnalyticsLevel | Union of well-known analytics events or any custom string. |
| MetricOp | Single metric key-value pair for batch operations. |
| IngestResponse | Response from single event send. |
| BatchIngestResponse | Response from batch event send. |
React Native Example
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { CollectorClient } from '@cherrydotfun/collector-sdk';
const collector = new CollectorClient({
baseUrl: 'https://analytics.cherry.fun',
apiKey: 'xxxxx',
platform: 'android', // or 'ios'
version: '2.1.0',
debug: false,
onError: (error, context) => {
console.error(`Collector error [${context}]:`, error);
},
});
export function App() {
useEffect(() => {
const init = async () => {
await collector.init();
collector.setUser(userWallet);
collector.setSession(sessionId);
collector.setModule('chat');
};
init();
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'background') {
collector.flush();
}
});
return () => {
subscription.remove();
collector.flush();
collector.destroy();
};
}, []);
return (
// Your app components
);
}Node.js Server Example
import { CollectorClient } from '@cherrydotfun/collector-sdk';
const collector = new CollectorClient({
baseUrl: 'https://analytics.cherry.fun',
apiKey: 'xxxxx',
platform: 'server',
version: '1.0.0',
debug: process.env.DEBUG === 'true',
});
await collector.init();
// Log application startup
collector.info('Server started', { port: 3000, env: process.env.NODE_ENV });
// Track unhandled errors
process.on('uncaughtException', (error) => {
collector.error('Uncaught exception', {
name: error.name,
message: error.message,
stackTrace: error.stack,
});
collector.flush().finally(() => process.exit(1));
});
// On graceful shutdown
process.on('SIGTERM', async () => {
console.log('Shutting down...');
await collector.flush();
collector.destroy();
process.exit(0);
});Build Distribution
The SDK is published in multiple formats:
- CommonJS —
dist/index.jsfor Node.js - ES Modules —
dist/index.mjsfor bundlers - TypeScript Declarations —
dist/index.d.ts
License
MIT
