Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions packages/apps-website/src/components/Stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// The gate and the tag. There is no client behaviour to test — the collector's bundle owns
// pageviews, SPA navigation and DECO events, and it is tested where it lives. What can break
// here is what this component actually decides: whether to render at all, where it points, and
// which attributes it emits.
//
// `renderToStaticMarkup` rather than a DOM render: the component has no effects and no state, so
// mounting it would test React rather than this file.
import { afterEach, describe, expect, it, vi } from "vitest";

const ENV = { ...process.env };

afterEach(() => {
process.env = { ...ENV };
vi.resetModules();
});

/** Re-imported per test, because the gate is read at MODULE LOAD. A test that sets the variable
* after importing would be asserting against the value the previous test left behind — and it
* would pass or fail depending on file order, which is the worst kind of green. */
async function render(props: Record<string, unknown> = {}) {
const { renderToStaticMarkup } = await import("react-dom/server");
const { default: Stats } = await import("./Stats");
const { createElement } = await import("react");
return renderToStaticMarkup(createElement(Stats, props));
}

describe("Stats", () => {
it("renders nothing unless explicitly enabled", async () => {
delete process.env.DECO_ANALYTICS_ENABLED;
expect(await render()).toBe("");

// Not "any truthy value". `ONEDOLLAR_ENABLED` defaults to ON and is disabled with
// "false"; this one defaults to OFF and needs "true". A loose check here would make
// `DECO_ANALYTICS_ENABLED=0` turn analytics on, which is the opposite of what anyone
// setting it to 0 intends.
process.env.DECO_ANALYTICS_ENABLED = "1";
vi.resetModules();
expect(await render()).toBe("");
});

it("points at the same origin by default, and preconnects only when it does not", async () => {
process.env.DECO_ANALYTICS_ENABLED = "true";
const same = await render();
expect(same).toContain('src="/_dq/a.js"');
// A preconnect to the page's own origin is a wasted hint, and on some browsers a
// second connection opened for nothing.
expect(same).not.toContain("preconnect");

vi.resetModules();
process.env.DECO_ANALYTICS_ORIGIN = "https://analytics.example.com";
const cross = await render();
expect(cross).toContain('src="https://analytics.example.com/_dq/a.js"');
expect(cross).toContain("preconnect");
});

it("carries dev and debug as attributes, and omits them when off", async () => {
process.env.DECO_ANALYTICS_ENABLED = "true";
// The whole reason these are attributes: TanStack hoists `<script async>` into `<head>`
// above any inline config block, so a global set alongside the tag loses the race and the
// collector boots into silence with no error.
const on = await render({ dev: true, debug: true });
expect(on).toContain('data-dev="true"');
expect(on).toContain('data-debug="true"');

vi.resetModules();
const off = await render();
// Absent, not `="false"`. The collector treats them the same; a reader of the page source
// does not, and `data-dev="false"` looks like someone decided something.
expect(off).not.toContain("data-dev");
expect(off).not.toContain("data-debug");
});

it("puts the site key in the URL, because that is where the collector reads it", async () => {
process.env.DECO_ANALYTICS_ENABLED = "true";
// Sites behind our edge are identified by the Host header, which a visitor cannot forge.
// Emitting an empty key would put a `tag`-sourced identity on a site that has a
// trustworthy one, and `tag` is the source that must never reach an invoice.
const none = await render();
expect(none).toContain('src="/_dq/a.js"');
expect(none).not.toContain("?k=");

vi.resetModules();
process.env.DECO_ANALYTICS_SITE_KEY = "dq_abc123";
const keyed = await render();
// IN THE QUERY STRING. The collector resolves the site while rendering the bundle, from
// `?k=` -- a key on the element is read by nothing and arrives after the decision. As
// `data-site` this rendered fine, resolved nothing, served the `s:"unknown"` fallback and
// collected zero without an error anywhere.
expect(keyed).toContain("/_dq/a.js?k=dq_abc123");
expect(keyed).not.toContain("data-site");
});

it("uses defer only when asked, async otherwise", async () => {
process.env.DECO_ANALYTICS_ENABLED = "true";
// Nothing visual may depend on this script. `async` is what keeps a slow or failed
// collector from becoming a slow or broken page.
expect(await render()).toContain("async");

vi.resetModules();
const deferred = await render({ defer: true });
expect(deferred).toContain("defer");
expect(deferred).not.toContain("async");
});
});
127 changes: 127 additions & 0 deletions packages/apps-website/src/components/Stats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Stats — deco's first-party analytics collector.
*
* Mount once in `__root.tsx`, alongside or instead of `<OneDollarStats />`:
*
* ```tsx
* <DecoRootLayout … >
* <Stats />
* </DecoRootLayout>
* ```
*
* ## Why this is twenty lines and OneDollarStats is three hundred
*
* Not because it does less — because the work is on the other side. The lilstts SDK
* has no notion of SPA navigation the way this app routes, no notion of the
* `deco_segment` cookie, and no notion of `window.DECO.events`, so the component has
* to wrap `history.pushState`, poll for globals, read and decode the cookie, and
* forward every commerce event by hand.
*
* The deco collector's own bundle already does all of it, and is tested doing it:
* the core module takes the first pageview through the prerender guard, wraps
* `pushState`, `replaceState` and `popstate`, and flushes on `pagehide` and
* `visibilitychange`; the deco module reads `deco_segment` into experiment
* assignments and subscribes to `window.DECO.events`, mapping the commerce
* vocabulary. None of that belongs in a component that would then be a second
* implementation of it, drifting from the first.
*
* So there is no `useEffect` here, and that is the point. Nothing to hydrate, no
* readiness polling, no module-level guard against StrictMode double-mounting —
* because there is no client state to guard.
*
* ## data- attributes, not a global
*
* `dev` and `debug` are read off the tag rather than from `window.__dq`, and this is
* load-bearing on exactly this framework. TanStack hoists `<script async>` into
* `<head>` ABOVE any inline configuration block — measured at byte 190 against byte
* 1108 on a real site. A component that set a global and expected the collector to
* find it would boot into silence here, with no error: the collector would see a
* development host, skip, and say nothing. Attributes cannot lose that race because
* they are on the element that is executing.
*
* ## Off by default
*
* `DECO_ANALYTICS_ENABLED` must be set to `true`. This is the inverse of
* `ONEDOLLAR_ENABLED`, which defaults to on, and the asymmetry is deliberate: one is
* the incumbent and the other is being introduced. The two gates are also
* independent, so a site can run both during a shadow comparison and neither gate
* can turn the other off.
*/

