fjosue4/trace-ux

Go

6

174 commits

updated Oct 5, 2026

See the code

See what people are saying

SourceMessageScoreDate

Show HN: TraceUX – Self-hosted session replay in one Go binary

3

Oct 7, 2026

TraceUX: a self-hosted TypeScript tracker for session replay and user feedback (r/javascript)

I’m building TraceUX, a self-hosted UX toolkit for JavaScript and TypeScript applications. The browser tracker is available through NPM: npm i @trace-ux/tracker import { init } from '@trace-ux/tracker'; const traceux = init({ siteKey: 'YOUR_SITE_KEY', origin: 'https://your-trace-ux-host', }); It…

7

Sep 16, 2026

TraceUX: Self-Hosted, Privacy-First Session Replay

1

Sep 7, 2026

README

TraceUX

npm Live demo License

The complete UX bundle for products that want to understand, hear, and update their users.

Self-hosted session replay, in-product feedback, and announcements with per-release reactions and comments — in one small binary, on your server, with an experience you control.

Try TraceUX live: Open the Live Demo →

TraceUX closes the loop between what users do, what they say, and what you ship: watch a real visit, ask for feedback in context, publish an update, and see the reaction to that update. No separate replay vendor, survey tool, changelog service, or analytics warehouse required.

With Smartlook shutting down, TraceUX provides a self-hosted path for teams that need product insight without handing their data to another hosted analytics platform. It records how real visitors use your site (DOM event streams, not video), stores them on your own server, and brings replay, feedback, and announcements into one dashboard. One binary, one SQLite file, one Docker container.

Current bundle: session replay, in-app feedback, public announcements, per-announcement reads/reactions/comments, browser logs, backend performance percentiles, multi-user access, and a unified visitor widget.

Live landing page: trace-ux.builtbyfrank.dev

The complete UX bundle

TraceUX gives a small product team one practical loop instead of four disconnected tools:

CapabilityWhat it answers
Session replayWhat did the visitor actually do, and where did the journey break?
In-product feedbackWhat did the visitor think at that exact moment?
AnnouncementsWhat do we need to tell users about the product?
Per-announcement feedbackDid users read, like, or comment on this specific update?
Logs & performanceWhat browser and backend signals explain the experience?

All of these surfaces are organized by site in the dashboard. The tracker ships one unified Help & updates launcher: visitors can open What's new or Feedback without juggling separate widgets, and each response can be connected back to the session that produced it.

Why

With Smartlook shutting down, many teams are looking for a replacement that offers a familiar session-replay workflow while keeping data under their control. Existing self-hosted options are often heavy: full analytics suites that need 8 GB+ RAM, multi-service Docker stacks, or paid plugins. TraceUX is built for a tiny VPS: a single static binary (~20 MB, ~30 MB RAM) with SQLite and the dashboard embedded. Backups are copying one folder.

Screenshots

Sessions dashboard

image

Session replay

image

Logs view

image

Quickstart

Docker (recommended):

mkdir trace-ux && cd trace-ux
docker run -d --name trace-ux -p 127.0.0.1:8080:8080 -v "$PWD/data:/data" \
  -e TRACE_UX_PASSWORD=change-me ghcr.io/fjosue4/trace-ux:latest

Or with docker-compose (optional Caddy TLS included):

cd deploy && TRACE_UX_PASSWORD=change-me docker compose up -d

Single binary (any Linux/macOS, amd64/arm64):

TRACE_UX_PASSWORD=change-me TRACE_UX_SECURE_COOKIES=0 ./trace-ux # local HTTP development only

Then:

  1. Put the dashboard behind HTTPS (the Compose example binds the backend to loopback for this purpose), then open your HTTPS URL and sign in. Leave the username empty (or type admin) and use TRACE_UX_PASSWORD — that's the bootstrap admin account.
  2. Add site → create a site for your application's URL and copy its site key.
  3. Connect your application with the npm integration below (recommended). If npm is not available in your environment, the HTML/GTM compatibility option is documented afterward.

Use @trace-ux/tracker on npm for the complete tracker API, including the optional React provider and announcement callback. Install it in the application that serves the site:

npm install @trace-ux/tracker

The server origin is required because npm/React integrations do not have a script URL from which to infer it:

import { init } from '@trace-ux/tracker';

const traceux = init({
  siteKey: 'YOUR_SITE_KEY',
  origin: 'https://your-server.example.com',
  userId: currentUser?.id,
  widget: true, // opt into the dashboard-configured visitor widget
  onAnnouncement: (announcement) => {
    // Full title, summary, body, release label, link and metadata are available.
    console.log('New announcement:', announcement.title, announcement.body);
    console.log('Version metadata:', announcement.internal_headers?.current_version);
  },
});

traceux.identify({ userId: user.id }); // after login
traceux.track('checkout_completed');
traceux.setUserStatus('active');
traceux.warn('Checkout request failed', { orderId });

// Flush and stop recording when the host application owns the lifecycle.
traceux.stop();

The package entry has no import-time side effects. widget: true opts into the optional widget's dynamic import; the widget is fetched only when the server configuration enables at least one section. onAnnouncement runs when a newly published or edited published announcement reaches the widget, with the full announcement payload, including any internal_headers key/value metadata configured by the admin. Those headers are not rendered in the visitor widget, but they are delivered to the browser callback, so they must not contain secrets. It is not replayed for announcements already present during the widget's initial load. warn, error, info, and debug send application logs when Logs is enabled for the site and its severity threshold permits them. setUserStatus records a seekable user_status activity; it does not create a separate server-side user table.

The script and GTM snippets pass widget: true for you; npm and React do not. Set it to exactly true when the application should load the optional widget; other truthy values are ignored. Leave it out when the application only needs recording, identity, events, or logs so the widget chunk is not fetched.

React applications can use the optional provider and hook:

import { TraceUXProvider, useTraceUX } from '@trace-ux/tracker/react';

function Checkout() {
  const traceux = useTraceUX();
  return <button onClick={() => traceux.track('checkout_started')}>Checkout</button>;
}

export function App() {
  return (
    <TraceUXProvider siteKey="YOUR_SITE_KEY" origin="https://your-server.example.com" widget>
      <Checkout />
    </TraceUXProvider>
  );
}

Sessions start appearing within seconds. Enable Feedback and Announcements in the site's dashboard settings to use the unified Help & updates launcher.

Script tag and Google Tag Manager (compatibility option)

For static pages or environments where npm is not available, the server still serves the browser bundle directly:

<script async src="https://your-server/t.js" data-site="YOUR_SITE_KEY"></script>

