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
| Platform | Serve the script | Forward events (Scale) |
|---|---|---|
| Next.js | rewrites in next.config.js | Route handler |
| Vercel | vercel.json rewrite | Vercel Function |
| Netlify | _redirects | Netlify Function |
| Cloudflare Worker | Worker route | Same Worker |
| Nginx | location block | Through your app server |
| Caddy | reverse_proxy | Through your app server |
| PHP | A PHP file | A PHP file |
| Flask | Route | Route |
| FastAPI | Route | Route |
| Laravel | Route | Route |
| Firebase Hosting | Cloud Function | Same function |
| DigitalOcean App Platform | Service component | Same 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 toapi.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 200Then point the tag at your path and set the events base:
<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.
- Create an API key with the
eventsscope (see Authentication and API keys). - Add a route at
/m/api/eon your domain that forwards each request as below. - 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.