feat(sdk): first-party collection, fetch-first transport, optional payload encryption - #9
Merged
Merged
Conversation
…yload encryption Three related changes, all driven by the same problem: ad blockers match a request by its DOMAIN, so the /web/ingest rename bought time but cannot outrun a rule written against api.linkrunner.io. FIRST-PARTY COLLECTION `scriptSrc` on LinkrunnerScript so the bundle can be served from the customer's own origin too. An endpoint on their domain fed by a script on ours only moves the failure one step earlier. README documents both routes: a reverse proxy on their origin (strongest — nothing to uncloak) and a CNAME'd subdomain (one DNS record, but uBO on Firefox and Brave resolve the chain). FETCH-FIRST TRANSPORT sendBeacon is demoted to a fallback. Measured on a live customer page with a mainstream blocker installed: a beacon to the collector was cancelled while a fetch carrying the same bytes to the same url in the same second went through and got a real response. Filter lists match the beacon/ping resource type separately. The usual argument for beacon-first — surviving unload — does not apply: keepalive gives fetch the same guarantee and this SDK has no unload handler. fetch also reports a status, which is what makes a misconfigured first-party proxy visible instead of silent. PAYLOAD ENCRYPTION (off by default) ECIES over P-256: ephemeral key per page load, ECDH to our public key, HKDF-SHA256, AES-256-GCM with a fresh IV per event. Only a public key ships in the bundle. Both key constants are empty here, and empty means cleartext exactly as before — there is deliberately no placeholder, since a wrong key would produce envelopes nothing can decrypt and lose events silently. It is documented as what it is: payload confidentiality from TLS-terminating hops between the browser and us. It does NOT help with ad blockers, which cancel the request before a body exists — tested three ways, cleartext and encrypted were indistinguishable in every case. Also: a local dev collector (npm run dev:collector) that serves the demo page, the SDK unbuilt from src/core.js, and a decrypting endpoint, so the whole loop runs without a backend. Releases 0.1.14. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tashviks
force-pushed
the
tashvik/first-party-collection
branch
from
August 20, 2026 11:58
405dff8 to
f2c572c
Compare
Moving to a CNAME'd subdomain meant a site changed two things — the script src and the endpoint — and the second is the one that gets forgotten. Forgetting it looks exactly like working: the bundle loads first-party, the beacon keeps going to api.linkrunner.io, and only the network tab says so. When the script is served from /web/v1/ on a host that is not our CDN, the collector is on that same host by construction, so the endpoint is derived from it. One attribute to change instead of two. Matched on the full /web/v1/ path rather than the host, because a site reverse-proxying the bundle at a path of its own choosing has chosen its collector path too and guessing /web/ingest there would be wrong. Our CDN is excluded explicitly — it serves the bundle and no collector. Explicit config still wins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removed for two reasons, either of which is sufficient. It could silently break an existing customer. Derivation fired whenever the bundle was served from /web/v1/ on any host that is not our CDN — so a customer self-hosting at that path would have had their events redirected to <their-host>/web/ingest and 404'd, with nothing logged anywhere. The payload carries no SDK version, so there is no query that tells us whether anyone is in that position. Low probability, unmeasurable, silent data loss. And it bought nothing. The justification was "one attribute instead of two", but that compared against a strawman: without a bundle proxy the customer only ever needed one attribute either way — set data-endpoint, or change src. The derivation only saves anything if the script is ALSO served first-party, and the companion ops change no longer does that, because the bundle is measurably not blocked. scriptSrc stays: a customer proxying the bundle on their own origin (Option 1) still needs it. What goes is the inference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…serving data-endpoint lets a site collect from a host it owns, which is the only thing that survives an ad blocker's rule against our domain. The hazard is that a CNAME and a certificate look like success while the route behind them is not live: the LB hands an unmatched path to the branded-link handler, which is GET-only and answers 405 to both the preflight and the POST. Measured today, api.linkrunner.io/web/ingest answers 400 with a 204 preflight while an unrouted customer host answers 405 with no CORS headers at all. Without a retry, setting data-endpoint one deploy too early takes a site's events to zero, and nothing in the payload says why. So a first-party endpoint that fails outright, or answers 404 or 405, is retried once against the default. 400 and 5xx are deliberately excluded. Both come from our own backend, which either host reaches: a 400 would be rejected identically on the retry, and a 5xx may already have enqueued the event, so retrying would double-count it. The retry cannot itself retry -- shouldFallBack is false once the endpoint IS the default, which also stops a hard outage becoming a resend loop from every page running the bundle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tashviks
force-pushed
the
tashvik/first-party-collection
branch
from
August 20, 2026 17:56
5a94e12 to
2d231bb
Compare
…arty table data-endpoint is the better mechanism and it already exists, but using it means the customer edits their page, and until they do they keep posting to a domain the blocklists match. The table moves them with no change on their side, because the bundle is served from our CDN behind a max-age=0, must-revalidate alias. Precedence is window config, then data-endpoint, then the table, then the default, so a customer in the table can still move or revert themselves without waiting on a release. The lookup is typeof-guarded: a token of 'constructor' or 'toString' would otherwise resolve up the prototype chain to a function and be handed to fetch as a URL. Seeds it with Playo, whose app.playo.co is already CNAME'd here with a valid certificate. The token is not a secret -- it sits in the page source of every page they serve -- so carrying it here exposes nothing new. Ship ops#468 first. A CNAME and a certificate are not enough to serve the collector: until the route is live the LB hands /web/ingest to the branded-link handler, which is GET-only and answers 405 to both the preflight and the POST. The retry added in the previous commit is what keeps that from costing events. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Releases 0.1.14. Three related changes, all from the same finding: ad blockers match a request by its domain, so the
/web/ingestrename bought time but cannot outrun a rule written againstapi.linkrunner.io.What the measurement showed
On a live Playo page with a mainstream blocker installed:
api.linkrunner.ioapp.playo.co(CNAME to the same server)api.linkrunner.ioTwo things fall out of that, and they're the first two changes below.
1. Fetch-first transport
sendBeaconis demoted to a fallback. The beacon was cancelled while afetchcarrying the same bytes to the same URL in the same second went through and got a real response — filter lists match the beacon/ping resource type separately, and ours is matched. Every event sent by beacon on those browsers was being dropped.The usual argument for beacon-first is surviving page unload. It doesn't apply here:
keepalivegivesfetchthe same guarantee, and this SDK has no unload handler — events fire on page view and explicittrack()calls, during normal page life.fetchalso reports a status, which is what makes a misconfigured first-party proxy visible instead of silent.This is the fix that needs no infrastructure. It recovers blocked events on the current domain today.
2. First-party collection
New
scriptSrcprop onLinkrunnerScriptso the bundle can be served from the customer's own origin too — an endpoint on their domain fed by a script on ours only moves the failure one step earlier.README documents both routes: a reverse proxy on their own origin (strongest — a same-origin path has nothing to uncloak) and a CNAME'd subdomain (one DNS record, but uBO on Firefox and Brave resolve the chain and would find us at the end of it). Includes Next.js / nginx / Cloudflare Worker snippets, the visitor-IP contract, and how to verify.
3. Payload encryption (off by default)
ECIES over P-256: ephemeral key pair per page load, ECDH to our public key, HKDF-SHA256, AES-256-GCM with a fresh IV per event. Only a public key ships in the bundle.
Both key constants are empty, and empty means cleartext exactly as before — there is deliberately no placeholder, since a wrong key would produce envelopes nothing can decrypt and lose events silently.
It is documented as what it is — payload confidentiality from TLS-terminating hops — and explicitly not as an anti-blocking measure. Cleartext and encrypted were indistinguishable in every cell above, because the request is cancelled before a body exists.
Costs, measured on a real 3.2 KB page view: +1.8 KB bundle, one ECDH per page load (not per event), payload grows ~39% (3,195 B → 4,430 B), and your own devtools show an opaque envelope.
Also
npm run dev:collector— a zero-dependency local harness serving the demo page, the SDK unbuilt fromsrc/core.js, and a decrypting endpoint, so the whole loop runs without a backend. Includes an on-page "On the wire" panel showing the exact bytes sent.Testing
npm test→ 22 passing, verified over 8 consecutive runs.test/payload-encryption.test.jsloads the real shippedcore.js, lets it seal a genuine page view, and decrypts it with the same primitives the Node collector uses — proving the two implementations interoperate rather than each agreeing with itself. Also fixed a flake: async waits were bounded by a fixed iteration count, which loses races against ECDH keygen under load; they're deadline-based now.Merge order — please read
Merging this to
mainfiresdeploy-cdn.ymland browsers pick up the new bundle. The transport change is safe on its own. If you also intend to point a customer at their own subdomain, the Caddy route must be live first or those requests 405:🤖 Generated with Claude Code