For a manual install, paste the snippet into the <head> of every page. For Google Tag Manager, create a Custom HTML tag with the snippet from the GTM tab (it sets data-site via setAttribute, because GTM's script injection drops the attribute), trigger it on All Pages, and publish.

These paths support browser tracking and the server-configured visitor widget; the browser bundle also exposes its handle as window.TraceUX for global calls. They do not expose npm/React-specific features such as module imports, the React provider/hook, or the onAnnouncement callback. Use npm when the application needs the complete tracker API. The script and GTM adapters pass widget: true automatically; npm and React integrations must opt in explicitly.

If an npm/React integration and /t.js happen to be present on one page, the first initialized tracker owns the page and the other path reuses that handle rather than recording a second session.

Install on a VPS (one script)

On any Linux server with systemd, one line installs everything — the binary ships with the dashboard and tracker embedded, so there is nothing else to set up:

curl -fsSL https://raw.githubusercontent.com/fjosue4/trace-ux/main/install.sh | sudo bash

The script: downloads the prebuilt release for your architecture (checksum-verified), creates a locked-down system user, puts recordings in /var/lib/trace-ux, generates an admin password, installs a systemd service (auto-start on boot, auto-restart on crash), and adds a trace-ux control command. Re-running it upgrades in place and keeps data and users.

Then manage the server with:

CommandWhat it does
trace-ux --statusis it running?
trace-ux --start / --stop / --restartcontrol the service
trace-ux --logslast 100 log lines (journalctl -u trace-ux -f to follow)
trace-ux --password '<new>'set a new admin password (revokes old admin logins) — single-quote it, see below
trace-ux --updateupgrade to the latest release
trace-ux --uninstallremove it (recordings are kept)
trace-ux --runrun in the foreground for debugging

First login

The installer generates the admin password and writes it to the env file rather than printing it. Read it from there:

sudo grep '^TRACE_UX_PASSWORD=' /etc/trace-ux/trace-ux.env

Sign in as admin with that value, then change it:

sudo trace-ux --password 'your-new-password'

Single-quote the password. Passwords worth using contain characters the shell interprets before TraceUX ever sees them. ! triggers history expansion in an interactive bash session, so an unquoted Pa!ss either expands to something else or fails outright with event not found — and you end up locked out by a password you never chose. $, ` and \ are substituted too. Double quotes do not stop any of this; only single quotes do.

Two follow-ups worth knowing: the password is passed as a command argument, so it lands in your shell history and is briefly visible in ps — clear the history entry on a shared machine. And --password revokes every existing admin login, so other sessions are signed out immediately.

Building from a git clone instead of a release: sudo bash install.sh --build-from-source (needs Go ≥ 1.27 and Node ≥ 18). Releases are built automatically by .github/workflows/release.yml when a v* tag is pushed.

Releases and updates

TraceUX publishes versioned Linux releases for amd64 and arm64. Each release bundles the dashboard and tracker into the server binary and publishes the architecture archives with a checksums.txt file. Release installs do not need Go, Node, or Docker on the VPS.

To update an existing release install to the latest published version:

sudo trace-ux --update
trace-ux --version
sudo trace-ux --status

The update downloads the matching release for the server architecture, verifies it against the published checksum, replaces the binary, and restarts the systemd service. Your SQLite data, sites, users, and admin password stay in place. Check /api/health after the restart if the instance is behind a proxy.

To install a specific release instead of the latest one, pin the version when running the installer:

curl -fsSL https://raw.githubusercontent.com/fjosue4/trace-ux/main/install.sh \
  | sudo env TRACE_UX_VERSION=0.3.0 bash

Installs created with --build-from-source do not use --update; pull the desired source revision and run sudo bash install.sh --build-from-source again.

Minimum requirements

TraceUX is deliberately built for the smallest VPS you can rent:

ResourceMinimumComfortable
CPU1 vCore2 vCores
RAM512 MB (server idles at ~30–50 MB)1 GB
Disk1 GB10 GB+ (recordings ≈ 60 KB per typical session; the SQLite file is the whole store)
OSLinux amd64/arm64 with systemd — Debian 11+, Ubuntu 20.04+, Rocky/Alma 9+, Fedoraany of those
Networkone open TCP port (8080 by default)+ Caddy/nginx for HTTPS

No external database, no queue, no other services — SQLite lives in /var/lib/trace-ux and backups are copying that folder. Docker is the alternative route (deploy/docker-compose.yml) if you prefer containers; the container runs the same binary. In practice: a $4–6/mo VPS handles a handful of sites with thousands of monthly sessions.

Configuration

Env varDefaultMeaning
TRACE_UX_PASSWORD—Required to create the first admin account
TRACE_UX_RESET_ADMIN—1 = re-point admin at TRACE_UX_PASSWORD on boot
TRACE_UX_DATA./dataData dir (SQLite db, users, auth secret)
TRACE_UX_ADDR:8080Listen address
TRACE_UX_SECURE_COOKIES1Set 0 only for local HTTP development
TRACE_UX_TRUSTED_PROXIES—Comma-separated proxy CIDRs allowed to supply XFF
TRACE_UX_RETENTION_DAYS90Auto-delete sessions older than this
TRACE_UX_MAX_GB_DISK— (no limit)Disk budget in GB; over it, oldest recordings are deleted
TRACE_UX_MAX_EVENT_MB4Ceiling on one rrweb event; raise it if replays are blank
TRACE_UX_CHECKOUT_INTERVAL_MS30000How often rrweb re-snapshots the DOM — the main storage lever
TRACE_UX_INLINE_STYLESHEET1Copy page CSS into every snapshot. 0 shrinks snapshots but replays load CSS cross-origin
TRACE_UX_SLIM_DOM1Drop comments, <script>, favicons and social meta from snapshots
TRACE_UX_SPACE_FLOOR_DAYS3The budget never deletes recordings newer than this
TRACE_UX_DEMO_REPLAY0Enable short-lived public demo replay links
TRACE_UX_DEMO_REPLAY_TTL900Demo replay lifetime in seconds (60–3600)
TRACE_UX_DEV_STATIC—Dev only: serve frontend builds from disk
TRACE_UX_PUBLIC_URL—Public http(s) origin used in Slack links
TRACE_UX_SLACK_SIGNING_SECRET—Optional fallback Slack app signing secret; the System health UI is preferred

Snapshot size and cadence

Two variables govern how much a recording costs. Both default to the values that were previously hardcoded, so an existing install behaves identically until you change one.

TRACE_UX_MAX_EVENT_MB caps a single rrweb event. The FullSnapshot — the whole serialized DOM with stylesheets inlined — is by far the largest, and a heavy app can exceed the 4 MB default. When it does, that snapshot is refused while the incremental events around it are accepted, and the replay scrubs normally while rendering nothing at all. The 400 names the actual size, so the number to set comes straight out of the error:

{"error":"event is too large: 5011782 bytes, limit 4194304 (raise TRACE_UX_MAX_EVENT_MB)"}

The request ceiling is derived from this, not configured separately: a batch carries a snapshot plus the incrementals buffered with it, so raising the event cap raises the body limit with it.

TRACE_UX_CHECKOUT_INTERVAL_MS is how often rrweb re-snapshots the page, and it is the dominant term in storage — at the 30s default a nine-hour session stores roughly 1,080 complete copies of the DOM, stylesheets and all. Raising it to 120s or 300s cuts disk close to linearly. The cost is seek latency: jumping to an arbitrary point in a replay may have to apply more diffs. Playback from the start is unaffected. Accepted range is 5,000–3,600,000 ms.

If your CSS bundle is large, note that it is inlined into every snapshot — shrinking the bundle and raising the interval multiply together.

TRACE_UX_INLINE_STYLESHEET controls that inlining. It defaults to on, matching rrweb. Turning it off makes snapshots dramatically smaller, but the replay must then load your stylesheets from the recorded origin, which the replay page's own style-src 'self' CSP blocks — so replays come back unstyled. The server stores each distinct stylesheet once regardless, so leaving this on is usually the right trade.

TRACE_UX_SLIM_DOM drops content a replay never needs: comments, <script> tags (they do not execute during playback), favicons, and social, robots and verification meta tags. On by default, worth a fraction of a percent, and harmless.

Disk budget

TRACE_UX_RETENTION_DAYS bounds recordings by age. TRACE_UX_MAX_GB_DISK bounds them by size, as a backstop for when traffic outruns the retention window: once the data directory exceeds the budget, the oldest sessions are deleted until it is back under, checked every 10 minutes.

It is unset by default, and unset means no limit — so upgrading changes nothing until you opt in. 0 means no limit too, so the variable can sit in an env file switched off rather than being commented out. A value that is not a plain number (25GB, say) is refused with a warning at startup rather than being read as "no limit", because an operator who wrote it believes the disk is capped.

TRACE_UX_SPACE_FLOOR_DAYS is a floor the budget cannot cross. If the store is still over budget with nothing older than the floor, it stops and logs that the budget is too small for the traffic, rather than deleting recordings the day they arrive.

The budget needs auto_vacuum=incremental, which SQLite only accepts on a database with no tables yet. Recordings are BLOBs inside trace_ux.db, so deleting them returns pages to SQLite's freelist but does not shrink the file -- without incremental auto-vacuum a sweep would destroy recordings and hand the filesystem nothing back. Databases created by current versions get it automatically. On one created earlier, the sweep refuses to delete anything and logs why; run a full VACUUM to convert it.

Each site opens to a tabbed hub: Overview keeps the latest recordings, logs, feedback, and summary counts together; Site manages the registered URL and installation snippet; Services manages the shared logs and performance connections; Recordings, Feedback, Announcements, Widget, and Logs keep each part of the bundle easy to configure without one long scrolling form.

Users & access

The dashboard supports real accounts; the admin (the TRACE_UX_PASSWORD account) manages them alone, under Settings → Team (hidden from viewers):

  • Roles — admin can manage users and sites; viewer can browse sites, sessions and replays. Both can change their own password under Settings.
  • Creating users — pick a username and a password (min 8 characters); the user signs in with it directly. No email, no invites.
  • Revocation is immediate — resetting a password, changing a role, or deleting a user signs that person out everywhere. Changing your own password keeps your current tab signed in.
  • Passwords are stored as salted PBKDF2-SHA256 hashes (210k iterations); login cookies are random tokens stored only as hashes, so a leaked DB can't be replayed.
  • Locked out? Restart with TRACE_UX_RESET_ADMIN=1 to re-point the admin account at the current TRACE_UX_PASSWORD (all previous admin logins are revoked), then remove the flag.

TRACE_UX_PASSWORD only seeds the bootstrap admin the first time. After that, passwords live in the database and changing the env var has no effect.

Tracking visitors & events

The npm handle takes identity at init, and the application can attach or change it later — for example when a visitor logs in mid-recording:

import { init } from '@trace-ux/tracker';

const traceux = init({
  siteKey: 'KEY',
  origin: 'https://your-server',
  userId: 'u-42',
  clientId: 'acme',
  remoteId: 'remote-7',
});

// any time during the session (latest non-empty value wins):
traceux.identify({ userId: 'u-42', clientId: 'acme', remoteId: 'remote-7' });

Sessions are filterable by any of the three ids with the Visitor filter in the dashboard, and ids show on the replay page.

Identifying a visitor who logs in mid-session

Recording starts the moment the tracker initializes, before anyone has logged in. Calling identify() later attaches the visitor to the whole session, including everything recorded before the call — you do not lose the anonymous part of the journey that led up to the login.

That is not a backfill pass; identity is stored on the session row rather than on individual events, so there is only ever one value to set:

const traceux = init({ siteKey: 'KEY', origin: 'https://your-server' });
// ...visitor browses anonymously; this is all being recorded...
traceux.identify({ userId: user.id, clientId: user.accountId }); // after login

init() returns immediately and queues calls made before the tracker has finished starting, so there is nothing to await — calling identify() on the next line is safe. If the visitor is already known at page load, pass userId / clientId / remoteId straight to init() instead.

In React, TraceUXProvider does this for you: identity props that change after login are forwarded through identify() without starting a second recorder.

import { TraceUXProvider, useTraceUX } from '@trace-ux/tracker/react';

Logging out does not un-identify the session. Only non-empty values are applied, so identify({ userId: '' }) is a no-op rather than a reset. On a shared device this matters: if a second person logs in on the same tab, their id overwrites the session's, and the first person's activity is re-attributed to them. End the session explicitly instead:

traceux.stop();
sessionStorage.removeItem('trace_ux_sid'); // next init() starts a fresh session

Mark any element to appear as seekable activity in the replay sidebar:

<button trace-ux-track-id="checkout-click">Buy now</button>

or programmatically: traceux.track('checkout-click'). Clicking an activity row jumps the recording to that exact moment.

Ask for a Slack notification alongside the event (see the site's Integrations tab in the dashboard) by adding notify: true:

traceux.track('checkout_error', 'checkout-button', {
  notify: true,
  details: { pathname: location.pathname, errorInfo: 'Payment failed' },
});

details is stored with the custom event and included in the Slack message. traceux.info(event, details) is a separate browser log and does not attach details to the custom event. The wrapper should pass the details through the third argument:

tracker.track(event, trackId, { ...options, details });
<button trace-ux-track-id="checkout-button" trace-ux-track-notify="true">Buy now</button>

This is independent of the browser-log matcher: it fires whenever the call or click sets notify, with no pattern to configure. An admin still has to enable Custom events and configure its webhook from that site's Integrations tab for anything to be sent. Server CPU, RAM, and disk alerts are configured under System health.

Slack buttons also send an interaction callback even when they open a URL. Set the Slack app's Interactivity & Shortcuts → Request URL to:

https://YOUR_TRACEUX_HOST/api/integrations/slack/interactions

Save the Slack app's Signing Secret under System health → Slack system alerts, below the Request URL. It is encrypted at rest and the dashboard shows only its last four characters. TRACE_UX_SLACK_SIGNING_SECRET remains an optional fallback for older deployments; a secret saved in the UI takes precedence. The endpoint verifies Slack's signature and immediately returns HTTP 200; the button's URL continues opening the linked ticket or replay. Do not use the incoming webhook URL as the Request URL.

Masking: all form inputs are masked by default; any element carrying trace-ux-mask — as a class or as a bare attribute — has its text masked, and trace-ux-block (class) removes the element from the recording entirely.

Logs

Logs can be enabled per site from the site's Logs configuration. Select any combination of the four levels — debug, info, warnings, and errors — and only those exact levels are stored (for example, info and errors without warnings). Browser rows stay linked to the visitor's recording session. The dashboard's unified Logs page opens in a live view of the last 15 minutes, refreshes every 5 seconds, and shows up to 1,000 browser and service rows together. It supports site, service, environment, severity, preset time-window, and custom time filters. Search can target the message, structured extra data, or both.

Add backend or external producers from a site's Services tab. Each service receives one generated tux_svc_… API key for both logs and performance, shown in full only when it is created or rotated. Keys are stored as hashes and can be revoked independently. A service inherits the site's selected log severity levels by default or can override them. Environment belongs to each event, so the same service key can report from staging and production:

curl -X POST "https://your-server/api/logs/ingest" \
  -H "Content-Type: application/json" \
  -H "X-TraceUX-Service-Key: YOUR_SERVICE_KEY" \
  -d '{"logs":[{
    "severity":"error",
    "message":"Payment request failed",
    "environment":"production",
    "extra":{"request_id":"req_123","status_code":502}
  }]}'

Authorization: Bearer YOUR_SERVICE_KEY is also accepted. The older X-TraceUX-Log-Key header remains available for compatibility. Messages may be up to 4 MiB and extra may be any valid JSON value up to 4 MiB; both are stored complete and extra is displayed as expandable formatted JSON. A request may contain up to 1,000 log entries within the 12 MB request limit.

Log storage has two independent per-site caps: 15 days by default and 1,000,000 rows by default. The oldest rows are removed during the regular retention sweep; either cap can be changed or disabled from the same Logs configuration. Browser logs are also removed automatically when their related recording is removed, while service logs remain independent of recordings. Browser logs are not yet shown inside the replay timeline.

Because browser console output can contain sensitive values, enable this only when the site's logging policy allows it. TraceUX stores a bounded, formatted message rather than raw console argument objects.

Performance

The Performance page shows backend endpoint latency as p50, p95 and p99, with request counts, error rate, a latency trend, and filters for site, environment, service and version. Metrics are stored as minute-level histograms so the dashboard stays small while retaining useful slow-tail measurements.

To connect a backend, open a site and choose Services, then create or reuse the service that owns the observations. Store its service key as a backend secret and use the same key for logs and performance. The service key identifies the site and service automatically; it is not used by the browser tracking snippet and does not instrument backend code by itself.

curl -X POST "https://your-server/api/performance/ingest" \
  -H "Content-Type: application/json" \
  -H "X-TraceUX-Service-Key: YOUR_SERVICE_KEY" \
  -d '{"observations":[
    {"environment":"production","version":"1.4.0","endpoint":"GET /orders","duration_ms":184,"status_code":200}
  ]}'

The backend integration is intentionally server-to-server. TraceUX stores only a hash of the service key, shows the full value only at creation or rotation, and takes the performance service dimension from the authenticated service rather than trusting the payload. Authorization: Bearer YOUR_SERVICE_KEY is also accepted. The former site-addressed performance endpoint and its existing keys remain valid for backward compatibility, but new integrations should use Services. New sites no longer receive a site-level performance key, and none can be created; any that remain are listed under Services → Legacy performance keys, where administrators can remove them once those backends have moved to a service key.

Feedback & surveys

Collect feedback from visitors right on your tracked sites — each response is linked to the session recording, so you can watch the moment behind the score. Feedback and announcements share one visitor-facing launcher, so listening and communicating feel like one product surface instead of two unrelated widgets.

Everything is configured per site from the dashboard (open a site from the Sites list): toggle recordings on/off, enable the widget, pick its corner, and manage Feedback campaigns. Every site starts with an editable, disable-only default campaign for basic feedback. Additional campaigns support Good/Bad, 1–5 stars, or a 1–10 scale, optional written feedback, and per-occurrence, 24-hour, or 7-day recurrence. The tracker picks the configuration up automatically from the server; the snippet carries no settings.

Collect programmatically with npm (recommended):

import { init } from '@trace-ux/tracker';

const traceux = init({
  siteKey: 'YOUR_SITE_KEY',
  origin: 'https://your-server.example.com',
  widget: true,
});

traceux.feedback({ rating: 5, comment: 'Loved it', surveyId: 'checkout' });

const result = await traceux.feedback.expand('phone-call-quality', {
  occurrenceId: call.id, // optional idempotency key
});

Normal eligibility decisions are returned as typed outcomes. If the widget is already open, the request is skipped without starting a cooldown. The site's widget master switch blocks both the launcher and explicit campaign calls.

Responses land in the dashboard's Feedback page (per-survey summaries, comments, device info, one-click jump into the replay). Each site's hub page shows the latest 5 recordings, the latest 5 feedback responses, and the overall positive-feedback percentage. Admins can delete individual responses (e.g. spam).

Turning recordings off for a site stops the capture of visitor event streams server-side — the feedback widget and lightweight session metadata keep working.

Announcements and per-release feedback

Publish product communication without sending visitors to a separate changelog:

  • Draft, preview, publish, and archive announcements from the dashboard.
  • Write announcement details in Markdown with headings, emphasis, lists, quotes, links, images, code, and tables; raw HTML stays disabled.
  • Add a release label, short summary, detailed body, and an optional safe http(s) link.
  • Deliver published announcements through the site's What's new tab.
  • Show an unread count and a lightweight in-page preview when a new update arrives; the widget polls gently and backs off when rate limited.
  • Let visitors mark an announcement as read, react with a like, and leave a comment.
  • Keep reads, reactions, and comments attached to each announcement, so release feedback is not mixed into a general survey stream.
  • See per-announcement read, reaction, and comment counts from the authenticated dashboard's announcement cards.

The announcements section is enabled per site and shares the same configurable launcher as Feedback. Admins can set the position, light/dark theme, accent, radius, width, label, and custom launcher icon from Widget. If a site only uses one section, the launcher opens directly into that section; when both are enabled, visitors get a two-tab What's new / Feedback panel.

This gives TraceUX a simple product-feedback loop:

visitor journey → session replay → contextual feedback → shipped announcement
                                      ↑                         ↓
                                      └──── per-update reaction/comment ────┘

Session behavior

  • 2-hour cap — a single session never records more than 2 hours of active time. The tracker splits a marathon visit into a fresh session at the cap (shipping the final events and duration of the outgoing session), and the server independently caps the stored duration, so buggy or hostile clients can't inflate it. Time with the tab hidden doesn't count; 30 minutes of inactivity or a hidden tab also ends a session.
  • Country without browser geolocation — when TraceUX runs behind a trusted reverse proxy, it stores only the normalized country code from common proxy geo headers (CF-IPCountry, CloudFront, or X-Country-Code variants). Sessions without that header show the country as unknown.
  • Filter by any visited path — the URL filter on the sessions list matches the entry URL, the exit URL, and every page the visitor navigated through, so searching pricing finds sessions that merely passed by the pricing page.

Privacy model

  • All form inputs are masked by default in recordings.
  • Add class="trace-ux-block" to remove an element from recording entirely; trace-ux-mask masks its text.
  • Visitors with Do Not Track enabled, or localStorage.trace_ux_optout = '1', are never recorded.
  • Raw IPs are never stored — only a salted, truncated hash for dedupe.
  • Everything lives on your server. Nothing leaves it.

Architecture

┌──────────────┐   batches (gzip, sendBeacon/fetch)   ┌─────────────────────┐
│  tracker.js  │ ───────────────────────────────────▶ │  Go server :8080    │
│ (~24 KB, on  │   hello / events / page / ping       │  ingest → gzip blob │
│  your site)  │                                      │  → SQLite (WAL)     │
└──────────────┘                                      │  + embedded SPA     │
                                                      └──────────┬──────────┘
                                    rrweb-player reconstructs   │
                                    the page as a "fake video"  ▼
                                                 dashboard ◀── SQLite
  • tracker/ — TypeScript SDK wrapping @rrweb/record. Records DOM mutations as compact event streams; batches and ships them compressed (2 KB threshold, CompressionStream), surviving page navigations via sessionStorage (session id, page index, active time all persist).
  • server/ — Go + modernc.org/sqlite (pure Go, no CGO). Events are stored as gzipped blobs per chunk (not one row per event), keeping SQLite fast and the file small. Retention job sweeps expired sessions every 6 h.
  • dashboard/ — Vite + React + rrweb-player. Chunks stream back decompressed in pages as you watch; site hubs also configure feedback, announcements, widget styles, logs, and service keys for backend telemetry.
  • demo/ — a pretend customer site with the snippet installed, for testing replay, feedback, multi-page navigation, and the unified announcements widget.

Capacity design point: 30 concurrent sessions ≈ 6–10 tiny requests/sec (a few % of one core). The same design comfortably reaches thousands of concurrent sessions on a 2–4 GB VPS before needing a queue/ClickHouse — at which point that's the next milestone, for any language.

Development

./traceux --start # Go API on :8090 + Vite dashboard on :5173 with HMR
make dev          # same development launcher
make serve-demo   # demo site on :8081 (edit demo/*.html, set your site key)
make test         # Go tests (ingest flow, auth, UA parsing, multi-page sessions)
make build        # production binary: server/trace-ux

Frontend changes update in the browser as you save through Vite HMR. The launcher builds the tracker once at startup; after tracker changes, run cd tracker && npm run build and reload the tracked page. Production remains a single binary with the dashboard and tracker embedded via make build. Startup prints the configured TRACE_UX_PASSWORD; an existing database may still have a different password if the admin changed it.

Building the release artifact

docker build -f deploy/Dockerfile -t trace-ux .

Multi-stage: frontend bundles built with esbuild/Vite, then a CGO_ENABLED=0 Go build embeds them (go:embed) into one distroless image.

FAQ

Is this a real video? No — and that's the point. We store DOM/style event streams and reconstruct the page in the player. It's tiny (~KBs per screen vs MBs per video second), searchable, and never captures actual pixels.

What about SPAs? Route changes via the History API are detected and become page entries in the session timeline.

How can I add a demo “watch my visit” button? The optional demo capability is disabled by default. Enable it only on an isolated demo deployment, then call traceux.claimReplay() from the npm handle in a button handler and redirect to the returned relative URL. The server stores only a keyed hash of the random capability, scopes it to the current site/session, rate-limits claims, and expires it automatically. This is intended for a private demo overlay, not as a replacement for dashboard authentication.

<button id="watch-my-visit" type="button">Watch my visit</button>
import { init } from '@trace-ux/tracker';

const traceux = init({
  siteKey: 'YOUR_SITE_KEY',
  origin: 'https://your-server.example.com',
});

const button = document.querySelector<HTMLButtonElement>('#watch-my-visit');
button?.addEventListener('click', async () => {
  if (!button) return;
  button.disabled = true;
  try {
    const link = await traceux.claimReplay();
    // The server currently returns a same-origin relative path.
    window.location.assign(new URL(link.url, window.location.origin).href);
  } catch {
    button.disabled = false;
    button.textContent = 'Replay unavailable';
  }
});

Can I see who the user was? By design, no. Sessions are anonymous; no cookies, no cross-site identity, no raw IPs.

License

TraceUX is available under the TraceUX Community Source License v1.0. You may use, clone, modify, collaborate on, and host it without a license fee. Shared or hosted versions must retain TraceUX branding and notices, and modified versions must provide their corresponding source. White-labeling, rebranding, or selling or distributing TraceUX as a separate product under another brand is not permitted.

This is a source-available license, not an OSI-approved open-source license.

fjosue4/trace-ux

Go

6

174 commits

updated Oct 5, 2026

See the code

See what people are saying

SourceMessageScoreDate

Show HN: TraceUX – Self-hosted session replay in one Go binary

3

Oct 7, 2026

TraceUX: a self-hosted TypeScript tracker for session replay and user feedback (r/javascript)

I’m building TraceUX, a self-hosted UX toolkit for JavaScript and TypeScript applications. The browser tracker is available through NPM: npm i @trace-ux/tracker import { init } from '@trace-ux/tracker'; const traceux = init({ siteKey: 'YOUR_SITE_KEY', origin: 'https://your-trace-ux-host', }); It…

7

Sep 16, 2026

TraceUX: Self-Hosted, Privacy-First Session Replay

1

Sep 7, 2026

README

TraceUX

npm Live demo License

The complete UX bundle for products that want to understand, hear, and update their users.

Self-hosted session replay, in-product feedback, and announcements with per-release reactions and comments — in one small binary, on your server, with an experience you control.

Try TraceUX live: Open the Live Demo →

TraceUX closes the loop between what users do, what they say, and what you ship: watch a real visit, ask for feedback in context, publish an update, and see the reaction to that update. No separate replay vendor, survey tool, changelog service, or analytics warehouse required.

With Smartlook shutting down, TraceUX provides a self-hosted path for teams that need product insight without handing their data to another hosted analytics platform. It records how real visitors use your site (DOM event streams, not video), stores them on your own server, and brings replay, feedback, and announcements into one dashboard. One binary, one SQLite file, one Docker container.

Current bundle: session replay, in-app feedback, public announcements, per-announcement reads/reactions/comments, browser logs, backend performance percentiles, multi-user access, and a unified visitor widget.

Live landing page: trace-ux.builtbyfrank.dev

The complete UX bundle

TraceUX gives a small product team one practical loop instead of four disconnected tools:

CapabilityWhat it answers
Session replayWhat did the visitor actually do, and where did the journey break?
In-product feedbackWhat did the visitor think at that exact moment?
AnnouncementsWhat do we need to tell users about the product?
Per-announcement feedbackDid users read, like, or comment on this specific update?
Logs & performanceWhat browser and backend signals explain the experience?

All of these surfaces are organized by site in the dashboard. The tracker ships one unified Help & updates launcher: visitors can open What's new or Feedback without juggling separate widgets, and each response can be connected back to the session that produced it.

Why

With Smartlook shutting down, many teams are looking for a replacement that offers a familiar session-replay workflow while keeping data under their control. Existing self-hosted options are often heavy: full analytics suites that need 8 GB+ RAM, multi-service Docker stacks, or paid plugins. TraceUX is built for a tiny VPS: a single static binary (~20 MB, ~30 MB RAM) with SQLite and the dashboard embedded. Backups are copying one folder.

Screenshots

Sessions dashboard

image

Session replay

image

Logs view

image

Quickstart

Docker (recommended):

mkdir trace-ux && cd trace-ux
docker run -d --name trace-ux -p 127.0.0.1:8080:8080 -v "$PWD/data:/data" \
  -e TRACE_UX_PASSWORD=change-me ghcr.io/fjosue4/trace-ux:latest

Or with docker-compose (optional Caddy TLS included):

cd deploy && TRACE_UX_PASSWORD=change-me docker compose up -d

Single binary (any Linux/macOS, amd64/arm64):

TRACE_UX_PASSWORD=change-me TRACE_UX_SECURE_COOKIES=0 ./trace-ux # local HTTP development only

Then:

  1. Put the dashboard behind HTTPS (the Compose example binds the backend to loopback for this purpose), then open your HTTPS URL and sign in. Leave the username empty (or type admin) and use TRACE_UX_PASSWORD — that's the bootstrap admin account.
  2. Add site → create a site for your application's URL and copy its site key.
  3. Connect your application with the npm integration below (recommended). If npm is not available in your environment, the HTML/GTM compatibility option is documented afterward.

Use @trace-ux/tracker on npm for the complete tracker API, including the optional React provider and announcement callback. Install it in the application that serves the site:

npm install @trace-ux/tracker

The server origin is required because npm/React integrations do not have a script URL from which to infer it:

import { init } from '@trace-ux/tracker';

const traceux = init({
  siteKey: 'YOUR_SITE_KEY',
  origin: 'https://your-server.example.com',
  userId: currentUser?.id,
  widget: true, // opt into the dashboard-configured visitor widget
  onAnnouncement: (announcement) => {
    // Full title, summary, body, release label, link and metadata are available.
    console.log('New announcement:', announcement.title, announcement.body);
    console.log('Version metadata:', announcement.internal_headers?.current_version);
  },
});

traceux.identify({ userId: user.id }); // after login
traceux.track('checkout_completed');
traceux.setUserStatus('active');
traceux.warn('Checkout request failed', { orderId });

// Flush and stop recording when the host application owns the lifecycle.
traceux.stop();

The package entry has no import-time side effects. widget: true opts into the optional widget's dynamic import; the widget is fetched only when the server configuration enables at least one section. onAnnouncement runs when a newly published or edited published announcement reaches the widget, with the full announcement payload, including any internal_headers key/value metadata configured by the admin. Those headers are not rendered in the visitor widget, but they are delivered to the browser callback, so they must not contain secrets. It is not replayed for announcements already present during the widget's initial load. warn, error, info, and debug send application logs when Logs is enabled for the site and its severity threshold permits them. setUserStatus records a seekable user_status activity; it does not create a separate server-side user table.

The script and GTM snippets pass widget: true for you; npm and React do not. Set it to exactly true when the application should load the optional widget; other truthy values are ignored. Leave it out when the application only needs recording, identity, events, or logs so the widget chunk is not fetched.

React applications can use the optional provider and hook:

import { TraceUXProvider, useTraceUX } from '@trace-ux/tracker/react';

function Checkout() {
  const traceux = useTraceUX();
  return <button onClick={() => traceux.track('checkout_started')}>Checkout</button>;
}

export function App() {
  return (
    <TraceUXProvider siteKey="YOUR_SITE_KEY" origin="https://your-server.example.com" widget>
      <Checkout />
    </TraceUXProvider>
  );
}

Sessions start appearing within seconds. Enable Feedback and Announcements in the site's dashboard settings to use the unified Help & updates launcher.

Script tag and Google Tag Manager (compatibility option)

For static pages or environments where npm is not available, the server still serves the browser bundle directly:

<script async src="https://your-server/t.js" data-site="YOUR_SITE_KEY"></script>

For a manual install, paste the snippet into the <head> of every page. For Google Tag Manager, create a Custom HTML tag with the snippet from the GTM tab (it sets data-site via setAttribute, because GTM's script injection drops the attribute), trigger it on All Pages, and publish.

These paths support browser tracking and the server-configured visitor widget; the browser bundle also exposes its handle as window.TraceUX for global calls. They do not expose npm/React-specific features such as module imports, the React provider/hook, or the onAnnouncement callback. Use npm when the application needs the complete tracker API. The script and GTM adapters pass widget: true automatically; npm and React integrations must opt in explicitly.

If an npm/React integration and /t.js happen to be present on one page, the first initialized tracker owns the page and the other path reuses that handle rather than recording a second session.

Install on a VPS (one script)

On any Linux server with systemd, one line installs everything — the binary ships with the dashboard and tracker embedded, so there is nothing else to set up:

curl -fsSL https://raw.githubusercontent.com/fjosue4/trace-ux/main/install.sh | sudo bash

The script: downloads the prebuilt release for your architecture (checksum-verified), creates a locked-down system user, puts recordings in /var/lib/trace-ux, generates an admin password, installs a systemd service (auto-start on boot, auto-restart on crash), and adds a trace-ux control command. Re-running it upgrades in place and keeps data and users.

Then manage the server with:

CommandWhat it does
trace-ux --statusis it running?
trace-ux --start / --stop / --restartcontrol the service
trace-ux --logslast 100 log lines (journalctl -u trace-ux -f to follow)
trace-ux --password '<new>'set a new admin password (revokes old admin logins) — single-quote it, see below
trace-ux --updateupgrade to the latest release
trace-ux --uninstallremove it (recordings are kept)
trace-ux --runrun in the foreground for debugging

First login

The installer generates the admin password and writes it to the env file rather than printing it. Read it from there:

sudo grep '^TRACE_UX_PASSWORD=' /etc/trace-ux/trace-ux.env

Sign in as admin with that value, then change it:

sudo trace-ux --password 'your-new-password'

Single-quote the password. Passwords worth using contain characters the shell interprets before TraceUX ever sees them. ! triggers history expansion in an interactive bash session, so an unquoted Pa!ss either expands to something else or fails outright with event not found — and you end up locked out by a password you never chose. $, ` and \ are substituted too. Double quotes do not stop any of this; only single quotes do.

Two follow-ups worth knowing: the password is passed as a command argument, so it lands in your shell history and is briefly visible in ps — clear the history entry on a shared machine. And --password revokes every existing admin login, so other sessions are signed out immediately.

Building from a git clone instead of a release: sudo bash install.sh --build-from-source (needs Go ≥ 1.27 and Node ≥ 18). Releases are built automatically by .github/workflows/release.yml when a v* tag is pushed.

Releases and updates

TraceUX publishes versioned Linux releases for amd64 and arm64. Each release bundles the dashboard and tracker into the server binary and publishes the architecture archives with a checksums.txt file. Release installs do not need Go, Node, or Docker on the VPS.

To update an existing release install to the latest published version:

sudo trace-ux --update
trace-ux --version
sudo trace-ux --status

The update downloads the matching release for the server architecture, verifies it against the published checksum, replaces the binary, and restarts the systemd service. Your SQLite data, sites, users, and admin password stay in place. Check /api/health after the restart if the instance is behind a proxy.

To install a specific release instead of the latest one, pin the version when running the installer:

curl -fsSL https://raw.githubusercontent.com/fjosue4/trace-ux/main/install.sh \
  | sudo env TRACE_UX_VERSION=0.3.0 bash

Installs created with --build-from-source do not use --update; pull the desired source revision and run sudo bash install.sh --build-from-source again.

Minimum requirements

TraceUX is deliberately built for the smallest VPS you can rent:

ResourceMinimumComfortable
CPU1 vCore2 vCores
RAM512 MB (server idles at ~30–50 MB)1 GB
Disk1 GB10 GB+ (recordings ≈ 60 KB per typical session; the SQLite file is the whole store)
OSLinux amd64/arm64 with systemd — Debian 11+, Ubuntu 20.04+, Rocky/Alma 9+, Fedoraany of those
Networkone open TCP port (8080 by default)+ Caddy/nginx for HTTPS

No external database, no queue, no other services — SQLite lives in /var/lib/trace-ux and backups are copying that folder. Docker is the alternative route (deploy/docker-compose.yml) if you prefer containers; the container runs the same binary. In practice: a $4–6/mo VPS handles a handful of sites with thousands of monthly sessions.

Configuration

Env varDefaultMeaning
TRACE_UX_PASSWORD—Required to create the first admin account
TRACE_UX_RESET_ADMIN—1 = re-point admin at TRACE_UX_PASSWORD on boot
TRACE_UX_DATA./dataData dir (SQLite db, users, auth secret)
TRACE_UX_ADDR:8080Listen address
TRACE_UX_SECURE_COOKIES1Set 0 only for local HTTP development
TRACE_UX_TRUSTED_PROXIES—Comma-separated proxy CIDRs allowed to supply XFF
TRACE_UX_RETENTION_DAYS90Auto-delete sessions older than this
TRACE_UX_MAX_GB_DISK— (no limit)Disk budget in GB; over it, oldest recordings are deleted
TRACE_UX_MAX_EVENT_MB4Ceiling on one rrweb event; raise it if replays are blank
TRACE_UX_CHECKOUT_INTERVAL_MS30000How often rrweb re-snapshots the DOM — the main storage lever
TRACE_UX_INLINE_STYLESHEET1Copy page CSS into every snapshot. 0 shrinks snapshots but replays load CSS cross-origin
TRACE_UX_SLIM_DOM1Drop comments, <script>, favicons and social meta from snapshots
TRACE_UX_SPACE_FLOOR_DAYS3The budget never deletes recordings newer than this
TRACE_UX_DEMO_REPLAY0Enable short-lived public demo replay links
TRACE_UX_DEMO_REPLAY_TTL900Demo replay lifetime in seconds (60–3600)
TRACE_UX_DEV_STATIC—Dev only: serve frontend builds from disk
TRACE_UX_PUBLIC_URL—Public http(s) origin used in Slack links
TRACE_UX_SLACK_SIGNING_SECRET—Optional fallback Slack app signing secret; the System health UI is preferred

Snapshot size and cadence

Two variables govern how much a recording costs. Both default to the values that were previously hardcoded, so an existing install behaves identically until you change one.

TRACE_UX_MAX_EVENT_MB caps a single rrweb event. The FullSnapshot — the whole serialized DOM with stylesheets inlined — is by far the largest, and a heavy app can exceed the 4 MB default. When it does, that snapshot is refused while the incremental events around it are accepted, and the replay scrubs normally while rendering nothing at all. The 400 names the actual size, so the number to set comes straight out of the error:

{"error":"event is too large: 5011782 bytes, limit 4194304 (raise TRACE_UX_MAX_EVENT_MB)"}

The request ceiling is derived from this, not configured separately: a batch carries a snapshot plus the incrementals buffered with it, so raising the event cap raises the body limit with it.

TRACE_UX_CHECKOUT_INTERVAL_MS is how often rrweb re-snapshots the page, and it is the dominant term in storage — at the 30s default a nine-hour session stores roughly 1,080 complete copies of the DOM, stylesheets and all. Raising it to 120s or 300s cuts disk close to linearly. The cost is seek latency: jumping to an arbitrary point in a replay may have to apply more diffs. Playback from the start is unaffected. Accepted range is 5,000–3,600,000 ms.

If your CSS bundle is large, note that it is inlined into every snapshot — shrinking the bundle and raising the interval multiply together.

TRACE_UX_INLINE_STYLESHEET controls that inlining. It defaults to on, matching rrweb. Turning it off makes snapshots dramatically smaller, but the replay must then load your stylesheets from the recorded origin, which the replay page's own style-src 'self' CSP blocks — so replays come back unstyled. The server stores each distinct stylesheet once regardless, so leaving this on is usually the right trade.

TRACE_UX_SLIM_DOM drops content a replay never needs: comments, <script> tags (they do not execute during playback), favicons, and social, robots and verification meta tags. On by default, worth a fraction of a percent, and harmless.

Disk budget

TRACE_UX_RETENTION_DAYS bounds recordings by age. TRACE_UX_MAX_GB_DISK bounds them by size, as a backstop for when traffic outruns the retention window: once the data directory exceeds the budget, the oldest sessions are deleted until it is back under, checked every 10 minutes.

It is unset by default, and unset means no limit — so upgrading changes nothing until you opt in. 0 means no limit too, so the variable can sit in an env file switched off rather than being commented out. A value that is not a plain number (25GB, say) is refused with a warning at startup rather than being read as "no limit", because an operator who wrote it believes the disk is capped.

TRACE_UX_SPACE_FLOOR_DAYS is a floor the budget cannot cross. If the store is still over budget with nothing older than the floor, it stops and logs that the budget is too small for the traffic, rather than deleting recordings the day they arrive.

The budget needs auto_vacuum=incremental, which SQLite only accepts on a database with no tables yet. Recordings are BLOBs inside trace_ux.db, so deleting them returns pages to SQLite's freelist but does not shrink the file -- without incremental auto-vacuum a sweep would destroy recordings and hand the filesystem nothing back. Databases created by current versions get it automatically. On one created earlier, the sweep refuses to delete anything and logs why; run a full VACUUM to convert it.

Each site opens to a tabbed hub: Overview keeps the latest recordings, logs, feedback, and summary counts together; Site manages the registered URL and installation snippet; Services manages the shared logs and performance connections; Recordings, Feedback, Announcements, Widget, and Logs keep each part of the bundle easy to configure without one long scrolling form.

Users & access

The dashboard supports real accounts; the admin (the TRACE_UX_PASSWORD account) manages them alone, under Settings → Team (hidden from viewers):

  • Roles — admin can manage users and sites; viewer can browse sites, sessions and replays. Both can change their own password under Settings.
  • Creating users — pick a username and a password (min 8 characters); the user signs in with it directly. No email, no invites.
  • Revocation is immediate — resetting a password, changing a role, or deleting a user signs that person out everywhere. Changing your own password keeps your current tab signed in.
  • Passwords are stored as salted PBKDF2-SHA256 hashes (210k iterations); login cookies are random tokens stored only as hashes, so a leaked DB can't be replayed.
  • Locked out? Restart with TRACE_UX_RESET_ADMIN=1 to re-point the admin account at the current TRACE_UX_PASSWORD (all previous admin logins are revoked), then remove the flag.

TRACE_UX_PASSWORD only seeds the bootstrap admin the first time. After that, passwords live in the database and changing the env var has no effect.

Tracking visitors & events

The npm handle takes identity at init, and the application can attach or change it later — for example when a visitor logs in mid-recording:

import { init } from '@trace-ux/tracker';

const traceux = init({
  siteKey: 'KEY',
  origin: 'https://your-server',
  userId: 'u-42',
  clientId: 'acme',
  remoteId: 'remote-7',
});

// any time during the session (latest non-empty value wins):
traceux.identify({ userId: 'u-42', clientId: 'acme', remoteId: 'remote-7' });

Sessions are filterable by any of the three ids with the Visitor filter in the dashboard, and ids show on the replay page.

Identifying a visitor who logs in mid-session

Recording starts the moment the tracker initializes, before anyone has logged in. Calling identify() later attaches the visitor to the whole session, including everything recorded before the call — you do not lose the anonymous part of the journey that led up to the login.

That is not a backfill pass; identity is stored on the session row rather than on individual events, so there is only ever one value to set:

const traceux = init({ siteKey: 'KEY', origin: 'https://your-server' });
// ...visitor browses anonymously; this is all being recorded...
traceux.identify({ userId: user.id, clientId: user.accountId }); // after login

init() returns immediately and queues calls made before the tracker has finished starting, so there is nothing to await — calling identify() on the next line is safe. If the visitor is already known at page load, pass userId / clientId / remoteId straight to init() instead.

In React, TraceUXProvider does this for you: identity props that change after login are forwarded through identify() without starting a second recorder.

import { TraceUXProvider, useTraceUX } from '@trace-ux/tracker/react';

Logging out does not un-identify the session. Only non-empty values are applied, so identify({ userId: '' }) is a no-op rather than a reset. On a shared device this matters: if a second person logs in on the same tab, their id overwrites the session's, and the first person's activity is re-attributed to them. End the session explicitly instead:

traceux.stop();
sessionStorage.removeItem('trace_ux_sid'); // next init() starts a fresh session

Mark any element to appear as seekable activity in the replay sidebar:

<button trace-ux-track-id="checkout-click">Buy now</button>

or programmatically: traceux.track('checkout-click'). Clicking an activity row jumps the recording to that exact moment.

Ask for a Slack notification alongside the event (see the site's Integrations tab in the dashboard) by adding notify: true:

traceux.track('checkout_error', 'checkout-button', {
  notify: true,
  details: { pathname: location.pathname, errorInfo: 'Payment failed' },
});

details is stored with the custom event and included in the Slack message. traceux.info(event, details) is a separate browser log and does not attach details to the custom event. The wrapper should pass the details through the third argument:

tracker.track(event, trackId, { ...options, details });
<button trace-ux-track-id="checkout-button" trace-ux-track-notify="true">Buy now</button>

This is independent of the browser-log matcher: it fires whenever the call or click sets notify, with no pattern to configure. An admin still has to enable Custom events and configure its webhook from that site's Integrations tab for anything to be sent. Server CPU, RAM, and disk alerts are configured under System health.

Slack buttons also send an interaction callback even when they open a URL. Set the Slack app's Interactivity & Shortcuts → Request URL to:

https://YOUR_TRACEUX_HOST/api/integrations/slack/interactions

Save the Slack app's Signing Secret under System health → Slack system alerts, below the Request URL. It is encrypted at rest and the dashboard shows only its last four characters. TRACE_UX_SLACK_SIGNING_SECRET remains an optional fallback for older deployments; a secret saved in the UI takes precedence. The endpoint verifies Slack's signature and immediately returns HTTP 200; the button's URL continues opening the linked ticket or replay. Do not use the incoming webhook URL as the Request URL.

Masking: all form inputs are masked by default; any element carrying trace-ux-mask — as a class or as a bare attribute — has its text masked, and trace-ux-block (class) removes the element from the recording entirely.

Logs

Logs can be enabled per site from the site's Logs configuration. Select any combination of the four levels — debug, info, warnings, and errors — and only those exact levels are stored (for example, info and errors without warnings). Browser rows stay linked to the visitor's recording session. The dashboard's unified Logs page opens in a live view of the last 15 minutes, refreshes every 5 seconds, and shows up to 1,000 browser and service rows together. It supports site, service, environment, severity, preset time-window, and custom time filters. Search can target the message, structured extra data, or both.

Add backend or external producers from a site's Services tab. Each service receives one generated tux_svc_… API key for both logs and performance, shown in full only when it is created or rotated. Keys are stored as hashes and can be revoked independently. A service inherits the site's selected log severity levels by default or can override them. Environment belongs to each event, so the same service key can report from staging and production:

curl -X POST "https://your-server/api/logs/ingest" \
  -H "Content-Type: application/json" \
  -H "X-TraceUX-Service-Key: YOUR_SERVICE_KEY" \
  -d '{"logs":[{
    "severity":"error",
    "message":"Payment request failed",
    "environment":"production",
    "extra":{"request_id":"req_123","status_code":502}
  }]}'

