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

@liiift-studio/sanity-visitor-insights

v0.27.1

Published

Visitor-behaviour analytics for type-foundry Sanity Studios — GA4, Vercel and order data reconciled honestly

Readme

@liiift-studio/sanity-visitor-insights

Visitor-behaviour analytics inside Sanity Studio, for Darden, TDF and MCKL.

This is not a sales tool and does not duplicate the sales portal. It reports what visitors do — where they come from, how far they get, which typefaces they engage with — and reconciles GA4 against Vercel so you can see how much of that behaviour you are actually measuring. Orders appear only as a conversion anchor and a count; no revenue, and no customer data.


What it reports

| Panel | Question it answers | |---|---| | Measurement health | How much of reality does each source actually see? | | Acquisition | Where do visitors come from, and how many are design-industry referrals? | | Journey | How far do visitors get, and where do they stop? | | Typeface interest | Which families get viewed, tested and bought? | | Diagnostics | What to fix before trusting any of the above? |

Each panel offers a week, quarter or year range.


Design decisions worth knowing

These are deliberate and load-bearing. Changing them changes whether the numbers are honest.

Pageviews are compared to pageviews. An earlier design subtracted Vercel pageviews from GA4 sessions and attributed the difference to consent, ad-blockers and bots. Sessions and pageviews are different units, so that difference is dominated by the unit mismatch rather than by anything missing — and GA4 exposes no signal separating those three causes anyway. The panel now compares like with like and labels the remainder unexplained.

Consent is measured, not inferred. A consent_granted event turns the single largest cause of the GA4/Vercel gap into a real number. Until a site instruments it, the panel says so instead of guessing.

Outages are recorded, not just start dates. An event can exist in the code and still stop reaching GA4 — Darden's ecommerce events did exactly that for nine months after a script-loading change. Every one of those days returns an honest zero from the API, and a yearly funnel would render it as a collapse in trade rather than a gap in measurement. eventCutovers therefore accepts { from, outages: [{ start, until, reason }] }, and a range inside an outage reports as unavailable rather than as zero.

A missing metric is never zero. Every figure is a MetricValue tagged union, so an uninstrumented event cannot silently coerce to 0 and be charted as a real trough. Unavailable figures render as an em dash with a stated reason.

The journey is a funnel, not a path. GA4's Data API has no path-exploration endpoint, and runFunnelReport returns step-conversion marginals rather than observed sequences. Drawing a flow diagram from that would assert co-occurrence nobody measured. Steps are reported as independent totals and the response is flagged approximate.

Auth is the Studio's own session token. The Studio forwards its Sanity token and the route verifies it against Sanity. A shared secret would be extractable from the public Studio bundle, moving the bar from "know the URL" to "open devtools", and would give no identity to audit.

No charting library. These panels compare and rank a handful of values, which labelled bars and tables do as well as a chart — without a large Studio-bundle dependency or theme-token bridging for light and dark. Tables are the accessible representation and the visual one, so they cannot drift apart.

No customer data leaves Sanity. Order queries project {_createdAt, orderStatus} and, for the interest panel, dereference only a typeface title. Joining behavioural data to order PII would turn aggregate statistics into personal-data processing.


Diagnostics — run this first

The panels are only as trustworthy as the property behind them, and the ways a GA4 property quietly produces wrong numbers are not visible from the numbers themselves. The Diagnostics panel measures them:

  • GA4 reachable — distinguishes a bad service-account key from missing Viewer access.
  • Timezone matches config — a mismatch shifts day and week boundaries, so GA4, Vercel and Sanity stop agreeing which period an event belongs to.
  • Retention covers a year — probes a window beyond the 2-month default rather than asking you to read the Admin screen. This is the check most likely to explain an empty year range.
  • Configured events fire — catches a cutover map claiming an event is live when GA4 has never seen it, which otherwise produces figures that look real but are not.
  • Purchases carry transaction_id — without it a GA4 purchase cannot be reconciled against a Sanity order at all.
  • GA4 purchases match orders — a large divergence usually means purchase fires on a page some buyers never reach, fires twice, or is being blocked.

It is also the one panel that still says something useful with nothing configured, so it is worth opening the moment credentials land. Available in the Studio, or headless:

import { runDiagnostics } from '@liiift-studio/sanity-visitor-insights/server'

const report = await runDiagnostics({ config, ga4, vercel, sanity })
console.log(report.verdict, report.checks)

Testing without credentials

Test doubles ship from a separate subpath, so a consuming site can exercise these reports before it has anything to point at:

import { createFakeGa4Client, makeGa4Total } from '@liiift-studio/sanity-visitor-insights/testing'

const ga4 = createFakeGa4Client({ batch: () => [makeGa4Total(800), makeGa4Total(300), makeGa4Total(0)] })
const data = await measurementHealth({ config, range, ga4, vercel: null, sanity: null })

The fakes record what they were asked, so a test can assert on the GROQ that ran — which is how this package proves no customer field is ever projected.


Installing

npm install @liiift-studio/sanity-visitor-insights

1. Studio

// sanity.config.ts
import { visitorInsights } from '@liiift-studio/sanity-visitor-insights'

export default defineConfig({
  plugins: [
    visitorInsights({
      apiBaseUrl: 'https://dardenstudio.com',
      siteLabel: 'Darden Studio',
      roles: ['administrator'],
    }),
  ],
})

Pass apiBaseUrl: '' when the Studio is served from the same origin as the site (Darden and TDF). MCKL's Studio deploys separately to mckl.sanity.studio, so it needs the full site URL.

