A highly typed, provider-agnostic analytics library for TypeScript applications. Works seamlessly on both client and server sides with full type safety for your custom events.
- Features
- Installation
- Quick Start
- Async Tracking
- A complete example
- Advanced Usage
- Server Deployments and waitUntil
- API Reference
- Best Practices
- Contributing
- License
- 🎯 Type-safe events: Define your own strongly typed events with full IntelliSense support
- 🔌 Plugin architecture: Easily add analytics providers by passing them as plugins
- 🌐 Universal: Same API works on both client (browser) and server (Node.js)
- 📦 Lightweight: Zero dependencies on the core library
- 🏗️ Framework agnostic: Use with any JavaScript framework
- 🌎 Edge ready: The server client is compatible with edge runtime (e.g. Cloudflare Workers)
- 🔧 Extensible: Simple interface to add new providers
pnpm install @stacksee/analytics
# For PostHog support
pnpm install posthog-js posthog-node
Create strongly typed events specific to your application:
import { CreateEventDefinition, EventCollection } from '@stacksee/analytics';
export const appEvents = {
userSignedUp: {
name: 'user_signed_up',
category: 'user',
properties: {} as {
userId: string;
email: string;
plan: 'free' | 'pro' | 'enterprise';
referralSource?: string;
}
},
featureUsed: {
name: 'feature_used',
category: 'engagement',
properties: {} as {
featureName: string;
userId: string;
duration?: number;
}
}
} as const satisfies EventCollection<Record<string, CreateEventDefinition<string>>>;
// Optionally extract types for use in your app
export type AppEvents = typeof appEvents;
export type AppEventName = keyof typeof appEvents;
export type AppEventProperties<T extends AppEventName> = typeof appEvents[T]['properties'];
Tip: If you have a lot of events, you can also divide your events into multiple files, then export them as a single object.
import { createClientAnalytics } from '@stacksee/analytics/client';
import { PostHogClientProvider } from '@stacksee/analytics/providers/posthog';
import type { AppEvents } from './events';
// Initialize analytics with providers as plugins
// Pass your event collection as a type parameter for full type safety
const analytics = createClientAnalytics<AppEvents>({
providers: [
new PostHogClientProvider({
apiKey: 'your-posthog-api-key',
host: 'https://app.posthog.com' // optional
}),
// Add more providers here as needed
],
debug: true,
enabled: true
});
// Track events with full type safety - event names and properties are typed!
analytics.track('user_signed_up', {
userId: 'user-123',
email: 'user@example.com',
plan: 'pro',
referralSource: 'google'
});
// TypeScript will error if you use wrong event names or properties
// analytics.track('wrong_event', {}); // ❌ Error: Argument of type '"wrong_event"' is not assignable
// analytics.track('user_signed_up', { wrongProp: 'value' }); // ❌ Error: Object literal may only specify known properties
// Identify users
analytics.identify('user-123', {
email: 'user@example.com',
name: 'John Doe',
plan: 'pro'
});
import { createServerAnalytics } from '@stacksee/analytics/server';
import { PostHogServerProvider } from '@stacksee/analytics/providers/posthog';
import type { AppEvents } from './events';
// Create analytics instance with providers as plugins
// Pass your event collection as a type parameter for full type safety
const analytics = createServerAnalytics<AppEvents>({
providers: [
new PostHogServerProvider({
apiKey: process.env.POSTHOG_API_KEY,
host: process.env.POSTHOG_HOST
}),
// Add more providers here as needed
],
debug: process.env.NODE_ENV === 'development',
enabled: true
});
// Track events - now returns a Promise with full type safety
await analytics.track('feature_used', {
featureName: 'export-data',
userId: 'user-123',
duration: 1500
}, {
userId: 'user-123',
context: {
page: {
path: '/api/export',
}
}
});
// Important: Always call shutdown when done, some providers such as Posthog require flushing events.
await analytics.shutdown();
The track()
method now returns a Promise<void>
, giving you control over how to handle event tracking:
// Don't await - let events send in the background
analytics.track('button_clicked', {
buttonId: 'checkout',
label: 'Proceed to Checkout'
});
// User interaction continues immediately
// In serverless/edge functions, you have two patterns:
// Pattern 1: Critical events that MUST complete before response
export async function handler(req, res) {
try {
// Process payment
const paymentResult = await processPayment(req.body);
// For critical events like payments, await to ensure they're tracked
// This blocks the response but guarantees the event is recorded
await analytics.track('payment_processed', {
amount: paymentResult.amount,
currency: 'USD',
userId: req.userId,
transactionId: paymentResult.id
});
return res.json({ success: true, transactionId: paymentResult.id });
} catch (error) {
// Even on error, you might want to track
await analytics.track('payment_failed', {
error: error.message,
userId: req.userId
});
return res.status(500).json({ error: 'Payment failed' });
}
}
// Pattern 2: Non-critical events using waitUntil (Vercel example)
import { waitUntil } from '@vercel/functions';
export default async function handler(req, res) {
const startTime = Date.now();
// Process request
const result = await processRequest(req);
// Track analytics in background without blocking response
waitUntil(
analytics.track('api_request', {
endpoint: req.url,
duration: Date.now() - startTime,
userId: req.headers['x-user-id']
}).then(() => analytics.shutdown())
);
// Response sent immediately
return res.json(result);
}
// The track method catches provider errors internally and logs them
// It won't throw even if a provider fails, ensuring one provider's failure
// doesn't affect others
// If you need to know about failures, check your logs
await analytics.track('important_event', { data: 'value' });
// Even if one provider fails, others will still receive the event
- Client-side: Usually fire-and-forget for better UX
- Server-side (serverless): Use
waitUntil
for non-critical events to avoid blocking responses - Server-side (long-running): Can await or fire-and-forget based on criticality
- Critical events: Always await (e.g., payments, sign-ups, conversions that must be recorded)
- High-volume/non-critical events: Use
waitUntil
in serverless or fire-and-forget in long-running servers - Error tracking: Consider awaiting to ensure errors are captured before function terminates
Here's a complete example using Svelte 5 that demonstrates both client and server-side analytics for a waitlist signup:
// src/lib/config/analytics.ts
import { createClientAnalytics } from '@stacksee/analytics/client';
import { PostHogClientProvider } from '@stacksee/analytics/providers/posthog';
import { PUBLIC_POSTHOG_API_KEY, PUBLIC_POSTHOG_HOST } from '$env/static/public';
// Define your events for the waitlist
export const appEvents = {
waitlistJoined: {
name: 'waitlist_joined',
category: 'user',
properties: {} as {
email: string;
source: string; // e.g., 'homepage_banner', 'product_page_modal'
}
},
waitlistApproved: {
name: 'waitlist_approved',
category: 'user',
properties: {} as {
userId: string; // This could be the email or a generated ID
email: string;
}
}
} as const;
// Client-side analytics instance
export const clientAnalytics = createClientAnalytics<AppEvents>({
providers: [
new PostHogClientProvider({
apiKey: PUBLIC_POSTHOG_API_KEY,
host: PUBLIC_POSTHOG_HOST
})
],
debug: import.meta.env.DEV
});
// src/lib/server/analytics.ts
import { createServerAnalytics } from '@stacksee/analytics/server';
import { PostHogServerProvider } from '@stacksee/analytics/providers/posthog';
import { AppEvents } from '$lib/config/analytics'; // Import AppEvents
import { PUBLIC_POSTHOG_API_KEY, PUBLIC_POSTHOG_HOST } from '$env/static/public';
export const serverAnalytics = createServerAnalytics<AppEvents>({
providers: [
new PostHogServerProvider({
apiKey: PUBLIC_POSTHOG_API_KEY,
host: PUBLIC_POSTHOG_HOST
})
],
debug: import.meta.env.DEV
});
<!-- src/routes/join-waitlist/+page.svelte -->
<script lang="ts">
import { clientAnalytics } from '$lib/config/analytics';
let email = $state('');
let loading = $state(false);
let message = $state('');
async function handleWaitlistSubmit(event: Event) {
event.preventDefault();
loading = true;
message = '';
try {
// Track waitlist joined event on the client
clientAnalytics.track('waitlist_joined', {
email,
source: 'waitlist_page_form'
});
// Submit email to the server
const response = await fetch('/api/join-waitlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || 'Failed to join waitlist');
}
message = 'Successfully joined the waitlist! We will notify you once you are approved.';
} catch (error) {
console.error('Waitlist submission failed:', error);
message = error instanceof Error ? error.message : 'An unexpected error occurred.';
} finally {
loading = false;
}
}
</script>
<h2>Join Our Waitlist</h2>
<form onsubmit={handleWaitlistSubmit}>
<label>
Email:
<input
type="email"
bind:value={email}
placeholder="you@example.com"
required
disabled={loading}
/>
</label>
<button type="submit" disabled={loading}>
{loading ? 'Joining...' : 'Join Waitlist'}
</button>
</form>
{#if message}
<p>{message}</p>
{/if}
// src/routes/api/join-waitlist/+server.ts
import { serverAnalytics } from '$lib/server/analytics';
import { json, type RequestHandler } from '@sveltejs/kit';
async function approveUserForWaitlist(email: string): Promise<{ userId: string }> {
console.log(`Processing waitlist application for: ${email}`);
const userId = `user_${Date.now()}_${email.split('@')[0]}`;
return { userId };
}
export const POST: RequestHandler = async ({ request }) => {
try {
const body = await request.json();
const email = body.email;
if (!email || typeof email !== 'string') {
return json({ success: false, message: 'Email is required' }, { status: 400 });
}
const { userId } = await approveUserForWaitlist(email);
serverAnalytics.track('waitlist_approved', {
userId,
email
}, {
userId,
context: {
page: {
path: '/api/join-waitlist'
},
ip: request.headers.get('x-forwarded-for') || undefined
}
});
// Important: Call shutdown if your application instance is short-lived. (e.g. serverless function)
// For long-running servers, you might call this on server shutdown.
await serverAnalytics.shutdown();
return json({ success: true, userId, message: 'Successfully joined and approved for waitlist.' });
} catch (error) {
console.error('Failed to process waitlist application:', error);
// In production, be careful about leaking error details
const errorMessage = error instanceof Error ? error.message : 'Internal server error';
return json({ success: false, message: errorMessage }, { status: 500 });
}
// Note: serverAnalytics.shutdown() should ideally be called when the server itself is shutting down,
// not after every request in a typical web server setup, unless the provider requires it for batching.
// For this example, PostHogServerProvider benefits from shutdown to flush events,
// so if this were, for example, a serverless function processing one event, calling shutdown would be appropriate.
// If it's a long-running server, manage shutdown centrally.
};
Event categories help organize your analytics data. The SDK provides predefined categories with TypeScript autocomplete:
product
- Product-related events (views, purchases, etc.)user
- User lifecycle events (signup, login, profile updates)navigation
- Page views and navigation eventsconversion
- Conversion and goal completion eventsengagement
- Feature usage and interaction eventserror
- Error tracking eventsperformance
- Performance monitoring events
You can also use custom categories for your specific needs:
export const appEvents = {
aiResponse: {
name: 'ai_response_generated',
category: 'ai', // Custom category
properties: {} as {
model: string;
responseTime: number;
tokensUsed: number;
}
},
customWorkflow: {
name: 'workflow_completed',
category: 'workflow', // Another custom category
properties: {} as {
workflowId: string;
duration: number;
steps: number;
}
}
} as const satisfies EventCollection<Record<string, CreateEventDefinition<string>>>;
Implement the AnalyticsProvider
interface to add support for other analytics services:
import { BaseAnalyticsProvider, BaseEvent, EventContext } from '@stacksee/analytics';
export class GoogleAnalyticsProvider extends BaseAnalyticsProvider {
name = 'GoogleAnalytics';
private measurementId: string;
constructor(config: { measurementId: string; debug?: boolean; enabled?: boolean }) {
super({ debug: config.debug, enabled: config.enabled });
this.measurementId = config.measurementId;
}
async initialize(): Promise<void> {
// Initialize GA
}
track(event: BaseEvent, context?: EventContext): void {
// Send event to GA
}
identify(userId: string, traits?: Record<string, unknown>): void {
// Set user properties in GA
}
// ... implement other required methods
}
Then use it as a plugin in your configuration:
const analytics = await createClientAnalytics<typeof AppEvents>({
providers: [
new PostHogClientProvider({ apiKey: 'xxx' }),
new GoogleAnalyticsProvider({ measurementId: 'xxx' })
]
});
Some analytics libraries are designed to work only in specific environments. For example:
- Client-only: Google Analytics (gtag.js), Hotjar, FullStory
- Server-only: Some enterprise analytics APIs that require secret keys
- Universal: PostHog, Segment (have separate client/server SDKs)
The library handles this by having separate provider implementations for client and server environments:
// Client-side provider for a client-only analytics service
import { BaseAnalyticsProvider, BaseEvent, EventContext } from '@stacksee/analytics';
export class MixpanelClientProvider extends BaseAnalyticsProvider {
name = 'Mixpanel-Client';
constructor(config: { projectToken: string }) {
super();
// Initialize Mixpanel browser SDK
}
// ... implement required methods
}
// Server-side provider for a server-only analytics service
export class MixpanelServerProvider extends BaseAnalyticsProvider {
name = 'Mixpanel-Server';
constructor(config: { projectToken: string; apiSecret: string }) {
super();
// Initialize Mixpanel server SDK with secret
}
// ... implement required methods
}
Then use the appropriate provider based on your environment:
// Client-side usage
import { createClientAnalytics } from '@stacksee/analytics/client';
import { MixpanelClientProvider } from './providers/mixpanel-client';
const clientAnalytics = createClientAnalytics<typeof AppEvents>({
providers: [
new MixpanelClientProvider({ projectToken: 'xxx' })
]
});
// Server-side usage
import { createServerAnalytics } from '@stacksee/analytics/server';
import { MixpanelServerProvider } from './providers/mixpanel-server';
const serverAnalytics = createServerAnalytics<typeof AppEvents>({
providers: [
new MixpanelServerProvider({
projectToken: 'xxx',
apiSecret: 'secret-xxx' // Server-only configuration
})
]
});
Important notes:
- Client providers should only use browser-compatible APIs
- Server providers can use Node.js-specific features and secret credentials
- The provider interface is the same, ensuring consistent usage patterns
- Import paths are separate (
/client
vs/server
) to prevent accidental usage in wrong environments
The plugin architecture makes it easy to send events to multiple analytics services simultaneously:
import { createClientAnalytics } from '@stacksee/analytics/client';
import { PostHogClientProvider } from '@stacksee/analytics/providers/posthog';
// Import your custom providers
import { GoogleAnalyticsProvider } from './providers/google-analytics';
import { MixpanelProvider } from './providers/mixpanel';
const analytics = createClientAnalytics<typeof AppEvents>({
providers: [
// PostHog for product analytics
new PostHogClientProvider({
apiKey: process.env.NEXT_PUBLIC_POSTHOG_KEY,
host: 'https://app.posthog.com'
}),
// Google Analytics for marketing insights
new GoogleAnalyticsProvider({
measurementId: process.env.NEXT_PUBLIC_GA_ID
}),
// Mixpanel for detailed user journey analysis
new MixpanelProvider({
projectToken: process.env.NEXT_PUBLIC_MIXPANEL_TOKEN
})
],
debug: process.env.NODE_ENV === 'development',
enabled: true
});
// All providers will receive this event
analytics.track('user_signed_up', {
userId: 'user-123',
plan: 'pro'
});
When deploying your application to serverless environments, it's important to handle analytics events properly to ensure they are sent before the function terminates. Different platforms provide their own mechanisms for this:
Vercel provides a waitUntil
API that allows you to continue processing after the response has been sent:
import { waitUntil } from '@vercel/functions';
export default async function handler(req, res) {
const analytics = createServerAnalytics<typeof AppEvents>({
providers: [new PostHogServerProvider({ apiKey: process.env.POSTHOG_API_KEY })]
});
// Process your request and prepare response
const result = { success: true, data: 'processed' };
// Use waitUntil to track events and flush without blocking the response
waitUntil(
analytics.track('api_request', {
endpoint: '/api/users',
method: 'POST',
statusCode: 200,
responseTime: 150
}).then(() => analytics.shutdown())
);
// Response is sent immediately, tracking happens in background
res.status(200).json(result);
}
Cloudflare Workers provides a waitUntil
method on the execution context:
export default {
async fetch(request, env, ctx) {
const analytics = createServerAnalytics<typeof AppEvents>({
providers: [new PostHogServerProvider({ apiKey: env.POSTHOG_API_KEY })]
});
// Process request and prepare response
const response = new Response('OK', { status: 200 });
// Use ctx.waitUntil to track events and flush without blocking the response
ctx.waitUntil(
analytics.track('worker_execution', {
url: request.url,
method: request.method,
cacheStatus: 'MISS',
executionTime: 45
}).then(() => analytics.shutdown())
);
// Response is returned immediately, tracking happens in background
return response;
}
};
Netlify Functions also support waitUntil
through their context object:
export async function handler(event, context) {
const analytics = createServerAnalytics<AppEvents>({
providers: [new PostHogServerProvider({ apiKey: process.env.POSTHOG_API_KEY })]
});
const responseBody = { success: true, data: 'processed' };
// Use context.waitUntil to track events and flush without blocking the response
context.waitUntil(
analytics.track('function_invocation', {
path: event.path,
httpMethod: event.httpMethod,
queryStringParameters: event.queryStringParameters,
executionTime: 120
}).then(() => analytics.shutdown())
);
// Response is returned immediately, tracking happens in background
return {
statusCode: 200,
body: JSON.stringify(responseBody)
};
}
Important Notes:
- Always call
analytics.shutdown()
withinwaitUntil
to ensure events are sent - The
waitUntil
API is platform-specific, so make sure to use the correct import/usage for your deployment platform - For long-running servers (not serverless), you should call
shutdown()
when the server itself is shutting down - Some providers may batch events, so
shutdown()
ensures all pending events are sent
Initialize analytics for browser environment with optional type-safe events.
TEvents
- (optional) Your event collection type for full type safetyconfig.providers
- Array of analytics provider instancesconfig.debug
- Enable debug loggingconfig.enabled
- Enable/disable analytics
const analytics = createClientAnalytics<typeof AppEvents>({
providers: [/* ... */],
debug: true,
enabled: true
});
track(eventName, properties): Promise<void>
- Track an event with type-safe event names and propertiesidentify(userId, traits)
- Identify a userpage(properties)
- Track a page viewreset()
- Reset user sessionupdateContext(context)
- Update event context
Create analytics instance for server environment with optional type-safe events.
TEvents
- (optional) Your event collection type for full type safetyconfig.providers
- Array of analytics provider instancesconfig.debug
- Enable debug loggingconfig.enabled
- Enable/disable analytics
const analytics = createServerAnalytics<AppEvents>({
providers: [/* ... */],
debug: true,
enabled: true
});
track(eventName, properties, options): Promise<void>
- Track an event with type-safe event names and propertiesidentify(userId, traits)
- Identify a userpage(properties, options)
- Track a page viewshutdown()
- Flush pending events and cleanup
CreateEventDefinition<TName, TProperties>
- Define a single eventEventCollection<T>
- Define a collection of eventsExtractEventNames<T>
- Extract event names from a collectionExtractEventPropertiesFromCollection<T, TEventName>
- Extract properties for a specific event
- Define events in a central location - Keep all event definitions in one file for consistency
- Use const assertions - Use
as const
for better type inference - Initialize early - Initialize analytics as early as possible in your app lifecycle
- Handle errors gracefully - Analytics should never break your app
- Respect privacy - Implement user consent and opt-out mechanisms
- Test your events - Verify events are tracked correctly in development
- Document events - Add comments to explain when each event should be fired
- Create provider instances once - Reuse provider instances across your app
Contributions are welcome! Please read our contributing guidelines before submitting PRs.
MIT