Authorization: Bearer YOUR_SERVICE_KEY is also accepted. The older X-TraceUX-Log-Key header remains available for compatibility. Messages may be up to 4 MiB and extra may be any valid JSON value up to 4 MiB; both are stored complete and extra is displayed as expandable formatted JSON. A request may contain up to 1,000 log entries within the 12 MB request limit.

Log storage has two independent per-site caps: 15 days by default and 1,000,000 rows by default. The oldest rows are removed during the regular retention sweep; either cap can be changed or disabled from the same Logs configuration. Browser logs are also removed automatically when their related recording is removed, while service logs remain independent of recordings. Browser logs are not yet shown inside the replay timeline.

Because browser console output can contain sensitive values, enable this only when the site's logging policy allows it. TraceUX stores a bounded, formatted message rather than raw console argument objects.

Performance

The Performance page shows backend endpoint latency as p50, p95 and p99, with request counts, error rate, a latency trend, and filters for site, environment, service and version. Metrics are stored as minute-level histograms so the dashboard stays small while retaining useful slow-tail measurements.

To connect a backend, open a site and choose Services, then create or reuse the service that owns the observations. Store its service key as a backend secret and use the same key for logs and performance. The service key identifies the site and service automatically; it is not used by the browser tracking snippet and does not instrument backend code by itself.

