Ad blockers and CSP
Why a script gets blocked, and the one change that fixes both causes.
There are only two causes
If the snippet is on your page and no data arrives, it is almost always one of two things. An ad blocker refused to load a script from a domain on its blocklist, or your own Content Security Policy refused to load a script from a domain you have not listed.
Both are judging the same thing: the domain the script came from. That is why one change fixes both.
Serve it from your own domain
Add two rewrites on your own server and point the snippet at your own path. The browser then makes an ordinary same domain request, which an ad blocker cannot distinguish from your own files and a Content Security Policy already allows under self.
Between twenty and forty per cent of visitors on a technical audience run a blocker, so this is usually the difference between roughly right numbers and numbers you can act on. Termind sends its data back to whichever origin it was loaded from, so nothing else in your setup changes.
module.exports = {
async rewrites() {
return [
{ source: "/pulse.js", destination: "https://www.termind.tech/t.js" },
{ source: "/v1/:path*", destination: "https://www.termind.tech/v1/:path*" },
];
},
};{
"rewrites": [
{ "source": "/pulse.js", "destination": "https://www.termind.tech/t.js" },
{ "source": "/v1/:path*", "destination": "https://www.termind.tech/v1/:path*" }
]
}On a single page app, put these two rewrites above your catch-all rule. A catch-all that sends every path to index.html will swallow them, and the script will quietly serve your HTML instead of JavaScript.
/pulse.js https://www.termind.tech/t.js 200
/v1/* https://www.termind.tech/v1/:splat 200export default defineNuxtConfig({
routeRules: {
"/pulse.js": { proxy: "https://www.termind.tech/t.js" },
"/v1/**": { proxy: "https://www.termind.tech/v1/**" },
},
});export default {
server: {
proxy: {
"/pulse.js": { target: "https://www.termind.tech/t.js", changeOrigin: true, rewrite: () => "/t.js" },
"/v1": { target: "https://www.termind.tech", changeOrigin: true },
},
},
};location = /pulse.js {
proxy_pass https://www.termind.tech/t.js;
proxy_set_header Host www.termind.tech;
}
location /v1/ {
proxy_pass https://www.termind.tech/v1/;
proxy_set_header Host www.termind.tech;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}<script defer src="/pulse.js" data-key="YOUR_KEY"></script>Do not name the file analytics.js, stats.js, tracking.js or termind.js. Blocklists match those words in the path, which puts you back where you started. Any unremarkable name works: pulse.js, p.js, script.js.
The same two rewrites, on other stacks
Every one of these does the same job: serve our script from a path on your domain, and forward everything under /v1 to us.
const { createProxyMiddleware } = require("http-proxy-middleware");
app.use("/pulse.js", createProxyMiddleware({
target: "https://www.termind.tech", changeOrigin: true, pathRewrite: { "^/pulse.js": "/t.js" },
}));
app.use("/v1", createProxyMiddleware({
target: "https://www.termind.tech", changeOrigin: true,
}));SSLProxyEngine On
ProxyPreserveHost Off
ProxyPass /pulse.js https://www.termind.tech/t.js
ProxyPassReverse /pulse.js https://www.termind.tech/t.js
ProxyPass /v1/ https://www.termind.tech/v1/
ProxyPassReverse /v1/ https://www.termind.tech/v1/handle /pulse.js {
rewrite * /t.js
reverse_proxy https://www.termind.tech {
header_up Host www.termind.tech
}
}
handle /v1/* {
reverse_proxy https://www.termind.tech {
header_up Host www.termind.tech
}
}export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/pulse.js") {
return fetch("https://www.termind.tech/t.js", request);
}
if (url.pathname.startsWith("/v1/")) {
return fetch("https://www.termind.tech" + url.pathname + url.search, request);
}
return fetch(request);
},
};Route::get('/pulse.js', fn () =>
response(Http::get('https://www.termind.tech/t.js')->body())
->header('Content-Type', 'application/javascript'));
Route::any('/v1/{path}', fn (Request $request, $path) =>
Http::withBody($request->getContent(), 'application/json')
->post("https://www.termind.tech/v1/{$path}"))->where('path', '.*');import requests
from flask import Response, request
@app.route("/pulse.js")
def pulse():
upstream = requests.get("https://www.termind.tech/t.js")
return Response(upstream.content, mimetype="application/javascript")
@app.route("/v1/<path:path>", methods=["GET", "POST"])
def collect(path):
upstream = requests.post(
f"https://www.termind.tech/v1/{path}",
data=request.get_data(),
headers={"Content-Type": "application/json",
"User-Agent": request.headers.get("User-Agent", "")},
)
return Response(upstream.content, upstream.status_code)import httpx
from fastapi import Request, Response
@app.get("/pulse.js")
async def pulse():
async with httpx.AsyncClient() as client:
upstream = await client.get("https://www.termind.tech/t.js")
return Response(upstream.content, media_type="application/javascript")
@app.post("/v1/{path:path}")
async def collect(path: str, request: Request):
async with httpx.AsyncClient() as client:
upstream = await client.post(
f"https://www.termind.tech/v1/{path}",
content=await request.body(),
headers={"Content-Type": "application/json",
"User-Agent": request.headers.get("user-agent", "")},
)
return Response(upstream.content, upstream.status_code)Forward the visitor's User-Agent header when you proxy from a server language. It is what fills in the browser and system panels, and a proxy that drops it leaves those blank.
If you have no server to add rewrites to
A purely static site with no rewrite support gets the same result through DNS instead. You point a subdomain of your own site at us, we issue the certificate, and the script serves from your hostname. The Managed tracking domain page has the steps.
Rewrites are the better option when you have the choice. They need no DNS change and no certificate, and they cannot break later because of a DNS edit made for some other reason.
Or just allow our domain in your CSP
If you would rather not proxy anything, add Termind to your Content Security Policy directly. This fixes the CSP problem but not the ad blocker one, so you will still lose the visitors who run a blocker.
script-src 'self' ... https://www.termind.tech
connect-src 'self' ... https://www.termind.techDo not skip connect-src. With only script-src the tag loads and the console stays clean, but every event is blocked on the way back. The dashboard stays empty and nothing tells you why.
Your CSP might live in your framework middleware, your hosting config such as vercel.json, netlify.toml or _headers, your web server config, or a meta tag in your HTML head. Search your project for the words Content-Security-Policy to find which.
Not sure whether you have one at all? Open your site, press F12, and read the Console. A CSP problem always uses the word violates and names the directive that refused.