export interface Props {
/**
* Where the collector is published. Empty means same-origin, which is the
* intended deployment: the script and the beacon are served from the site's own
* hostname so no third-party request is involved and nothing is blocked.
*/
origin?: string;
/**
* The site's public key, for sites NOT served through our CDN.
*
* Sites behind our edge are identified by the `Host` header, which a visitor
* cannot forge; those must leave this unset. A key travels in the page source
* where anyone can read and reuse it, so a key-identified site is recorded with
* `site_id_source = tag` and is never billed from.
*/
siteKey?: string;
/** `defer` instead of `async`. Only for a page that needs strict ordering. */
defer?: boolean;
/**
* Collect from localhost. The collector refuses local and private hostnames by
* default, which is why a developer sees nothing until this is on.
*/
dev?: boolean;
/** Log every queued and flushed batch to the console. */
debug?: boolean;
}

/** Same-origin. See {@link Props.origin}. */
export const DEFAULT_ORIGIN = "";

/**
* Opt-in, and independent of `ONEDOLLAR_ENABLED` so both can run at once.
*/
const DECO_ANALYTICS_ENABLED = process.env.DECO_ANALYTICS_ENABLED === "true";
const DECO_ANALYTICS_ORIGIN = process.env.DECO_ANALYTICS_ORIGIN;
const DECO_ANALYTICS_SITE_KEY = process.env.DECO_ANALYTICS_SITE_KEY;

function Stats({ origin, siteKey, defer, dev, debug }: Props) {
if (!DECO_ANALYTICS_ENABLED) return null;

const base = origin ?? DECO_ANALYTICS_ORIGIN ?? DEFAULT_ORIGIN;
const key = siteKey ?? DECO_ANALYTICS_SITE_KEY;

return (
<>
{/*
* Only when the collector is on another origin. A `preconnect` to the page's
* own origin is a wasted hint at best, and on some browsers it is a second
* connection opened for nothing.
*/}
{base ? <link rel="preconnect" href={base} crossOrigin="anonymous" /> : null}
<script
id="deco-analytics"
async={!defer}
defer={defer}
// THE KEY GOES IN THE URL, not in a `data-` attribute. The collector resolves the
// site server-side while RENDERING the bundle -- it reads `?k=` and writes the
// resolved config into the script it returns -- so a key on the element arrives
// far too late to matter. It is also never read: the bundle only looks at
// `data-dev` and `data-debug`.
//
// This was `data-site` and it would have failed the way this project's failures
// always do: the collector resolves nothing, serves the `s:"unknown"` fallback,
// and the site collects exactly zero with no error anywhere. Same shape as the
// bug that once made the entire self-serve tier silent.
src={`${base}/_dq/a.js${key ? `?k=${encodeURIComponent(key)}` : ""}`}
// Rendered only when true. `data-dev="false"` and an absent attribute mean
// the same thing to the collector, and the absent one cannot be mistaken
// for a deliberate setting by someone reading the page source.
data-dev={dev ? "true" : undefined}
data-debug={debug ? "true" : undefined}
/>
</>
);
}

export default Stats;