curl -X POST "https://your-server/api/performance/ingest" \
  -H "Content-Type: application/json" \
  -H "X-TraceUX-Service-Key: YOUR_SERVICE_KEY" \
  -d '{"observations":[
    {"environment":"production","version":"1.4.0","endpoint":"GET /orders","duration_ms":184,"status_code":200}
  ]}'

The backend integration is intentionally server-to-server. TraceUX stores only a hash of the service key, shows the full value only at creation or rotation, and takes the performance service dimension from the authenticated service rather than trusting the payload. Authorization: Bearer YOUR_SERVICE_KEY is also accepted. The former site-addressed performance endpoint and its existing keys remain valid for backward compatibility, but new integrations should use Services. New sites no longer receive a site-level performance key, and none can be created; any that remain are listed under Services → Legacy performance keys, where administrators can remove them once those backends have moved to a service key.

Feedback & surveys

Collect feedback from visitors right on your tracked sites — each response is linked to the session recording, so you can watch the moment behind the score. Feedback and announcements share one visitor-facing launcher, so listening and communicating feel like one product surface instead of two unrelated widgets.

Everything is configured per site from the dashboard (open a site from the Sites list): toggle recordings on/off, enable the widget, pick its corner, and manage Feedback campaigns. Every site starts with an editable, disable-only default campaign for basic feedback. Additional campaigns support Good/Bad, 1–5 stars, or a 1–10 scale, optional written feedback, and per-occurrence, 24-hour, or 7-day recurrence. The tracker picks the configuration up automatically from the server; the snippet carries no settings.

