DEV Community

Cover image for How I Built a Funnel Analytics Engine with Laravel Horizon, Redis and a Dead-Simple REST API
Sven Arndt
Sven Arndt

Posted on

How I Built a Funnel Analytics Engine with Laravel Horizon, Redis and a Dead-Simple REST API

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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:

  1. Performance — The API response stays fast regardless of how many funnels need recalculation
  2. 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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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'
});
Enter fullscreen mode Exit fullscreen mode

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)