luchy
v0.2.0
Published
Luchy tracking - browser script + server-side tracker
Maintainers
Readme
Luchy Tracker
A lightweight, privacy-focused analytics tracker for web applications. Built for CDN deployment with automatic compression and versioning.
Features
- 🚀 Lightweight: ~3KB minified, ~1.5KB compressed
- 📊 Privacy-focused: No cookies, no personal data collection
- 🔄 Auto-tracking: Pageviews, outbound links, hash routing
- 📦 CDN-ready: Optimized for global distribution
- 🗜️ Compressed: Gzip and Brotli compression included
- 🏷️ Versioned: Commit-based versioning for safe deployments
Quick Start
Installation
npm install luchyBasic Usage
<script
src="https://cdn.luchy.app/luchy.min.js"
data-api-key="your-api-key"
data-auto-pageviews
></script>Advanced Usage
<script
src="https://cdn.luchy.app/luchy.min.js"
data-api-key="your-api-key"
data-endpoint="https://api.luchy.app/ingest"
data-auto-pageviews
data-auto-outbound
data-hash-routing
data-track-localhost
></script>Data Attributes
| Attribute | Description | Required |
| ---------------------- | --------------------------------------- | -------- |
| data-api-key | Your Luchy API key | ✅ Yes |
| data-endpoint | Custom API endpoint | ❌ No |
| data-auto-pageviews | Enable automatic pageview tracking | ❌ No |
| data-auto-outbound | Enable automatic outbound link tracking | ❌ No |
| data-auto-events | Enable automatic data attribute event tracking | ❌ No |
| data-hash-routing | Enable hash routing support | ❌ No |
| data-track-localhost | Track localhost traffic | ❌ No |
Data Attribute Events
Track custom events without JavaScript by adding data-luchy-event to any HTML element. Clicks are detected automatically via event delegation.
<!-- Simple event -->
<button data-luchy-event="cta-click">Sign Up</button>
<!-- With a payload (data-luchy-payload-*) -->
<a data-luchy-event="post-click" data-luchy-payload-slug="hello-world" href="/blog/hello-world">
Read Post
</a>
<!-- Multiple keys, dashes convert to underscores -->
<!-- data-luchy-payload-plan-tier becomes { plan_tier: "enterprise" } -->
<button
data-luchy-event="purchase"
data-luchy-payload-plan-tier="enterprise"
data-luchy-payload-source="pricing-page"
>
Buy Now
</button>
data-luchy-prop-*is the original spelling and keeps working — it is written into pages we do not control, so it is never going away. New markup should usedata-luchy-payload-*, which matches the field name on the wire and in the dashboard.
When an element has data-luchy-event, it takes priority over outbound link tracking to avoid duplicate events. Disable with data-auto-events="false" on the script tag.
Manual Tracking
// Track a pageview
window.luchy.trackPageview();
// Track a custom event. The second argument is the event payload —
// it is sent as `payload` and shows up as the event's properties.
window.luchy.trackEvent('Button Click', {
button: 'signup',
page: 'home'
});
// Enable auto-tracking features
window.luchy.enableAutoPageviews();
window.luchy.enableAutoOutboundTracking();
window.luchy.enableHashRouting();Server-Side Tracking
The browser script can only see what happens in a page. A server sees what the application actually did — an order placed, a payment recorded, a webhook processed — and those are usually the events worth having.
luchy/server carries no browser globals, so it runs in Node, Bun,
Deno and Cloudflare Workers:
import { createServerTracker } from 'luchy/server';
const luchy = createServerTracker({
apiKey: process.env.LUCHY_API_KEY!,
// optional: point at a self-hosted dashboard
endpoint: 'https://dash.luchy.app/api/ingest',
onError: (error) => logger.warn('[luchy] event dropped', error)
});
await luchy.trackEvent({
name: 'order:placed',
pathname: '/checkout',
type: 'server',
payload: { plan: 'pro', amount: 29 }
});Nothing here ever rejects — analytics must not break the request it is
describing. Pass onError if you want failures in your logs.
Fire it after the response so it costs no latency. On Cloudflare Workers:
ctx.waitUntil(luchy.trackEvent({ name: 'order:placed', pathname: '/checkout' }));API client (luchy/api)
createServerTracker covers the two calls most apps make. luchy/api
is the whole API: a typed client generated from the dashboard's own OpenAPI
document, with tracking and reading on it. It has no DOM globals and no node
built-ins, so the same import works on a server and in a browser.
import { createLuchyClient } from 'luchy/api';
const luchy = createLuchyClient({
apiKey: LUCHY_API_KEY,
// optional: point at a self-hosted dashboard's API root
endpoint: 'https://dash.luchy.app/api',
onError: (error) => console.warn('[luchy] event dropped', error)
});
// Tracking never rejects, on either side of the wire.
await luchy.trackEvent({
name: 'order:placed',
pathname: '/checkout',
payload: { plan: 'pro', amount: 29 }
});
// Reads do — a chart with silently missing numbers is worse than an error.
const { results } = await luchy.query({
date_range: '30d',
metrics: ['pageviews', 'visitors'],
dimensions: ['event:page']
});Failed reads throw LuchyApiError, which carries the HTTP status and the
error body the API sent. For anything without a convenience method, luchy.api
is the underlying openapi-fetch
client, typed against every documented endpoint:
const { data, error } = await luchy.api.GET('/health');The request and response types come from the document too, so they are worth
importing rather than restating: EventInput, PageviewInput,
IngestSuccess, QueryRequest, QueryResponse, plus the raw paths and
components.
Where the types come from
apps/dash zod route schemas
→ bun run openapi:emit (in apps/dash)
→ packages/tracker/openapi.json
→ bun run generate (in packages/tracker)
→ src/api/schema.d.tsopenapi.json is checked in: it is the wire contract, so a change to the API's
shape shows up as a reviewable diff in the commit that caused it, and the
client can be regenerated without a dashboard running anywhere. schema.d.ts
is generated too — never edit either by hand. Change the zod schemas in
apps/dash, re-run both steps, commit the result.
React Router on Cloudflare (luchy/react-router)
A React Router app already tells you what it did — in the request. Every
console mutation is a form POST whose intent field names it (rotate,
invite-member, create-api-key, …), every JSON API mutation is
discriminated by its method, and every auth verb has a path that names the
operation. So the event does not need to be emitted by hand from a route
module, where it is only tracked once somebody remembers to instrument it: the
Worker derives it from the request it is already holding. One hook, zero
per-route code, and a new intent is tracked the day it is written.
Two lines in the Worker's fetch:
import { createRequestTracker } from 'luchy/react-router';
const tracker = createRequestTracker({
apiKey: LUCHY_API_KEY,
enabled: env.APP_ENV === 'production'
});
export default {
async fetch(request, env, ctx) {
const finish = tracker.begin(request);
const response = await requestHandler(request, loadContext);
finish(response, ctx);
return response;
}
} satisfies ExportedHandler<Env>;begin has to run before React Router gets the request: it is what clones
the body, and once the handler has read it, it is gone. finish schedules
everything else on ctx.waitUntil, so nothing about analytics is on the
response's critical path, and nothing it does can throw into your handler.
Events come out named route:intent — team/:id:invite-member,
applications:create — with ids collapsed to :id, React Router's
single-fetch .data suffix stripped (so a hydrated and a non-hydrated
submission report the same name), and / mapped to home. The payload always
carries status.
| Option | Default | What it does |
| --- | --- | --- |
| apiKey | — | Luchy API key. The public ingest key is fine. |
| endpoint | hosted API | API root, without a trailing slash. |
| enabled | true | When false, everything is a no-op — no clone, no network. |
| ignorePrefixes | ['/__manifest'] | Raw-pathname prefixes to drop. Yours are added to the default, not swapped for it. |
| ignoreRouteSuffixes | [] | Normalized-route suffixes to drop, e.g. /user-keys/validate. |
| ignoreEvents | [] | Fully-formed event names to drop, e.g. notifications:markRead. |
| trackFailures | false | When true, 4xx/5xx are tracked too (tell them apart by status). |
| methodSuffix | false | When true, an intent-less mutation is route:post / route:delete instead of bare route. |
| payload | — | (request, response) => payload, merged over status. Runs inside waitUntil; a rejection costs the payload, not the event. |
| onError | — | The only way to see failures. |
The pieces are exported on their own too — isMutatingMethod,
carriesIntent, normalizeRoute, serverEventName — if you want the naming
without the hook.
Development
Build Scripts
# Build the CDN bundle *and* the importable package entry points
npm run build
# Only the package entry points (dist/index.js, dist/server.js + types)
npm run build:lib
# Upload to R2 (requires Wrangler setup)
npm run upload
# Build and deploy everything
npm run deployFile Sizes
- Minified: 3.0 KB
- Gzipped: 1.7 KB (43% smaller)
- Brotli: 1.5 KB (50% smaller)
Generated Files
dist/script/
├── luchy.js (unminified)
├── luchy.min.js (minified)
├── luchy.js.gz (gzipped)
├── luchy.min.js.gz (gzipped)
├── luchy.js.br (brotli)
└── luchy.min.js.br (brotli)CDN Deployment
R2 Upload Structure
The deploy script uploads files to two locations:
Root Level:
bucket/
├── luchy.js
├── luchy.min.js
├── luchy.js.gz
├── luchy.min.js.gz
├── luchy.js.br
└── luchy.min.js.brVersioned Level:
bucket/v/{commit-hash}/
├── luchy.js
├── luchy.min.js
├── luchy.js.gz
├── luchy.min.js.gz
├── luchy.js.br
└── luchy.min.js.brCDN Usage Examples
<!-- Latest version -->
<script src="https://cdn.luchy.app/luchy.min.js"></script>
<!-- Specific version -->
<script src="https://cdn.luchy.app/v/abc123/luchy.min.js"></script>
<!-- With compression (automatic) -->
<script src="https://cdn.luchy.app/luchy.min.js"></script>Deploying
bun run deploy builds, uploads to R2, purges the CDN cache and then checks
what the edge actually serves.
The purge is not optional. R2 has the new bytes the instant the upload finishes,
but cdn.luchy.app is cached, so skipping it leaves every customer on the
previous build while the deploy prints nothing but green. For that reason the
script refuses to start when CLOUDFLARE_API_TOKEN is missing, rather than
uploading and half-shipping.
The verification step fetches https://cdn.luchy.app/luchy.min.js with no
cache-busting query string on purpose — a unique query string bypasses the edge
cache and would pass even when real visitors are still getting the old script.
Root paths are purged; v/{commit-hash}/ is immutable and never needs it.
Environment Variables
R2_BUCKET: R2 bucket name (default:cdn-luchy-app)CLOUDFLARE_API_TOKEN: required. Needs the Zone > Cache Purge permission on theluchy.appzone.CLOUDFLARE_ACCOUNT_ID: defaults to the account that ownscdn-luchy-app. Pinned becausewrangler r2 object putrefuses to pick between accounts in a non-interactive shell.CLOUDFLARE_ZONE_ID: defaults to theluchy.appzone.
License
MIT