Collect programmatically with npm (recommended):

import { init } from '@trace-ux/tracker';

const traceux = init({
  siteKey: 'YOUR_SITE_KEY',
  origin: 'https://your-server.example.com',
  widget: true,
});

traceux.feedback({ rating: 5, comment: 'Loved it', surveyId: 'checkout' });

const result = await traceux.feedback.expand('phone-call-quality', {
  occurrenceId: call.id, // optional idempotency key
});

Normal eligibility decisions are returned as typed outcomes. If the widget is already open, the request is skipped without starting a cooldown. The site's widget master switch blocks both the launcher and explicit campaign calls.

Responses land in the dashboard's Feedback page (per-survey summaries, comments, device info, one-click jump into the replay). Each site's hub page shows the latest 5 recordings, the latest 5 feedback responses, and the overall positive-feedback percentage. Admins can delete individual responses (e.g. spam).

Turning recordings off for a site stops the capture of visitor event streams server-side — the feedback widget and lightweight session metadata keep working.

Announcements and per-release feedback

Publish product communication without sending visitors to a separate changelog:

  • Draft, preview, publish, and archive announcements from the dashboard.
  • Write announcement details in Markdown with headings, emphasis, lists, quotes, links, images, code, and tables; raw HTML stays disabled.
  • Add a release label, short summary, detailed body, and an optional safe http(s) link.
  • Deliver published announcements through the site's What's new tab.
  • Show an unread count and a lightweight in-page preview when a new update arrives; the widget polls gently and backs off when rate limited.
  • Let visitors mark an announcement as read, react with a like, and leave a comment.
  • Keep reads, reactions, and comments attached to each announcement, so release feedback is not mixed into a general survey stream.
  • See per-announcement read, reaction, and comment counts from the authenticated dashboard's announcement cards.

