npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@plukio/emdash-analytics

v0.7.1

Published

Native EmDash plugin that tracks first-party analytics events and routes them to PostHog.

Readme

@plukio/emdash-analytics

A native EmDash plugin that installs PostHog on a site through a same-origin reverse proxy, so ad blockers don't break it.

It is a router, not a data store — events pass through to your own provider account. Each site is fully isolated: shared code, per-site credentials.

What works today

| | State | |---|---| | PostHog SDK through a same-origin reverse proxy | ✅ Verified in a browser, and at the Cloudflare edge — real visitor IP forwarded, cookies stripped, size cap and path guards holding | | Consent gate — one decision, shared by every injected script | ✅ Verified in a browser, by counting the events a first visit and a reload each produce | | Base eventsscroll, click, file_download, search | ✅ Verified in a browser | | Content overlayform_start, form_submit, sign_up | ✅ Verified in a browser, including that a rejected submit produces no conversion | | Commerce overlayview_item, add_to_cart, begin_checkout, purchase | ⚠️ Unit-tested against a real DOM; never run on a storefront | | Lead-gen overlaygenerate_lead, contact | ⚠️ Unit-tested against a real DOM; never run on a lead-gen site | | GA4 destination | ⚠️ Unit-tested against a real DOM; no GA4 property has ever received an event from it | | Session replay, feature flags, exception capture, web vitals | ✅ Enabled by default, configured through the SDK | | Consent banner | ❌ Not built. The gate is wired; the UI is yours. | | SaaS preset | ❌ Not built. A SaaS marketing site is the lead-gen preset today. | | Server-side forward (POST /track → PostHog) | ✅ Built and working — validated, forwarded, with upstream failures surfaced in the admin. Deliberately not used by the browser: browser events go direct to the SDK so session and visitor identity come for free. | | server_event webhook path (POST /track, signed) | ⚠️ Built — signature-authenticated, idempotent, 16 KB. Unit-tested only; no real webhook sender has ever called it. Off until you store a secret. PostHog only, never GA4. |

The ⚠️ features are inert until you switch them on — commerce needs its preset and data-pluk-* markup, GA4 needs a Measurement ID, the webhook path needs a stored secret, and blank is the default in every case. So they cannot affect a site that ignores them. But the browser is where analytics code goes wrong, so treat your first install as the verification, and point GA4 at a throwaway property before a real one. See Limitations.

Upgrade notes for every version are in CHANGELOG.md.

Requirements

| | | |---|---| | emdash | ^0.21.0 (peer) | | react | ^19.0.0 (peer) | | Astro | ^6 |

Install

Installation is two steps. Both are required — the plugin alone does nothing.

1. Register the plugin

EmDash native plugins are compiled into the site bundle at build time; there is no admin-side install. From the site's content/ directory (wherever its astro.config.mjs lives):

pnpm add @plukio/emdash-analytics
// astro.config.mjs
import { analyticsPlugin } from "@plukio/emdash-analytics";

export default defineConfig({
  integrations: [
    emdash({
      // ...existing config...
      plugins: [analyticsPlugin()],
    }),
  ],
});

2. Add the reverse-proxy route

Create src/pages/ph/[...path].ts in the host site:

import { createPostHogProxyHandler } from "@plukio/emdash-analytics/proxy";

export const prerender = false;
export const ALL = createPostHogProxyHandler({ region: "us" });

This file cannot live in the plugin. EmDash 0.21 consumes the raw request body before a plugin route handler runs and always JSON-wraps responses with a hardcoded 200, so a plugin route can neither read PostHog's compressed payloads nor return JavaScript. The proxy logic itself is versioned in the package; this file is a three-line shim over it.

