MenuProxy overview

Proxy guides

Proxy overview

Serve a.js from your own domain, and send events through your own server without breaking visitor counts or country data.

You can serve the script from your own domain, and on the Scale plan you can send events through your own server too. Read the warning below before you add a plain rewrite for the events endpoint.

Prefer not to run anything? The managed proxy serves the script and events from a subdomain of yours with one CNAME record.

Pick your platform

PlatformServe the scriptForward events (Scale)
Next.jsrewrites in next.config.jsRoute handler
Vercelvercel.json rewriteVercel Function
Netlify_redirectsNetlify Function
Cloudflare WorkerWorker routeSame Worker
Nginxlocation blockThrough your app server
Caddyreverse_proxyThrough your app server
PHPA PHP fileA PHP file
FlaskRouteRoute
FastAPIRouteRoute
LaravelRouteRoute
Firebase HostingCloud FunctionSame function
DigitalOcean App PlatformService componentSame service

What a proxy does and does not fix for ad blockers

Blocklists match two things: the script URL and the domain events are sent to.

  • Serving only the script from your domain gets it past rules that match api.productlense.app/a.js. Events still go to api.productlense.app, so a blocker that lists that domain still drops them.
  • Forwarding events as well makes every request first-party. This is the only setup that survives domain-based blocking, and it needs an API key, which is why it is on the Scale plan.

Pick a neutral path such as /js/a.js or /assets/m.js. Paths containing words like analytics, tracker or productlense are on many lists.

How the script picks its endpoints

The script posts events to <base>/api/e. The base is the data-api attribute when set, otherwise the origin the script was loaded from. So if you serve the script from https://example.com/js/a.js without data-api, events go to https://example.com/api/e, which is probably your own application's route.

Option 1: serve only the script (any plan)

Serve a.js from your domain and keep events going straight to ProductLense with data-api.

next.config.js

module.exports = {
  async rewrites() {
    return [{ source: '/js/a.js', destination: 'https://api.productlense.app/a.js' }];
  },
};

vercel.json

{
  "rewrites": [{ "source": "/js/a.js", "destination": "https://api.productlense.app/a.js" }]
}

_redirects

/js/a.js  https://api.productlense.app/a.js  200

Then point the tag at your path and set the events base:

HTML
<script defer data-site="SITE_ID" data-api="https://api.productlense.app" src="/js/a.js"></script>

ProductLense serves a.js with Cache-Control: public, max-age=86400; keep that or shorter on your side so updates reach your visitors within a day.

Option 2: send events through your server (Scale plan)

Your server receives the browser's batches and forwards them to the Events API, adding the visitor's real IP and user agent to every event. The Events API accepts ip and ua per event, so visitor ids and countries stay correct.

  1. Create an API key with the events scope (see Authentication and API keys).
  2. Add a route at /m/api/e on your domain that forwards each request as below.
  3. Set data-api="https://example.com/m" on the script tag.

server.js (Express)

import express from 'express';

const app = express();
app.set('trust proxy', true); // req.ip is the visitor's address behind your load balancer

app.post('/m/api/e', express.text({ type: '*/*', limit: '8kb' }), async (req, res) => {
  let body;
  try {
    body = JSON.parse(req.body);
  } catch {
    return res.sendStatus(400);
  }
  const extra = { ip: req.ip, ua: req.get('user-agent') ?? '' };
  const out = body.b ? { ...body, b: body.b.map((e) => ({ ...e, ...extra })) } : { ...body, ...extra };
  const r = await fetch('https://api.productlense.app/api/v1/events', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.PL_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(out),
  });
  res.sendStatus(r.status);
});

proxy.go

func productLenseProxy(w http.ResponseWriter, r *http.Request) {
	var body map[string]any
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&body); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	ip, _, _ := net.SplitHostPort(r.RemoteAddr) // use your load balancer's client IP header if you have one
	add := func(e map[string]any) { e["ip"], e["ua"] = ip, r.UserAgent() }
	if batch, ok := body["b"].([]any); ok {
		for _, e := range batch {
			if m, ok := e.(map[string]any); ok {
				add(m)
			}
		}
	} else {
		add(body)
	}
	out, _ := json.Marshal(body)
	req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, "https://api.productlense.app/api/v1/events", bytes.NewReader(out))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("PL_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		http.Error(w, "upstream error", http.StatusBadGateway)
		return
	}
	defer resp.Body.Close()
	w.WriteHeader(resp.StatusCode)
}

The browser sends text/plain bodies through navigator.sendBeacon, so read the raw body rather than relying on a JSON body parser. The Events API checks that the site id in the body belongs to the key's site and that the page hostname is one of the site's hostnames.

    ↑ ↓ to move, Enter to open