The announcements section is enabled per site and shares the same configurable launcher as Feedback. Admins can set the position, light/dark theme, accent, radius, width, label, and custom launcher icon from Widget. If a site only uses one section, the launcher opens directly into that section; when both are enabled, visitors get a two-tab What's new / Feedback panel.

This gives TraceUX a simple product-feedback loop:

visitor journey → session replay → contextual feedback → shipped announcement
                                      ↑                         ↓
                                      └──── per-update reaction/comment ────┘

Session behavior

  • 2-hour cap — a single session never records more than 2 hours of active time. The tracker splits a marathon visit into a fresh session at the cap (shipping the final events and duration of the outgoing session), and the server independently caps the stored duration, so buggy or hostile clients can't inflate it. Time with the tab hidden doesn't count; 30 minutes of inactivity or a hidden tab also ends a session.
  • Country without browser geolocation — when TraceUX runs behind a trusted reverse proxy, it stores only the normalized country code from common proxy geo headers (CF-IPCountry, CloudFront, or X-Country-Code variants). Sessions without that header show the country as unknown.
  • Filter by any visited path — the URL filter on the sessions list matches the entry URL, the exit URL, and every page the visitor navigated through, so searching pricing finds sessions that merely passed by the pricing page.

Privacy model

  • All form inputs are masked by default in recordings.
  • Add class="trace-ux-block" to remove an element from recording entirely; trace-ux-mask masks its text.
  • Visitors with Do Not Track enabled, or localStorage.trace_ux_optout = '1', are never recorded.
  • Raw IPs are never stored — only a salted, truncated hash for dedupe.
  • Everything lives on your server. Nothing leaves it.