Four things that will silently break ingest if you get them wrong:

  • The directory must be ph, matching PROXY_PATH. Changing one without the other breaks everything with no error.
  • prerender = false is required. A prerendered route cannot proxy.
  • The route must stay publicly reachable. If the site has auth middleware, /ph/* needs an exemption.
  • region must match your PostHog project's region and the region selected in the admin.

The admin page checks for this file on load and shows a prominent warning when it is missing — that check exists because this is the failure most installs actually hit.

Configure

Open the EmDash admin → Analytics.

| Setting | Notes | |---|---| | PostHog Project API Key | Your phc_... key. Stored write-only, but see the note below. | | Region | us or eu. Must match the proxy route and your project. | | Enable tracking | Defaults to on. | | Session replay | Defaults to on. | | Mask all text in replays | Defaults to on. See Session replay privacy. | | Feature flags & experiments | Defaults to on. | | Exception capture | Defaults to on. Console errors are not captured. | | Web vitals | Defaults to on. | | Preset | Base — every site, Content — blog, news, media, Commerce — shops, donations, or Lead-gen — services, trades, professional. Base is always on; the overlay adds that vertical's conversion events. See Events. | | Signup form selector | Content preset only. Blank falls back to the shipped form[data-pluk-signup]. | | Success element selector | Content preset only. Blank falls back to the shipped [data-pluk-signup-success]. | | Success URL parameter | Content preset only, name=value. Blank switches that signal off instead of falling back — a form that never redirects needs a way to say "there is no success URL". A bare name is ignored, since it would match ?subscribed=error. | | Default currency | Commerce preset only. Three-letter ISO 4217 code, used when the markup does not carry data-pluk-currency. Anything else falls back to USD — GA4 silently discards revenue in a currency it does not recognise. | | Enquiry form / success element / success URL parameter | Lead-gen preset only. Same three signals and the same blank semantics as the signup fields above, pointed at the enquiry form. | | Value of one lead | Lead-gen preset only, optional. Blank sends no value — nothing on the page knows it, and a guess would invent revenue. A negative or non-numeric entry is ignored the same way. | | Currency | Shared by the commerce and lead-gen overlays: one site, one currency. | | GA4 Measurement ID | G-…, from your GA4 web data stream. Blank — the default — means no GA4 at all. A malformed value is rejected on save, not shrugged off: a bad currency costs a fallback, but a bad Measurement ID means GA4 collects nothing and does not backfill, so the traffic measured during the typo is gone. See the GA4 destination. |

The API key is publishable. phc_* project keys are designed to be public, and this plugin serves one in page source. The admin stores it write-only so it can't be read back out of an API, but do not treat it as a secret.

Nothing is injected until the API key is set. Without one the page:fragments hook returns nothing at all, so visitors don't even pay for a script fetch.

Settings live in that site's own plugin KV namespace, so credentials never cross between sites.

What it does

When configured, the page:fragments hook injects PostHog's official loader snippet into <head>, initialised with api_host: "/ph". The loader derives its bundle URL from api_host, so it fetches /ph/static/array.js — same-origin, through the proxy, which forwards static/ and array/ to PostHog's asset host and everything else to the ingest host.

Because the real SDK is doing the work, $pageview, $session_id, $device_id, referrer, and UTM properties are correct by construction rather than by a hand-maintained mapping.

capture_pageview is set to history_change, so pageviews fire on pushState/replaceState/popstate as well as first load. That covers sites with and without <ClientRouter>.

The hook injects a second inline script beside the loader: the event runtime — and a third, gtag.js, when a GA4 Measurement ID is stored. Each prepends the same idempotent consent gate, so whichever the browser runs first makes the decision and the others reuse it.

Consent gating

The snippet initialises with opt_out_capturing_by_default: true, then asks /_emdash/api/plugins/pluk-analytics/session (served no-store) whether to opt in. Consent is per-visitor and must never be baked into a cached page, which is why it is a runtime fetch rather than a build-time flag.

The answer is applied from the SDK's loaded callback, and both directions are guarded against the consent already stored in localStorage:

  • shouldTrack: true opts in only if the visitor is not already opted in. opt_in_capturing() captures a billable $opt_in event every time it is called, so an unguarded call costs roughly one extra event per pageview. The initial $pageview is unaffected either way — on a first visit opt_in_capturing() captures it, and on later visits init does so itself once it sees stored consent.
  • shouldTrack: false opts out only if the visitor is currently opted in. Stored consent overrides opt_out_capturing_by_default, so without this a visitor stays tracked forever once opted in. Leaving a never-consented visitor alone also avoids writing a consent record for them.

One consequence worth knowing: because consent is decided server-side after init, a visitor whose consent is withdrawn between loads may still emit one $pageview on the load where the withdrawal is first seen. The opt-out takes effect from that point on.

There is no consent banner — this wires the gate, not the UI.

Events

Alongside PostHog's autocapture, the plugin emits a fixed business vocabulary using GA4's recommended event names. The base layer is always on:

| Event | Fires when | Parameters | |---|---|---| | scroll | The visitor reaches 90% of the page | percent_scrolled | | click | A link to another hostname is clicked | link_url, link_domain, link_text, outbound | | file_download | A link to a document, archive, or media file is clicked | file_extension, file_name, link_text, link_url | | search | The URL carries ?q, ?s, ?search, or ?query | search_term |

Each is capped at one per pageview or one per matching interaction. scroll and search are the per-pageview ones — including once per client-side navigation on a site with <ClientRouter>, which is treated as a new page. click and file_download are per-interaction and uncapped: ten outbound clicks are ten click events. A single link can also match both listeners, so an outbound .pdf emits click and file_download for one user action. mailto: and tel: links are deliberately not counted as outbound clicks; they belong to the deferred lead-gen overlay's contact event, and counting them here would double-count once that ships.

page_view is not emitted — posthog-js already captures $pageview (the snippet sets capture_pageview: "history_change", so it fires on pushState/replaceState/popstate too), and GA4 collects page_view automatically. Emitting our own would double-count on both.

Events dispatch straight through the providers' own browser SDKs — posthog.capture(), and gtag() when GA4 is configured — never through /track. Routing browser events through a server means rebuilding session and visitor identity by hand; going through the SDK means $session_id and $device_id come for free.

The two destinations are independent. A site missing its /ph proxy route never loads the PostHog SDK, and GA4 still receives everything; an ad blocker stops gtag.js, and PostHog still receives everything.

The content overlay

Selecting the Content preset adds newsletter signup tracking:

| Event | Fires when | Parameters | |---|---|---| | form_start | First interaction with the signup form | form_id, form_name, form_destination | | form_submit | The form is submitted | the above plus form_submit_text | | sign_up | The signup is confirmed successful | method, form_name |

form_start is capped at one per pageview and sign_up at one per confirmed success; form_submit is not capped, so a submission rejected and retried counts twice.

form_submit is an attempt, not a conversion. A site that validates server-side will reject some submissions, and counting submits as subscribers overcounts every one of them. sign_up fires only on a confirmed success signal:

  • Success element — the form reveals an element (in-place fetch submits with no navigation). A MutationObserver watches for that transition. An element already visible when the page loads is not a conversion; that is a reload of a server-rendered success block, and the URL parameter is the signal for it.
  • Success URL parameter — the form posts and redirects back with, say, ?subscribed=1. Guarded in sessionStorage, so reloading or sharing that URL does not report a second conversion.

Both are configured in the admin, but only the URL parameter can be switched off: a blank selector falls back to the shipped default, so there is no way to disable that signal from the admin, while a blank URL parameter is preserved and disables it.

Mark up a form with the shipped defaults and no configuration is needed:

<form data-pluk-signup action="/api/subscribe" method="post">…</form>
<div data-pluk-signup-success hidden>Thanks!</div>

The lead-gen overlay

Selecting the Lead-gen preset adds enquiry tracking — for MSPs, trades, real estate, professional services, and SaaS marketing sites: every vertical whose conversion is an enquiry rather than a transaction.

| Event | Fires when | Parameters | |---|---|---| | form_start | First interaction with the enquiry form | form_id, form_name, form_destination | | form_submit | The form is submitted | the above plus form_submit_text | | generate_lead | The enquiry is confirmed successful | method, form_name, and value + currency if you declared one | | contact | A mailto: or tel: link is clicked | method, link_domain |

generate_lead uses exactly the same confirmed-success machinery as sign_up — a success element revealed in place, or a success URL parameter after a redirect — for the same reason: a submit is an attempt, not a lead. Any site validating server-side would otherwise count every rejected and spam submission as a lead.

Mark up a form with the shipped defaults and no configuration is needed:

<form data-pluk-lead action="/api/enquiry" method="post">…</form>
<div data-pluk-lead-success hidden>Thanks — we'll be in touch.</div>

The enquiry form is configured separately from the content overlay's newsletter form, so a site running both points each at its own markup.

contact carries no address and no phone number. A mailto: href is an email address; GA4's terms forbid sending PII and GA4 cannot retract a parameter once it has one. So method is email or phone, and for email link_domain is the mailbox domain only — enough to tell your domains apart, not enough to identify a person. These are the two protocols click deliberately skips, so one click is always exactly one event.

Optionally, what a lead is worth. Set a value in the admin and every generate_lead carries it with your currency. Nothing on a page knows this number, so leaving it blank sends no value at all rather than a guess — and a value is only ever sent paired with a valid currency, since GA4 discards revenue without one. This is the difference between knowing a form was submitted and knowing you received a lead worth $250, which is the entire reason this event layer exists.

Booking widgets are not covered. Calendly and similar embeds are third-party iframes, and a parent page cannot see inside one. A click on the iframe is not a booking and is not reported as one — use the provider's own webhook if you need that signal.

The commerce overlay

Selecting the Commerce preset adds shop and donation events:

| Event | Fires when | Parameters | |---|---|---| | view_item | A page carrying data-pluk-view-item loads | currency, value, items | | add_to_cart | Something inside data-pluk-add-to-cart is clicked | currency, value, items | | begin_checkout | Something inside data-pluk-begin-checkout is clicked | currency, value, coupon, items | | purchase | A page carrying data-pluk-purchase loads | transaction_id, currency, value, tax, shipping, coupon, items |

Unlike a form, a price is not something a selector can find: a page shows $1,299.00 inside a heading and usually never renders the SKU at all. So this overlay reads values your markup declares. Mark what you view and mark what you click:

<article data-pluk-view-item
         data-pluk-item-id="SKU-1"
         data-pluk-item-name="Blue Widget"
         data-pluk-price="$1,299.00">…</article>

<button data-pluk-add-to-cart data-pluk-item-id="SKU-1" data-pluk-price="1299">Add to cart</button>

<main data-pluk-purchase
      data-pluk-transaction-id="ORD-1024"
      data-pluk-value="1299.00"
      data-pluk-currency="USD">
  <li data-pluk-item-id="SKU-1" data-pluk-price="1299" data-pluk-quantity="1">…</li>
</main>

Inside a marked scope, every element with data-pluk-item-id is one line item and may also carry -item-name, -item-brand, -item-category, -item-variant, -price, and -quantity. A scope that carries an item id itself is the only item. Scope-level attributes are data-pluk-value, -currency, -transaction-id, -tax, -shipping, and -coupon. Prices may be written the way they are displayed. The final separator decides how the number reads, by how many digits follow it — so $1,299.00, 1.299,00, 29,99, and 1299 USD all parse correctly, and a European decimal comma is not a special case:

| Digits after the last . or , | Read as | Examples | |---|---|---| | 1 or 2 | decimal point | 19.99, 29,99 → 19.99 / 29.99; 1.299,00, $1,299.00 → 1299.00 | | exactly 3 | digit grouping | 1.299, 1,299 → 1299 | | anything else | not a price — no value sent | 1.2999 |

The three-digit case is the one genuine ambiguity: 1.299 means 1299 in German and 1.299 in English. Grouping wins, because it is far more common and three-decimal prices are rare outside fuel. Write the price with an explicit decimal if that matters to you.

Three behaviours worth knowing before you trust the numbers:

  • purchase requires data-pluk-transaction-id and will not fire without one. It is deduplicated in localStorage, so a bookmarked, reloaded, or shared confirmation page reports the sale exactly once — PostHog does not deduplicate on its own. Use the order number.
  • value is only ever sent together with currency, since GA4 discards revenue that has no currency. If no data-pluk-value is given, the line items are summed; if none of them is priced, no value is sent at all rather than a misleading 0.
  • A donation is a purchase. Give the thank-you page a value, a currency, and the receipt number and it reports as revenue with no items, which is why there is no separate nonprofit preset.

Every event name and parameter is shaped to GA4's constraints — 40-character names, 25 parameters per event, 100-character values, no reserved names — which is what let the GA4 destination be a fan-out rather than a translation layer. String values longer than 100 characters are truncated before dispatch, and empty parameters are dropped rather than sent blank.

The GA4 destination

Enter a GA4 Measurement ID and every event above goes to Google as well as PostHog. Blank — the default — means no GA4 at all: no gtag.js on the page, and no GA4 branch emitted into the runtime.

The vocabulary is not translated on the way out. GA4's recommended event names are the vocabulary, chosen because GA4 is the constrained provider and PostHog accepts anything; designing the other way round does not work.

Five events are deliberately withheld from GA4. Enhanced Measurement already collects scroll, click, file_download, form_start, and form_submit on its own, and sending ours as well would count each twice in the one destination that cannot be corrected afterwards. So they are suppressed on the GA4 branch only — all five still reach PostHog, and the vocabulary is unchanged.

Note the mismatch that makes this trap easy to get wrong: five events come from four toggles, because "Form interactions" is a single switch emitting both form_start and form_submit. A suppression list built by counting toggles silently doubles form_start.

Suppressing ours was chosen over asking every property to switch those toggles off, because no code can verify a per-property setting and it drifts the moment somebody clicks something in Google's admin. The cost is small: scroll fires at 90% because that is Enhanced Measurement's threshold, and the download extension list is decoded from Google's own regex. One genuine divergence — GA4's own outbound-click collection includes mailto:/tel: links, which this plugin holds back for the future lead-gen contact event.

search is a near miss and is not suppressed: Enhanced Measurement reads the same query parameters but reports view_search_results, a different name.

gtag.js is fetched only after consent resolves true. That is stricter than Consent Mode, on purpose: analytics_storage: denied still sends cookieless pings to Google, so not fetching the tag is the only way to promise a non-consenting visitor that nothing reached Google. An unreachable /session sends nothing.

Four constraints worth knowing before you rely on the data:

  • No same-origin proxy is possible. gtag.js is third-party JS and ad blockers stop it, while PostHog keeps working through /ph. This is the one respect in which the GA4 numbers will always be worse than PostHog's on the same site. There is no mitigation.
  • Mark your conversions as key events in the GA4 property, or they are just events. No code can do this for you.
  • A parameter is only readable if you register it. GA4 exposes event names out of the box; reading a custom parameter in reports or the API needs a per-property custom dimension (50 on the free tier, 24–48h lag, no historical data). The taxonomy's value is in the names.
  • Client-side navigation. PostHog covers SPA pageviews itself. GA4 covers them only through Enhanced Measurement → Page views → "page changes based on browser history events" — on by default, but a checkbox. Switched off on a site with <ClientRouter>, GA4 sees one page_view per session and every session reads as a bounce. Emitting our own page_view is not the fix; it would double-count whenever that sub-toggle is on.

If the site already runs GA4 for the same stream — a hand-added tag, or a GTM container — entering that same ID is fine: the existing configuration is detected and left alone, and our events join it. A config inside GTM is invisible from the page and cannot be detected; if that is your setup, expect a duplicated page_view and remove one of the two configs.

Events are gated by the same consent decision as PostHog itself, resolved once per page from /session. Nothing is sent to any provider before it resolves — up to 50 events are queued meanwhile and flushed only on consent — and a /session that cannot be reached is treated as no consent.

Server events (webhooks)

Everything that happens in a browser dispatches straight to the provider SDKs. This path is for the events a page never sees: a payment confirmed after the visitor closed the tab, a CRM marking an enquiry qualified, an order refunded a week later.

It is off until you store a secret. Paste one into Server events in the plugin settings — generate it with openssl rand -hex 32 — and keep it wherever your sending code keeps its credentials. Until then the endpoint rejects every request, and removing the secret turns it off again.

Then POST to /_emdash/api/plugins/pluk-analytics/track:

{
  "event": "purchase",
  "eventId": "evt_1QxYz…",
  "distinctId": "0192a7f1-…",
  "properties": { "transaction_id": "T-1042", "value": 129.5, "currency": "GBP" },
  "timestamp": "2026-08-12T12:00:00.000Z"
}

with an X-Pluk-Signature header. Four rules govern whether it is accepted:

| | | |---|---| | X-Pluk-Signature | v1=<hmac-sha256 hex> over the canonical string below, keyed with your secret. No signature, wrong secret, or a payload edited after signing → 401. | | timestamp | Must be within 5 minutes of our clock, in either direction. Sign at send time; a retry hours later must be re-signed, not re-sent verbatim. | | eventId | Your idempotency key — a Stripe evt_…, an order id, anything stable for one real occurrence. A second delivery of the same id is answered skipped: "duplicate" and forwarded nowhere. The id is claimed before the forward and released again if it fails, so a retry after an outage still lands. | | Size | 16 KB for the validated payload — enough for a real cart's items[]. Undeclared fields are discarded before it is measured, and nothing here caps the raw request body, so this bounds what you can store, not what someone can send. | | Nesting | properties may not nest deeper than 20 levels. A cart is two. |

What comes back, and what to retry

Every success is wrapped in a data envelope by EmDash, so the field you want is body.data.skipped, never body.skipped:

{ "data": { "ok": true, "skipped": "duplicate" } }

Errors are wrapped too, as { "error": { "code": …, "message": … } }.

| Status | Meaning | Retry? | |---|---|---| | 200 | Accepted. Includes the deliberate no-ops: duplicate, disabled, and purchase-source-browser. A refused purchase answers 200 on purpose — anything else turns a misconfiguration into a retry storm. | No. | | 400 | The payload is not the documented shape — a zone-less timestamp, a missing eventId, over 16 KB, properties deeper than 20 levels. EmDash validates the body before this route authenticates, so this answer comes back even with no signature and even from a site storing no secret. | No — fix the payload. | | 401 | No signature, wrong secret, payload edited after signing, timestamp outside the window, or no secret stored on the site. The body is identical in every case by design. | No — re-signing a stale timestamp is a new request, not a retry. | | 502 | We accepted and authenticated your event, and PostHog could not be reached or rejected us. The event was not forwarded and its eventId claim has been released. | Yes — this is the one to retry, and the only reason eventId exists. |

A 502 means the event is genuinely lost unless you send it again — it is the only status worth retrying. Treat any other non-2xx as a bug worth reporting rather than something to hammer.

Signing

The signature covers a canonical string, not the raw bytes: EmDash parses the request body before a plugin route ever sees it, so byte-exact verification is impossible here. Both ends build the same string — six fields joined by newlines, with object keys sorted at every depth:

import { createHmac } from "node:crypto";

// Emits the text directly. Sorting keys into a rebuilt object does NOT work:
// JavaScript holds integer-like keys in numeric order ahead of string keys, so
// the sort is undone on the way out and only non-JS signers notice.
const canonical = (x) => {
  // `Array.from`, not `.map` — `map` skips holes in a sparse array.
  if (Array.isArray(x)) return `[${Array.from(x, (i) => canonical(i) ?? "null").join(",")}]`;
  if (x === null || typeof x !== "object") return JSON.stringify(x);
  const fields = [];
  for (const k of Object.keys(x).sort()) {
    const v = canonical(x[k]);
    if (v !== undefined) fields.push(`${JSON.stringify(k)}:${v}`);
  }
  return `{${fields.join(",")}}`;
};

export function sign(secret, body) {
  const message = [
    "v1",
    body.timestamp,
    body.event,
    body.eventId,
    body.distinctId ?? "",
    canonical(body.properties ?? {}),
  ].join("\n");
  return "v1=" + createHmac("sha256", secret).update(message).digest("hex");
}

Property key order does not matter — both sides sort. None of event, eventId, distinctId or timestamp may contain a line break, and timestamp must state its zone (…Z or …+02:00) — a zone-less string is read as one instant here and a different one by PostHog.

Porting the signer to another language? The reference is JavaScript's JSON.stringify, and four of its habits are easy to miss. Each mismatch produces a 401 with nothing in the logs to explain it:

  • No whitespace. {"a":1,"b":2}, not {"a": 1, "b": 2} — Python's json.dumps needs separators=(",", ":").
  • Numbers are formatted the JavaScript way — ECMA-262 Number::toString (§6.1.6.1.20), not your language's repr. 100.0 serialises as 100 and 1e21 as 1e+21; a float-typed integer amount is the common trap. Two examples are not the rule, and the thresholds differ where you would not expect: JavaScript writes 1e16 as 10000000000000000 and 1e-5 as 0.00001, where Python's repr gives 1e+16 and 1e-05. Route every number through that algorithm — including integers past 2⁵³, since JSON.parse has already made them doubles on our side.
  • Non-ASCII stays literal. Python needs ensure_ascii=False.
  • Keys sort by UTF-16 code unit, which is JavaScript's default string order. Sort them as strings, always: "10" comes before "2", because "1" < "2". This is the trap that bites hardest, because the language that defines the format is the one that gets it wrong — a JavaScript object silently reorders integer-like keys into numeric order, so a signer that sorts keys into a rebuilt object emits {"2":…,"10":…} while a correct port emits {"10":…,"2":…}. Build the JSON text directly, as the snippet above does. Any properties keyed by a number hits this: a cart keyed by line index, a quantity map keyed by SKU. Above the BMP — an emoji in a property key — UTF-16 order also differs from Python's, Go's and Ruby's default.

Three more things live only in the code block above, and a port written from this prose alone will get each of them wrong:

  • The six fields are in a fixed order, and it is not the order of the example payload: the literal string v1, then timestamp, event, eventId, distinctId, and the canonical properties last. An absent distinctId signs as the empty string, and absent properties as {} — not as omitted lines.
  • The digest is lowercase hex, and both the key and the message are UTF-8. The comparison is exact, so uppercase hex fails.
  • Strings escape exactly as JSON.stringify escapes them — including which control characters become \uXXXX.

If in doubt, sign a payload with the snippet above and with your port, and compare the two hex digests before going near a live property. tools/sign-parity/ in this repo is a worked example: a Python signer written from these rules, and a test that holds it byte-identical to the block above across the traps listed here.

Attribution — read this before sending purchase

distinctId is optional, and that is not a shortcut. A payment processor has no visitor identity, and nothing this plugin can do will invent one. So:

  • Pass one and the event joins that visitor. Stash posthog.get_distinct_id() in your order metadata at checkout and hand it back on the webhook. This is the only way a server event stitches to a real session.
  • Omit it and the event is ingested anonymously — counted, queryable, attached to no one, and marked so PostHog does not mint a person profile it will never see again. Honest, and less useful.

Server events never reach GA4. Measurement Protocol without the visitor's own client_id fills sessions, bounceRate and sessionDefaultChannelGroup with numbers that look real and are not, and those are exactly the dimensions read downstream. A GA4-shaped answer here would be worse than none.

Only one path may report purchase

The commerce overlay already fires purchase from a data-pluk-purchase thank-you page, deduping on transaction_id in localStorage. This route dedupes on eventId in the site's own storage. Neither can see the other's record, and PostHog does not deduplicate at all — so a site running both would report every order's revenue twice.

Purchase source in the settings decides which one is live. Leave it on Browser and a signed server purchase is accepted and dropped ({"skipped":"purchase-source-browser"}, with a warning in the log — a 200, because a webhook sender reads anything else as "retry"). Switch it to Server and the browser trigger is removed from the page entirely. Every other event type is unaffected; only purchase is exclusive.

Security and privacy

CSP is weakened by design

Same-origin proxying makes PostHog's script first-party from the browser's perspective. A site with script-src 'self' will silently permit it, where loading from us-assets.i.posthog.com would have required an explicit, auditable allowlist entry. Subresource Integrity is also unavailable, because PostHog updates array.js at will and no hash can be pinned.

This is inherent to same-origin proxying, not a defect in this implementation. Operators who want that guarantee back should serve the SDK from PostHog's origin with an explicit script-src entry and accept the ad-blocker cost.

The inline init snippet needs 'unsafe-inline' or a nonce — unchanged from the previous tracker, so not a regression.

Rate limiting

/ph/* is public and unauthenticated. Three layers, weakest to strongest:

  1. Built-in limiter (default, weak). 300 ingest requests/minute per IP, held in isolate memory. This is per-isolate, not global — Workers run many isolates across many colos and each keeps its own counter. It blunts a single-source flood hitting one isolate and nothing more. Do not rely on it. Static assets are exempt, deliberately: they are cacheable and identical for everyone, and limiting them would break the SDK load for legitimate users behind a shared NAT.
  2. rateLimit option. Back it with a Cloudflare rate-limit binding or Durable Object via locals.runtime.env:
    export const ALL = createPostHogProxyHandler({
      region: "us",
      rateLimit: async (ip) => (await env.MY_LIMITER.limit({ key: ip })).success,
    });
  3. Cloudflare WAF rate-limiting rule on /ph/* — recommended for production. The only control that holds across isolates without extra code.

Request bodies are capped at 1 MiB (maxRequestBytes) and upstream responses at 8 MiB (maxResponseBytes). The request cap is checked against content-length first and re-checked against the bytes that actually arrived, so a missing or dishonest header doesn't get around it.

Cookies and Authorization are stripped before forwarding — the site's own session credentials must never reach PostHog.

Geolocation

The proxy forwards CF-Connecting-IP as X-Forwarded-For. Without that, PostHog geolocates the Cloudflare edge and every visitor resolves to the same city — the single most common reverse-proxy defect, and a silent one. If you see that symptom, header forwarding is broken.

Use PostHog's "Discard IP data" project setting to drop IPs. The SDK's ip config option is deprecated and has no effect.

Session replay privacy

maskAllInputs is already the SDK default, so input values are masked. maskTextSelector is not — by default every name, email, and order detail rendered on the page is recorded verbatim. That is the real exposure.

So "Mask all text in replays" defaults to on. A privacy control that requires the operator to notice it is not a privacy control. Turning it off gives richer replays and records all displayed text; do it knowingly.

Per-element escape hatches for content authors:

| Class | Effect | |---|---| | ph-mask | Masks the element's text. | | ph-no-capture | Blocks the element from the recording entirely. |

Replay starts only after opt_in_capturing(), so the consent gate governs it too.

Do a real content pass before going live on any site with sensitive rendered data. Strict masking helps; it is not a substitute for playing back a recording and looking.

Ad blockers

Same-origin proxying defeats most list-based blockers. It does not defeat all of them — some block on request shape and payload heuristics. Do not promise 100% capture.

Verify an install

  • /ph/static/array.js200 with content-type: application/javascript. A 404 means the route file is missing or misnamed. An EmDash JSON error shape means it is being caught by the plugin API route instead of the site route.
  • View source — an inline <script> in <head> containing your phc_ key and "api_host":"/ph".
  • Network tabPOST /ph/e/ returning 200.
  • PostHog → Activity — a Pageview with correct $current_url, $pathname, $session_id, $device_id, and $referrer.
  • Geo — confirm the event resolves to your actual location, not a datacenter.
  • Cookies — inspect a /ph/e/ request and confirm no site session cookie is on it.

With tracking disabled in the admin, no /ph/e/ requests should fire at all.

With a GA4 Measurement ID set, also confirm:

  • GA4 → Realtime shows page_view once. Twice means the stream is configured twice — most likely a GTM container this plugin cannot see.
  • GA4 → DebugView shows your events with their parameters intact.
  • scroll, click, file_download, form_start, and form_submit each arrive in GA4 exactly once — Google's own copy. Two of any of them means the suppression is not working, and GA4 cannot be corrected after the fact.
  • All five still arrive in PostHog for the same session.
  • With consent withheld, the network panel shows no request to googletagmanager.com at all — not a request carrying denied.

The repository's tools/ga4-check/ automates this: it installs the build, proves the served page carries it, and gives you a console probe that counts what actually leaves the browser by event name.

Routes

Mounted by EmDash at /_emdash/api/plugins/pluk-analytics/:

| Route | Method | Purpose | |---|---|---| | session | GET | Per-visitor consent gate (no-store). | | track | POST | Server events → PostHog forward. Signature required. Payload capped at 16 KB. |

Both are public: true — no session auth, no CSRF. session is called from unauthenticated public pages; track is called by machines that have neither a session nor a CSRF token, and carries its own signature instead.

track is not called by the browser, and it is not how GA4 is delivered — GA4 is a browser destination exactly like PostHog, and only the reverse proxy is PostHog-specific.

The reverse proxy is not a plugin route — it is the host-site route you added in step 2.

Limitations

  • The proxy route is a manual per-site file. Miss it and everything 404s silently. The admin check exists for exactly this.
  • No consent banner. The gate is wired; the UI is not. No DNT check, no external consent-signal integration.
  • A server event never joins a browser session unless you pass a distinctId. Without one it is ingested anonymously — real, countable, and unattributed. No wiring can invent the identity a payment processor never had.
  • Server events reach PostHog only, never GA4. Measurement Protocol without the visitor's own client_id populates sessions, bounceRate and sessionDefaultChannelGroup badly, and those are exactly the dimensions read downstream.
  • A duplicate is possible under truly concurrent delivery. The idempotency claim is taken before the event is forwarded, which narrows the window to two storage writes, but there is no atomic compare-and-set available to close it. Two deliveries of one eventId arriving at the same instant can both land.
  • Idempotency records are kept forever. One small row per forwarded event, with no expiry and no pruning. At a thousand events a day that is a few hundred thousand rows a year.
  • Switching Purchase source does not reach a page that is already built or cached. The browser trigger is removed when a page is rendered, so prerendered or CDN-cached HTML keeps firing purchase until it is rebuilt and purged — and a thank-you page already open in someone's tab keeps firing regardless. Rebuild and purge before you count on the switch.
  • Rotating the secret takes effect instantly, with no grace period. Anything already signed with the old one is rejected. Rotate when the sender's queue is empty, or accept a gap.
  • The webhook path has never been called by a real sender. Its wire contract is now verified against EmDash's genuine route dispatcher — a real 401 for every rejection, a real 502 on a failed forward, a real 200 for the deliberate no-ops — and a Python signer written from this README's rules alone is accepted byte-for-byte. What has still never happened is a delivery from an actual Stripe or CRM account across the public internet, and nothing has confirmed that a real forwarder's clock lands inside the five-minute window.
  • No GA4 property has yet received an event from this plugin. The destination is unit-tested — including tests that distinguish a queued gtag() call from a delivered one — but it has never been pointed at a real property. Verify against a throwaway property before a client's, because GA4 does not rename events and does not backfill.
  • GA4 is ad-blockable and PostHog is not. gtag.js cannot be proxied same-origin, so the two destinations will not agree on totals. Expect GA4 to be lower.
  • GA4 correctness depends on a setting no code can read. The suppression list assumes Enhanced Measurement is collecting those five events. Switch those toggles off in the property and the events go missing from GA4 entirely rather than doubling.
  • Four presets. Base, content, commerce, and lead-gen. The SaaS overlay is designed but not shipped — a SaaS marketing site should select lead-gen, whose conversion is a demo request or a signup rather than a purchase.
  • The lead-gen overlay has not been verified on a live site. Same position as commerce: unit-tested against a real DOM, never run against markup this project did not write. The contact trigger and the value-on-conversion path are the two to watch on a first install.
  • Booking embeds are invisible. Calendly and similar iframes cannot be observed from the parent page, so bookings made inside one are not recorded at all.
  • The commerce overlay has not been verified on a live shop. It is covered by unit tests, including tests that run the emitted runtime against a real DOM, but no production storefront has used it yet. Check the events arrive as expected before trusting revenue numbers from it.
  • Security posture is net worse than pure server-forwarding. Third-party JS runs on every page, and replay collects far more than a handful of chosen fields. That is the accepted trade for the browser-only feature set.

Prove it on a development site before pointing it at a client's production site.

Development

pnpm install
pnpm test          # vitest
pnpm check-types
pnpm build         # cleans and re-emits dist/

To iterate against a real site, install by path instead of by version:

pnpm add @plukio/emdash-analytics@file:/absolute/path/to/emdash-analytics/packages/plugin

Three gotchas, in order of how much time they cost. All three fail the same silent way — the site keeps serving the previous build, which reads exactly like "my fix didn't work":

  1. pnpm build after every source change. The site consumes dist/, not src/.
  2. Re-run pnpm add — not pnpm install — after every build. Despite appearing as a symlink, pnpm snapshots the package into its virtual store and hardlinks the files. Edits in place propagate, but build starts with rm -rf dist, which replaces every file and breaks every link. pnpm install, pnpm install --force, and deleting the store entry all answer Already up to date and change nothing; re-running pnpm add …@file:/absolute/path is what re-copies. (link: behaves identically — it is not a live view of the source directory.) A new file fails louder: Cannot find module .../dist/<newfile>.js.
  3. Delete node_modules/.vite and restart. Astro prebundles the plugin and keeps serving its own copy after pnpm add has correctly replaced dist/. Grepping dist/ passes while the running server is still stale, so this one survives the check for gotcha 2.

Then confirm before trusting any measurement — grep the served HTML for a string only the new build contains. tools/ga4-check/install.sh in the repository does all of this, including a hash comparison against the built files.

License

MIT