Most analytics tools fall into one of two traps: they're either too shallow to be useful, or so complex that integration alone takes a sprint. I got tired of both. So I built my own.
This is the story of how I built Tracetics — a funnel analytics engine for developers — and the technical decisions behind it.
The Core Problem
Funnel analytics sounds simple: a user does A, then B, then C. What percentage make it from A to C? Where do they drop off?
In practice, it's surprisingly tricky to build well:
- Events arrive asynchronously and out of order
- Funnels need to be flexible — different steps, different timeframes
- Calculation needs to be fast, even with thousands of events
- The integration overhead for the developer must be minimal
My goal: a developer should be able to start tracking in under 5 minutes with a single HTTP POST.
Architecture Overview
Browser/App → REST API → Event Storage → Queue → Funnel Engine → Dashboard
The stack:
- Backend: Laravel 12 with a modular architecture (nwidart/laravel-modules)
- Queue: Laravel Horizon + Redis
- Frontend: Next.js 14 + TypeScript
- Database: MariaDB
- Payments: Stripe
The API Layer
The integration is intentionally minimal:
POST /api/v1/events
X-OL-Tenant-Key: your-tenant-key
X-OL-App-Key: your-app-key
Content-Type: application/json
{
"event_name": "signup_completed",
"user_identifier": "user_123",
"metadata": {
"plan": "pro",
"source": "landing_page"
}
}
Two headers, one JSON body. That's the entire contract.
The controller validates the keys, identifies the tenant and tracked app, persists the event, and immediately returns 200. No heavy lifting in the request lifecycle.
The Funnel Engine
This is where it gets interesting.
When a user builds a funnel in the dashboard — say "visited_pricing → started_trial → upgraded" — the system needs to calculate conversion rates for each step.
I deliberately moved this out of the request cycle entirely. Every event write dispatches a background job:
ProcessFunnelEngineJob::dispatch($event)->onQueue('funnels');
The job picks up the event, loads all funnels for that app, and recalculates conversion rates per step using a sliding window approach. Results are cached and served to the dashboard.
Why a queue? Two reasons:
- Performance — The API response stays fast regardless of how many funnels need recalculation
- Resilience — Failed jobs retry automatically, no data loss on transient errors
Laravel Horizon gives us a real-time dashboard to monitor job throughput, failed jobs, and queue depth without any additional infrastructure.
Multi-Tenancy
Tracetics is multi-tenant by design. Each tenant (company) can have multiple TrackedApps, each with their own events and funnels. The key hierarchy looks like this:
Tenant
└── TrackedApp (X-OL-App-Key)
└── Events
└── Funnels
└── FunnelSteps
Authentication at the API level uses two headers:
-
X-OL-Tenant-Key— identifies the tenant -
X-OL-App-Key— identifies which app the event belongs to
This keeps the integration clean on the developer side while enforcing strict data isolation on the backend.
Plan Limits & Billing
Each plan defines limits for TrackedApps, Funnels, and monthly Events. These are enforced at the controller level before any write operation:
if ($tenant->trackedApps()->count() >= $tenant->plan->app_limit) {
return response()->json(['error' => 'App limit reached'], 403);
}
Stripe handles subscriptions via webhooks. The interesting challenge here: Stripe's newer API moved current_period_end into items.data[0] rather than the subscription root object. A subtle breaking change that cost me an hour of debugging.
The TypeScript SDK
For developers who prefer typed clients over raw HTTP, I published a TypeScript SDK:
npm install tracetics-sdk
import { Tracetics } from 'tracetics-sdk';
const client = new Tracetics({
tenantKey: 'your-tenant-key',
trackedAppKey: 'your-app-key',
endpoint: 'https://tracetics.com'
});
await client.track({
event_name: 'signup_completed',
user_identifier: 'user_123'
});
The SDK is built with tsup — dual ESM/CJS output, full TypeScript types, zero dependencies.
What I Learned
1. Keep the write path dumb and fast.
The API should do as little as possible. Validate, persist, enqueue. Everything else is the queue's problem.
2. Queue-first architecture pays off immediately.
I never had to worry about slow funnel calculations blocking API responses. The separation of concerns made both sides easier to reason about and debug.
3. Stripe webhooks are not optional.
I made the mistake early on of relying only on redirect-based confirmation. Webhooks are the only reliable source of truth for subscription state.
4. Multi-tenancy is easier with a clear key hierarchy.
Having explicit Tenant → App → Event ownership made permission checking trivial and data isolation bulletproof.
What's Next
Tracetics is live at tracetics.com with a free plan available. The TypeScript SDK is on npm as tracetics-sdk.
If you've built something similar or have questions about the architecture, I'd love to hear from you in the comments.
Top comments (0)