Architecture

┌──────────────┐   batches (gzip, sendBeacon/fetch)   ┌─────────────────────┐
│  tracker.js  │ ───────────────────────────────────▶ │  Go server :8080    │
│ (~24 KB, on  │   hello / events / page / ping       │  ingest → gzip blob │
│  your site)  │                                      │  → SQLite (WAL)     │
└──────────────┘                                      │  + embedded SPA     │
                                                      └──────────┬──────────┘
                                    rrweb-player reconstructs   │
                                    the page as a "fake video"  ▼
                                                 dashboard ◀── SQLite
  • tracker/ — TypeScript SDK wrapping @rrweb/record. Records DOM mutations as compact event streams; batches and ships them compressed (2 KB threshold, CompressionStream), surviving page navigations via sessionStorage (session id, page index, active time all persist).
  • server/ — Go + modernc.org/sqlite (pure Go, no CGO). Events are stored as gzipped blobs per chunk (not one row per event), keeping SQLite fast and the file small. Retention job sweeps expired sessions every 6 h.
  • dashboard/ — Vite + React + rrweb-player. Chunks stream back decompressed in pages as you watch; site hubs also configure feedback, announcements, widget styles, logs, and service keys for backend telemetry.
  • demo/ — a pretend customer site with the snippet installed, for testing replay, feedback, multi-page navigation, and the unified announcements widget.

