Skip to content

Repository files navigation

MouseTracker — self-hosted heatmaps & session replay for Symfony

Open source Hotjar / Mouseflow / Microsoft Clarity alternative for Symfony 5.4, 6.4 and 7.x: mouse movement & click heatmaps, scroll maps and session recording / replay, stored in your database. GDPR friendly, no third-party service, plugs into any site with one <script> tag.

Latest version Total downloads CI PHP >= 7.2.5 Symfony 5.4 | 6.4 | 7.x Doctrine ORM 2 | 3 MIT license

MouseTracker click heatmap dashboard

Table of contents

Why MouseTracker

MouseTracker SaaS (Hotjar, Mouseflow, Clarity…)
Data location your own database (Doctrine) third-party servers
Cost free, MIT per session / per month
GDPR / privacy no data leaves your infrastructure data processor agreement needed
Integration Symfony bundle + one script tag external script
Dashboard inside your admin (React, Vue, Twig, Web Component) external website

Features

  • Session recording & replay — cursor, clicks (numbered), scroll, hover states, viewport resizes, typed values; timeline with seek, 1× to 8× speed, skip inactivity, event log, every page of the visit.
  • Heatmaps — mouse movement heatmap, click heatmap, scroll map (75 / 50 / 25 % of visitors), per device width (desktop / tablet / mobile).
  • Zero-code recorder — <script src="https://your-api/tracker/recorder.js">, 12 KB, no dependency, works with server-rendered pages and single page apps (React, Vue, Angular: history.pushState navigations are followed).
  • Pluggable dashboard — React component, Vue 3 component, <mouse-tracker-dashboard> Web Component (Shadow DOM) or Twig function; themable with CSS variables (follows shadcn/ui, Tailwind, Bootstrap themes), light & dark, English & French.
  • Read-only replay — the replayed page can read but never write (no POST, no GraphQL mutation, no form submit).
  • Privacy controls — sampling, opt-out, never records passwords / card fields, keyboard recording can be disabled, IP exclusion, page exclusion, kill switch.
  • Symfony 5.4 → 7.x, PHP 7.2.5 → 8.4, Doctrine ORM 2 & 3, MySQL / MariaDB / PostgreSQL / SQLite / SQL Server.

Requirements

Package Version
PHP >= 7.2.5
Symfony 5.4, 6.4, 7.x
Doctrine ORM 2.7+, 3.x

Installation

composer require benmacha/mousetracker

Register the bundle (if Symfony Flex did not):

// config/bundles.php
benmacha\mousetracker\TrackerBundle::class => ['all' => true],

Import the routes:

# config/routes/mouse_tracker.yaml
mouse_tracker:
    resource: '@TrackerBundle/Resources/config/routes.yaml'
    prefix: /tracker

The ingest routes (public) and the dashboard routes (to protect) can also be imported separately: @TrackerBundle/Resources/config/routes/ingest.yaml and @TrackerBundle/Resources/config/routes/dashboard.yaml.

Create the tables (tracker__client, tracker__page, tracker__data) and publish the assets:

php bin/console doctrine:migrations:diff && php bin/console doctrine:migrations:migrate
php bin/console assets:install public

The Doctrine mapping is XML and is detected automatically with auto_mapping: true. Otherwise:

doctrine:
    orm:
        mappings:
            MouseTracker:
                type: xml
                is_bundle: false
                dir: '%kernel.project_dir%/vendor/benmacha/mousetracker/src/Resources/config/doctrine'
                prefix: 'benmacha\mousetracker\Entity'

Record your site (zero code)

Any website or single page app — one tag, nothing to install in the front-end project:

<!-- before your application scripts, without async/defer -->
<script src="https://api.example.com/tracker/recorder.js" data-exclude="/login,/admin"></script>

The recorder finds its endpoint from its own URL. Attributes: data-exclude (paths never recorded), data-spa="false" (do not follow pushState navigations), data-endpoint, data-debug.

Symfony / Twig sites, without touching templates:

mouse_tracker:
    auto_inject: true                       # adds the tag before </body> of every HTML page
    auto_inject_exclude: ['^/admin', '^/_']

or explicitly in a template: {{ mouse_tracker_script() }}.

From code (npm), if you prefer:

import { startRecorder } from '@benmacha/mouse-tracker/recorder';
const recorder = startRecorder({ endpoint: 'https://api.example.com/tracker', excludePaths: ['/login'] });
// recorder.optOut() · optIn() · stop() · newPage()

When the site and the API are on different domains, allow the site origin (allowed_origins) or let your CORS bundle handle /tracker/*.

What is recorded: mouse positions (every delay ms), clicks, scroll positions, viewport resizes and — unless record_keyboard: false — input values on blur. Never passwords, hidden, file or card fields; never elements with class mt-ignore / noRecord or attribute data-mt-ignore.

Dashboard

Open /tracker/back/, or embed the dashboard in your own admin:

Twig

{{ mouse_tracker_dashboard({ height: '800px', theme: 'auto', locale: 'fr' }) }}

Plain JavaScript / any framework — Web Component served by the bundle, no npm package:

<script src="/bundles/tracker/build/dashboard.js"></script>
<mouse-tracker-dashboard api="/tracker" theme="auto" locale="fr" style="height: 85vh"></mouse-tracker-dashboard>
<script>
  // non-string options (authentication headers…) go through the `options` property
  document.querySelector('mouse-tracker-dashboard').options = {
    headers: () => ({ Authorization: 'Bearer ' + localStorage.getItem('token') }),
  };
</script>

React

import { MouseTrackerDashboard } from '@benmacha/mouse-tracker';

<MouseTrackerDashboard apiUrl="https://api.example.com/tracker" headers={() => ({ Authorization: `Bearer ${token}` })} theme="auto" />

Vue 3

<script setup>
import { MouseTrackerDashboard } from '@benmacha/mouse-tracker/vue';
</script>
<template>
  <MouseTrackerDashboard api-url="/tracker" theme="auto" height="85vh" />
</template>

The npm package is the bundle repository itself (npm install ../vendor/benmacha/mousetracker). More examples in examples/.

Option Description
apiUrl Base URL of the dashboard routes
siteUrl / resolvePageUrl(page) Where recorded pages are loaded (default: recorded host)
headers Extra headers, object or function (authentication)
credentials, fetch fetch options
theme light, dark, auto
locale en, fr
title, height, className, view, injectStyles, onViewChange

Replay is read-only

Replay and heatmaps load the real page in an <iframe name="mousetracker-replay">, logged in with the viewer's own session. In that frame the recorder does not record; it installs a guard that blocks every request that could change data: POST / PUT / PATCH / DELETE, GraphQL mutations, sendBeacon and form submissions (GraphQL queries and GET requests still work so the page renders). Clicks are not re-triggered unless the viewer ticks Replay clicks in the page, and even then nothing can be saved. Load the recorder synchronously before your application so the guard is active before the first request.

The tracked site must accept being framed by the dashboard (X-Frame-Options / frame-ancestors).

Configuration

# config/packages/mouse_tracker.yaml
mouse_tracker:
    enabled: true                  # false: script served, nothing recorded (kill switch)
    auto_inject: false             # add the recorder to every HTML page
    auto_inject_exclude: ['^/_(profiler|wdt|error)', '^/admin']
    exclude_paths: []              # pages never recorded (prefixes), sent to the recorder
    record_click: true
    record_move: true
    record_keyboard: true
    percentage_recorded: 100       # sample visitors
    disable_mobile: false
    ignore_ips: []                 # checked server side
    delay: 200                     # ms between two mouse positions
    max_moves: 800                 # per page view
    session_timeout: 40            # seconds of inactivity before a new session
    ignore_query_params: [utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, fbclid]
    entity_manager: ~              # entity manager holding the tracker__* tables
    allowed_origins: []            # CORS for recorders on other origins ("*" = any)
    max_payload: 2097152           # bytes per request
    access_role: ~                 # e.g. ROLE_ADMIN for the dashboard and its API
    site_url: ~                    # base URL of the tracked site in the replay frames
    title: 'Mouse Tracker'

Theming

Every colour is a CSS custom property; they cross the Shadow DOM:

mouse-tracker-dashboard, .my-admin {
  --mt-accent: #e11d48;
  /* follow a shadcn/ui (Tailwind) theme, light and dark */
  --mt-bg: hsl(var(--background));
  --mt-surface: hsl(var(--card));
  --mt-surface-2: hsl(var(--muted));
  --mt-border: hsl(var(--border));
  --mt-text: hsl(var(--foreground));
  --mt-muted: hsl(var(--muted-foreground));
}