Omit roles to show the tool to every Studio user. These panels read order-derived conversion figures, so gating to administrators is usually right.

2. Site API route

// pages/api/visitor-insights/[report].js
import { createVisitorInsightsHandler } from '@liiift-studio/sanity-visitor-insights/server'
import { client } from '../../../lib/sanityClient'

export default createVisitorInsightsHandler({
  sanityClient: client,
  config: {
    siteId: 'darden',
    label: 'Darden Studio',
    ga4: { propertyId: '123456789', timezone: 'America/New_York' },
    vercel: { projectId: 'prj_...' },
    orders: { documentType: 'order', typefacesField: 'typefaces' },
    eventCutovers: {
      page_view: 'preexisting',
      view_item: 'preexisting',
      add_to_cart: 'preexisting',
      begin_checkout: 'preexisting',
      purchase: 'preexisting',
      consent_granted: null,
      tester_engaged: null,
    },
    // Only needed where the Studio is on a different origin.
    // Enforced server-side, unlike the plugin option of the same name. Set both, to the same list.
    roles: ['administrator'],
    allowedStudioOrigins: ['https://mckl.sanity.studio'],
  },
})

propertyId is the numeric GA4 property id from GA4 Admin, not the G-XXXXXXX measurement id. The config is validated at construction and will throw on deploy rather than silently returning empty charts — pasting a measurement id here is caught by name.

3. Environment variables

| Variable | Where | What | |---|---|---| | VISITOR_INSIGHTS_ENABLED | Site (server) | Required. Any truthy value switches the route on | | VISITOR_INSIGHTS_GA4_SERVICE_ACCOUNT | Site (server) | Service-account JSON, raw or base64 | | VISITOR_INSIGHTS_VERCEL_TOKEN | Site (server) | Vercel API token with project read access | | SANITY_STUDIO_PROJECT_ID | Site (server) | Already set; used to verify Studio tokens |

The service account needs Viewer on each GA4 property. Nothing here is NEXT_PUBLIC_; none of it reaches the browser.

Scoping to your own hostnames

ga4.hostnames restricts every GA4 report to the hosts you name. A GA4 property is not necessarily one website — Darden's also receives impactsport.ca, an unrelated business, which was inside the headline session count, both percentages computed from it, and the funnel's entry rung. Omit the field to accept every hostname, which is the old behaviour.

ga4: {
  propertyId: '123456789',
  timezone: 'America/Los_Angeles',
  hostnames: ['www.dardenstudio.com', 'dardenstudio.com'],
}

Orders: status and revenue

orders.countedStatuses names the statuses that count as a real sale; everything else is excluded and reported separately. Leave it unset and test, failed, pending and refunded orders all count as sales — at seven orders a quarter, one test order is a 14% error. The status breakdown is always reported so you can see what vocabulary your own orders use before configuring this.

orders.totalField turns on revenue. Without it a $30 web licence and a $400 multi-seat desktop licence are the same integer, so nothing can be ranked by what it is worth. An order total is not a customer field; the PII projection allow-list is unchanged.

orders: {
  documentType: 'order',
  typefacesField: 'typefaces',
  statusField: 'orderStatus',
  countedStatuses: ['complete', 'paid'],
  totalField: 'total',
  currency: 'USD',
}

Conversions that are not a sale

eventNames.enquiry, .subscribe and .assetDownload surface outcomes the funnel used to score as drop-offs. A custom commission is worth many multiples of a licence, and the funnel ended at purchase — so the visitor who read three typeface pages and emailed was counted as a leak.

eventNames: {
  enquiry: ['enquiry_submit'],
  subscribe: ['subscribe'],
}

The master switch

VISITOR_INSIGHTS_ENABLED is an explicit opt-in. Unset, the route answers 503 with disabled: true and does no Sanity or GA4 work, and the Studio tool shows a neutral "Switched off" card rather than an error. Set it to anything that is not false, 0, off, no, disabled or empty — a word, a code, true, whatever the site's operator prefers.

This is deliberately the opposite default to SALES_PORTAL_ENABLED, which is on unless the value is literally "false". Mounting this route wires up analytics credentials, so it should not begin answering on a site where nobody chose to switch it on.


Before it will show anything useful

  • Set GA4 data retention to 14 months on every property (Admin → Data Settings → Data Retention). The default may be 2 months, which makes the year range return nothing for most of its span. It does not backfill, so every day at the default is data lost permanently.
  • Register custom dimensions before deploying the events that populate them. A dimension only reports from its registration date forward.
  • Instrument consent_granted and tester_engaged. Without them the measurement-health residual stays unexplained and the journey funnel has a hole where the tester belongs.
  • tester_engaged must require a change from the tester's default state — custom text, or a weight/size change. Firing on tester open counts every page load as a test and makes the viewed-to-tested ratio meaningless.

Development

npm install
npm test          # pure logic plus the client/server boundary guard
npm run build

The boundary test walks the real import graph from each entry point and fails if anything reachable from the Studio entry touches src/server/, node:crypto or process.env. That check exists because the failure it prevents — a credential-reading module shipped in a public Studio bundle — is invisible to type checking and to review.


Architecture

src/
  types.ts          MetricValue, ranges, report envelope
  reportData.ts     Result shapes shared by server and panels, importing neither
  core/             Pure logic: cutovers, range resolution, config validation
  server/           Node only — GA4, Vercel, Sanity, auth, cache, handler factory
  studio/           Browser only — the tool, panels and figure renderers

core/ and reportData.ts are shared. Nothing in studio/ may import from server/, which is what the split export subpaths and the boundary test enforce together.