Capacity design point: 30 concurrent sessions ≈ 6–10 tiny requests/sec (a few % of one core). The same design comfortably reaches thousands of concurrent sessions on a 2–4 GB VPS before needing a queue/ClickHouse — at which point that's the next milestone, for any language.

Development

./traceux --start # Go API on :8090 + Vite dashboard on :5173 with HMR
make dev          # same development launcher
make serve-demo   # demo site on :8081 (edit demo/*.html, set your site key)
make test         # Go tests (ingest flow, auth, UA parsing, multi-page sessions)
make build        # production binary: server/trace-ux

Frontend changes update in the browser as you save through Vite HMR. The launcher builds the tracker once at startup; after tracker changes, run cd tracker && npm run build and reload the tracked page. Production remains a single binary with the dashboard and tracker embedded via make build. Startup prints the configured TRACE_UX_PASSWORD; an existing database may still have a different password if the admin changed it.

Building the release artifact

docker build -f deploy/Dockerfile -t trace-ux .

Multi-stage: frontend bundles built with esbuild/Vite, then a CGO_ENABLED=0 Go build embeds them (go:embed) into one distroless image.

FAQ

Is this a real video? No — and that's the point. We store DOM/style event streams and reconstruct the page in the player. It's tiny (~KBs per screen vs MBs per video second), searchable, and never captures actual pixels.

What about SPAs? Route changes via the History API are detected and become page entries in the session timeline.

How can I add a demo “watch my visit” button? The optional demo capability is disabled by default. Enable it only on an isolated demo deployment, then call traceux.claimReplay() from the npm handle in a button handler and redirect to the returned relative URL. The server stores only a keyed hash of the random capability, scopes it to the current site/session, rate-limits claims, and expires it automatically. This is intended for a private demo overlay, not as a replacement for dashboard authentication.

<button id="watch-my-visit" type="button">Watch my visit</button>
import { init } from '@trace-ux/tracker';

const traceux = init({
  siteKey: 'YOUR_SITE_KEY',
  origin: 'https://your-server.example.com',
});

const button = document.querySelector<HTMLButtonElement>('#watch-my-visit');
button?.addEventListener('click', async () => {
  if (!button) return;
  button.disabled = true;
  try {
    const link = await traceux.claimReplay();
    // The server currently returns a same-origin relative path.
    window.location.assign(new URL(link.url, window.location.origin).href);
  } catch {
    button.disabled = false;
    button.textContent = 'Replay unavailable';
  }
});

Can I see who the user was? By design, no. Sessions are anonymous; no cookies, no cross-site identity, no raw IPs.

License

TraceUX is available under the TraceUX Community Source License v1.0. You may use, clone, modify, collaborate on, and host it without a license fee. Shared or hosted versions must retain TraceUX branding and notices, and modified versions must provide their corresponding source. White-labeling, rebranding, or selling or distributing TraceUX as a separate product under another brand is not permitted.

This is a source-available license, not an OSI-approved open-source license.