Tokens: --mt-accent, --mt-accent-foreground, --mt-bg, --mt-surface, --mt-surface-2, --mt-border, --mt-text, --mt-muted, --mt-stage, --mt-danger, --mt-success, --mt-radius, --mt-font, --mt-font-mono.

HTTP API

Ingest (public):

Method Path
GET /recorder.js the recorder
GET /config { settings, ignored }
POST /createClient token, clientID?, url, domain, resolution, source, versionMobile → { clientID, clientPageID }
POST /addData clientPageID, token, movements?, clicks?, partial?, cachedRecords?

Dashboard (protected):

Method Path
GET /back/ (page), /api/settings, /api/stats?domain=
GET /api/sessions?domain=&q=&from=&to=&offset=&limit=
GET /api/pages?domain=&q=, /api/recordings/{pageViewId}
GET /api/heatmap?url=&domain=&type=movements|clicks&minWidth=&maxWidth=, /api/scrollmap?url=…
DELETE /api/sessions/{id}, /api/pageviews/{id}

Security & GDPR

  • Ingest routes are public by design; addData only accepts data for a page view whose session token matches.
  • Protect the dashboard: access_role and/or access_control on ^/tracker/(back|api). With a stateless API (JWT), put those routes behind your API firewall and pass the token with the headers option.
  • Ask for consent where required, disable record_keyboard when forms may contain personal data, use exclude_paths for sensitive pages and enabled: false to stop collecting instantly.

FAQ

Is it a free alternative to Hotjar, Mouseflow, Smartlook or Microsoft Clarity? Yes for heatmaps, scroll maps and session replay, self-hosted in your Symfony application (no surveys or funnels).

Does it work with React, Vue or Angular single page apps? Yes. The recorder follows history.pushState / popstate navigations and the dashboard ships as React, Vue and Web Components.

Do I need Node.js or npm? No. The built files are committed; composer require is enough. npm is only needed to modify the front-end.

Will replaying a session submit forms or delete data? No. See Replay is read-only.

Which databases are supported? Every database supported by Doctrine ORM (MySQL, MariaDB, PostgreSQL, SQLite, SQL Server…).

How much data is stored? Mouse positions are sampled (delay, max_moves); recordings are JSON chunks attached to page views. Delete sessions from the dashboard or the API.

Development

composer update && vendor/bin/phpunit       # PHP tests (kernel + SQLite)
npm install && npm test                      # Vitest
npm run typecheck && npm run build           # rebuild src/Resources/public/build (committed)

Upgrading from 2.x

  • Same tables and columns (an index is added on tracker__page(domain, date)).
  • Doctrine mapping moved from attributes to XML (src/Resources/config/doctrine), PHP sources to src/.
  • {{ mouse_tracker_service.build()|raw }} still works; prefer {{ mouse_tracker_script() }} or auto_inject.
  • The recorder sends the session token with every request.
  • The jQuery back-office and /back/getPages, /back/getClients, /back/getData are replaced by the React dashboard and /api/*.
  • IP filtering is done server side (no third-party IP service).

License

MIT © Ali Ben Macha — contributions welcome, see CONTRIBUTING.md.

About

Self-hosted heatmaps, scroll maps and session replay for Symfony 5.4–7.x — open source Hotjar / Clarity alternative. React, Vue & Web Component dashboard, zero-code recorder.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages